OLD | NEW |
---|---|
(Empty) | |
1 // Copyright 2016 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 package main | |
6 | |
7 /* | |
8 Generate the tasks.json file. | |
9 */ | |
10 | |
11 import ( | |
12 "bytes" | |
13 "encoding/json" | |
14 "fmt" | |
15 "io/ioutil" | |
16 "os" | |
17 "path" | |
18 "path/filepath" | |
19 "strings" | |
20 | |
21 "github.com/skia-dev/glog" | |
22 "go.skia.org/infra/go/common" | |
23 "go.skia.org/infra/go/util" | |
24 "go.skia.org/infra/task_scheduler/go/specs" | |
25 ) | |
26 | |
27 const ( | |
28 DEFAULT_OS = "Ubuntu" | |
29 | |
30 // Pool for Skia bots. | |
31 POOL_SKIA = "Skia" | |
32 | |
33 // Name prefix for upload jobs. | |
34 PREFIX_UPLOAD = "Upload" | |
35 ) | |
36 | |
37 var ( | |
38 // "Constants" | |
39 | |
40 // Top-level list of all jobs to run at each commit. | |
41 JOBS = []string{ | |
42 "Build-Ubuntu-GCC-x86_64-Release-GN", | |
43 "Perf-Ubuntu-GCC-GCE-CPU-AVX2-x86_64-Release-GN", | |
44 "Test-Ubuntu-GCC-GCE-CPU-AVX2-x86_64-Release-GN", | |
45 } | |
46 | |
47 // UPLOAD_DIMENSIONS are the Swarming dimensions for upload tasks. | |
48 UPLOAD_DIMENSIONS = []string{ | |
49 fmt.Sprintf("pool:%s", POOL_SKIA), | |
50 "os:Ubuntu", | |
51 "gpu:none", | |
52 "cpu:x86-64-avx2", | |
53 } | |
54 | |
55 // Defines the structure of job names. | |
56 jobNameSchema *JobNameSchema | |
57 | |
58 // Caches CIPD package info so that we don't have to re-read VERSION | |
59 // files. | |
60 cipdPackages = map[string]*specs.CipdPackage{} | |
61 | |
62 // Path to the infra/bots directory. | |
63 infrabotsDir = "" | |
64 ) | |
65 | |
66 // deriveCompileTaskName returns the name of a compile task based on the given | |
67 // job name. | |
68 func deriveCompileTaskName(jobName string, parts map[string]string) string { | |
69 if parts["role"] == "Housekeeper" { | |
70 return "Build-Ubuntu-GCC-x86_64-Release-Shared" | |
71 } else if parts["role"] == "Test" || parts["role"] == "Perf" { | |
72 task_os := parts["os"] | |
73 ec := parts["extra_config"] | |
74 if task_os == "Android" { | |
dogben
2016/09/30 17:35:14
Did you intend to set task_os in this if?
borenet
2016/09/30 19:13:16
Yep, thanks!
| |
75 if ec == "Vulkan" { | |
76 ec = "Android_Vulkan" | |
77 } else if !strings.Contains(ec, "GN_Android") { | |
78 ec = task_os | |
79 } | |
80 } else if task_os == "iOS" { | |
81 ec = task_os | |
82 task_os = "Mac" | |
83 } else if strings.Contains(task_os, "Win") { | |
84 task_os = "Win" | |
85 } | |
86 name, err := jobNameSchema.MakeJobName(map[string]string{ | |
87 "role": "Build", | |
88 "os": task_os, | |
89 "compiler": parts["compiler"], | |
90 "target_arch": parts["arch"], | |
91 "configuration": parts["configuration"], | |
92 "extra_config": ec, | |
93 }) | |
94 if err != nil { | |
95 glog.Fatal(err) | |
96 } | |
97 return name | |
98 } else { | |
99 return jobName | |
100 } | |
101 } | |
102 | |
103 // swarmDimensions generates swarming bot dimensions for the given task. | |
104 func swarmDimensions(parts map[string]string) []string { | |
105 if parts["extra_config"] == "SkiaCT" { | |
106 return []string{ | |
107 "pool:SkiaCT", | |
108 } | |
109 } | |
110 d := map[string]string{ | |
111 "pool": "Skia", | |
dogben
2016/09/30 17:35:14
nit: fmt.Sprintf("pool:%s", POOL_SKIA)
(Although
borenet
2016/09/30 19:13:16
Done-ish. Kept POOL_SKIA since it's used in multip
| |
112 } | |
113 if os, ok := parts["os"]; ok { | |
114 d["os"] = os | |
115 } else { | |
116 d["os"] = DEFAULT_OS | |
117 } | |
118 if strings.Contains(d["os"], "Win") { | |
119 d["os"] = "Windows" | |
120 } | |
121 if parts["role"] == "Test" || parts["role"] == "Perf" { | |
122 if strings.Contains(parts["os"], "Android") { | |
123 // For Android, the device type is a better dimension | |
124 // than CPU or GPU. | |
125 d["device_type"] = map[string]string{ | |
126 "AndroidOne": "sprout", | |
127 "GalaxyS3": "m0", // "smdk4x12", Detected i ncorrectly by swarming? | |
128 "GalaxyS4": "", // TODO(borenet,kjlubick) | |
dogben
2016/09/30 17:35:14
In swarm_trigger.py, this is None, which I'm guess
borenet
2016/09/30 19:13:16
Yeah, I'm not sure what the behavior is in either
| |
129 "GalaxyS7": "heroqlteatt", | |
130 "NVIDIA_Shield": "foster", | |
131 "Nexus10": "manta", | |
132 "Nexus5": "hammerhead", | |
133 "Nexus6": "shamu", | |
134 "Nexus6p": "angler", | |
135 "Nexus7": "grouper", | |
136 "Nexus7v2": "flo", | |
137 "Nexus9": "flounder", | |
138 "NexusPlayer": "fugu", | |
139 }[parts["model"]] | |
140 } else if strings.Contains(parts["os"], "iOS") { | |
141 d["device"] = map[string]string{ | |
142 "iPad4": "iPad4,1", | |
143 }[parts["model"]] | |
144 // TODO(borenet): Replace this hack with something | |
145 // better. | |
146 d["os"] = "iOS-9.2" | |
147 } else if parts["cpu_or_gpu"] == "CPU" { | |
148 d["gpu"] = "none" | |
149 d["cpu"] = map[string]string{ | |
150 "AVX": "x86-64", | |
151 "AVX2": "x86-64-avx2", | |
152 "SSE4": "x86-64", | |
153 }[parts["cpu_or_gpu_value"]] | |
154 if strings.Contains(parts["os"], "Win") && parts["cpu_or _gpu_value"] == "AVX2" { | |
155 // AVX2 is not correctly detected on Windows. Fa ll back on other | |
156 // dimensions to ensure that we correctly target machines which we know | |
157 // have AVX2 support. | |
158 d["cpu"] = "x86-64" | |
159 d["os"] = "Windows-2008ServerR2-SP1" | |
160 } | |
161 } else { | |
162 d["gpu"] = map[string]string{ | |
163 "GeForce320M": "10de:08a4", | |
164 "GT610": "10de:104a", | |
165 "GTX550Ti": "10de:1244", | |
166 "GTX660": "10de:11c0", | |
167 "GTX960": "10de:1401", | |
168 "HD4000": "8086:0a2e", | |
169 "HD4600": "8086:0412", | |
170 "HD7770": "1002:683d", | |
171 "iHD530": "8086:1912", | |
172 }[parts["cpu_or_gpu_value"]] | |
173 } | |
174 } else { | |
175 d["gpu"] = "none" | |
176 } | |
177 rv := make([]string, 0, len(d)) | |
178 for k, v := range d { | |
179 rv = append(rv, fmt.Sprintf("%s:%s", k, v)) | |
180 } | |
181 return rv | |
dogben
2016/09/30 17:35:14
Sort?
borenet
2016/09/30 19:13:16
Done.
| |
182 } | |
183 | |
184 // getCipdPackage finds and returns the given CIPD package and version. | |
185 func getCipdPackage(assetName string) *specs.CipdPackage { | |
186 if pkg, ok := cipdPackages[assetName]; ok { | |
187 return pkg | |
188 } | |
189 versionFile := path.Join(infrabotsDir, "assets", assetName, "VERSION") | |
190 contents, err := ioutil.ReadFile(versionFile) | |
191 if err != nil { | |
192 glog.Fatal(err) | |
193 } | |
194 version := strings.TrimSpace(string(contents)) | |
195 pkg := &specs.CipdPackage{ | |
196 Name: fmt.Sprintf("skia/bots/%s", assetName), | |
197 Path: assetName, | |
198 Version: fmt.Sprintf("version:%s", version), | |
199 } | |
200 if assetName == "win_toolchain" { | |
201 pkg.Path = "t" // Workaround for path length limit on Windows. | |
202 } | |
203 cipdPackages[assetName] = pkg | |
204 return pkg | |
205 } | |
206 | |
207 // compile generates a compile task. | |
208 func compile(cfg *specs.TasksCfg, name string, parts map[string]string) string { | |
209 // Collect the necessary CIPD packages. | |
210 pkgs := []*specs.CipdPackage{} | |
211 | |
212 // Android bots require a toolchain. | |
213 if strings.Contains(name, "Android") { | |
214 pkgs = append(pkgs, getCipdPackage("android_sdk")) | |
215 if strings.Contains(name, "Mac") { | |
216 pkgs = append(pkgs, getCipdPackage("android_ndk_darwin") ) | |
217 } else { | |
218 pkgs = append(pkgs, getCipdPackage("android_ndk_linux")) | |
219 } | |
220 } | |
221 | |
222 // Clang on Linux. | |
223 if strings.Contains(name, "Ubuntu") && strings.Contains(name, "Clang") { | |
224 pkgs = append(pkgs, getCipdPackage("clang_linux")) | |
225 } | |
226 | |
227 // Windows toolchain. | |
228 if strings.Contains(name, "Win") { | |
229 pkgs = append(pkgs, getCipdPackage("win_toolchain")) | |
230 if strings.Contains(name, "Vulkan") { | |
231 pkgs = append(pkgs, getCipdPackage("win_vulkan_sdk")) | |
232 } | |
233 } | |
234 | |
235 // Add the task. | |
236 cfg.Tasks[name] = &specs.TaskSpec{ | |
237 CipdPackages: pkgs, | |
238 Dimensions: swarmDimensions(parts), | |
239 ExtraArgs: []string{ | |
240 "--workdir", "../../..", "swarm_compile", | |
241 fmt.Sprintf("buildername=%s", name), | |
242 "mastername=fake-master", | |
243 "buildnumber=2", | |
244 "slavename=fake-buildslave", | |
245 fmt.Sprintf("swarm_out_dir=%s", specs.PLACEHOLDER_ISOLAT ED_OUTDIR), | |
246 fmt.Sprintf("revision=%s", specs.PLACEHOLDER_REVISION), | |
247 }, | |
248 Isolate: "compile_skia.isolate", | |
249 Priority: 0.8, | |
250 } | |
251 return name | |
252 } | |
253 | |
254 // recreateSKPs generates a RecreateSKPs task. | |
255 func recreateSKPs(cfg *specs.TasksCfg, name string) string { | |
256 // TODO | |
257 return name | |
258 } | |
259 | |
260 // ctSKPs generates a CT SKPs task. | |
261 func ctSKPs(cfg *specs.TasksCfg, name string) string { | |
262 // TODO | |
263 return name | |
264 } | |
265 | |
266 // housekeeper generates a Housekeeper task. | |
267 func housekeeper(cfg *specs.TasksCfg, name, compileTaskName string) string { | |
268 // TODO | |
269 return name | |
270 } | |
271 | |
272 // test generates a Test task. | |
dogben
2016/09/30 17:35:14
nit: say what return value is
same for perf
borenet
2016/09/30 19:13:16
Done here and elsewhere.
| |
273 func test(cfg *specs.TasksCfg, name string, parts map[string]string, compileTask Name string, pkgs []*specs.CipdPackage) string { | |
274 cfg.Tasks[name] = &specs.TaskSpec{ | |
275 CipdPackages: pkgs, | |
276 Dependencies: []string{compileTaskName}, | |
277 Dimensions: swarmDimensions(parts), | |
278 ExtraArgs: []string{ | |
279 "--workdir", "../../..", "swarm_test", | |
280 fmt.Sprintf("buildername=%s", name), | |
281 "mastername=fake-master", | |
282 "buildnumber=2", | |
283 "slavename=fake-buildslave", | |
284 fmt.Sprintf("swarm_out_dir=%s", specs.PLACEHOLDER_ISOLAT ED_OUTDIR), | |
285 fmt.Sprintf("revision=%s", specs.PLACEHOLDER_REVISION), | |
286 }, | |
287 Isolate: "test_skia.isolate", | |
288 Priority: 0.8, | |
289 } | |
290 // Upload results if necessary. | |
291 skipUploadBots := []string{ | |
292 "ASAN", | |
293 "Coverage", | |
294 "MSAN", | |
295 "TSAN", | |
296 "UBSAN", | |
297 "Valgrind", | |
298 } | |
299 upload := true | |
300 for _, s := range skipUploadBots { | |
301 if strings.Contains(name, s) { | |
302 upload = false | |
303 break | |
304 } | |
305 } | |
306 if upload { | |
307 uploadName := fmt.Sprintf("%s%s%s", PREFIX_UPLOAD, jobNameSchema .Sep, name) | |
308 cfg.Tasks[uploadName] = &specs.TaskSpec{ | |
309 Dependencies: []string{name}, | |
310 Dimensions: UPLOAD_DIMENSIONS, | |
311 ExtraArgs: []string{ | |
312 "--workdir", "../../..", "upload_dm_results", | |
313 fmt.Sprintf("buildername=%s", name), | |
314 "mastername=fake-master", | |
315 "buildnumber=2", | |
316 "slavename=fake-buildslave", | |
317 fmt.Sprintf("swarm_out_dir=%s", specs.PLACEHOLDE R_ISOLATED_OUTDIR), | |
318 fmt.Sprintf("revision=%s", specs.PLACEHOLDER_REV ISION), | |
319 }, | |
320 Isolate: "upload_dm_results.isolate", | |
321 Priority: 0.8, | |
322 } | |
323 return uploadName | |
324 } | |
325 return name | |
326 } | |
327 | |
328 // perf generates a Perf task. | |
329 func perf(cfg *specs.TasksCfg, name string, parts map[string]string, compileTask Name string, pkgs []*specs.CipdPackage) string { | |
330 cfg.Tasks[name] = &specs.TaskSpec{ | |
331 CipdPackages: pkgs, | |
332 Dependencies: []string{compileTaskName}, | |
333 Dimensions: swarmDimensions(parts), | |
334 ExtraArgs: []string{ | |
335 "--workdir", "../../..", "swarm_perf", | |
336 fmt.Sprintf("buildername=%s", name), | |
337 "mastername=fake-master", | |
338 "buildnumber=2", | |
339 "slavename=fake-buildslave", | |
340 fmt.Sprintf("swarm_out_dir=%s", specs.PLACEHOLDER_ISOLAT ED_OUTDIR), | |
341 fmt.Sprintf("revision=%s", specs.PLACEHOLDER_REVISION), | |
342 }, | |
343 Isolate: "perf_skia.isolate", | |
344 Priority: 0.8, | |
345 } | |
346 // Upload results if necessary. | |
347 if strings.Contains(name, "Release") { | |
dogben
2016/09/30 17:35:14
This agrees with the recipe, but just so you're aw
borenet
2016/09/30 19:13:16
Ouch. That's a bug. Fixed here, and will make a se
| |
348 uploadName := fmt.Sprintf("%s%s%s", PREFIX_UPLOAD, jobNameSchema .Sep, name) | |
349 cfg.Tasks[uploadName] = &specs.TaskSpec{ | |
350 Dependencies: []string{name}, | |
351 Dimensions: UPLOAD_DIMENSIONS, | |
352 ExtraArgs: []string{ | |
353 "--workdir", "../../..", "upload_dm_results", | |
dogben
2016/09/30 17:35:14
s/dm/nano/
borenet
2016/09/30 19:13:16
Done.
| |
354 fmt.Sprintf("buildername=%s", name), | |
355 "mastername=fake-master", | |
356 "buildnumber=2", | |
357 "slavename=fake-buildslave", | |
358 fmt.Sprintf("swarm_out_dir=%s", specs.PLACEHOLDE R_ISOLATED_OUTDIR), | |
359 fmt.Sprintf("revision=%s", specs.PLACEHOLDER_REV ISION), | |
360 }, | |
361 Isolate: "upload_dm_results.isolate", | |
362 Priority: 0.8, | |
363 } | |
364 return uploadName | |
365 } | |
366 return name | |
367 } | |
368 | |
369 // process generates tasks and jobs for the given job name. | |
370 func process(cfg *specs.TasksCfg, name string) { | |
371 if _, ok := cfg.Jobs[name]; ok { | |
372 glog.Fatalf("Duplicate job %q", name) | |
373 } | |
374 deps := []string{} | |
375 | |
376 parts, err := jobNameSchema.ParseJobName(name) | |
377 if err != nil { | |
378 glog.Fatal(err) | |
379 } | |
380 | |
381 // RecreateSKPs. | |
382 if strings.Contains(name, "RecreateSKPs") { | |
383 deps = append(deps, recreateSKPs(cfg, name)) | |
384 } | |
385 | |
386 // CT bots. | |
387 if strings.Contains(name, "-CT_") { | |
388 deps = append(deps, ctSKPs(cfg, name)) | |
389 } | |
390 | |
391 // Compile bots. | |
392 if parts["role"] == "Build" { | |
393 deps = append(deps, compile(cfg, name, parts)) | |
394 } | |
395 | |
396 // Any remaining bots need a compile task. | |
397 compileTaskName := deriveCompileTaskName(name, parts) | |
dogben
2016/09/30 17:35:14
This seems to assume we will have Build jobs for e
borenet
2016/09/30 19:13:16
Yeah, that was my intention, although I can defini
| |
398 | |
399 // Housekeeper. | |
400 if parts["role"] == "Housekeeper" { | |
401 deps = append(deps, housekeeper(cfg, name, compileTaskName)) | |
402 } | |
403 | |
404 // Common assets needed by the remaining bots. | |
405 pkgs := []*specs.CipdPackage{ | |
406 getCipdPackage("skimage"), | |
407 getCipdPackage("skp"), | |
408 getCipdPackage("svg"), | |
409 } | |
410 | |
411 // Test bots. | |
412 if parts["role"] == "Test" { | |
413 deps = append(deps, test(cfg, name, parts, compileTaskName, pkgs )) | |
414 } | |
415 | |
416 // Perf bots. | |
417 if parts["role"] == "Perf" { | |
418 deps = append(deps, perf(cfg, name, parts, compileTaskName, pkgs )) | |
419 } | |
420 | |
421 // Add the Job spec. | |
422 cfg.Jobs[name] = &specs.JobSpec{ | |
423 Priority: 0.8, | |
424 TaskSpecs: deps, | |
425 } | |
426 } | |
427 | |
428 // getCheckoutRoot returns the path of the root of the Skia checkout, or an | |
429 // error if it cannot be found. | |
430 func getCheckoutRoot() string { | |
431 cwd, err := os.Getwd() | |
432 if err != nil { | |
433 glog.Fatal(err) | |
434 } | |
435 for { | |
436 if _, err := os.Stat(cwd); err != nil { | |
437 glog.Fatal(err) | |
438 } | |
439 s, err := os.Stat(path.Join(cwd, ".git")) | |
440 if err == nil && s.IsDir() { | |
441 // TODO(borenet): Should we verify that this is a Skia | |
442 // checkout and not something else? | |
443 return cwd | |
444 } | |
445 cwd = filepath.Clean(path.Join(cwd, "..")) | |
446 } | |
447 } | |
448 | |
449 // Regenerate the tasks.json file. | |
450 func main() { | |
451 common.Init() | |
452 defer common.LogPanic() | |
453 | |
454 // Where are we? | |
455 root := getCheckoutRoot() | |
456 infrabotsDir = path.Join(root, "infra", "bots") | |
457 | |
458 // Create the JobNameSchema. | |
459 schema, err := NewJobNameSchema(path.Join(infrabotsDir, "recipe_modules" , "builder_name_schema", "builder_name_schema.json")) | |
460 if err != nil { | |
461 glog.Fatal(err) | |
462 } | |
463 jobNameSchema = schema | |
464 | |
465 // Create the config. | |
466 cfg := &specs.TasksCfg{ | |
467 Jobs: map[string]*specs.JobSpec{}, | |
468 Tasks: map[string]*specs.TaskSpec{}, | |
469 } | |
470 | |
471 // Create Tasks and Jobs. | |
472 for _, j := range JOBS { | |
473 process(cfg, j) | |
474 } | |
475 | |
476 // Validate the config. | |
477 if err := cfg.Validate(); err != nil { | |
478 glog.Fatal(err) | |
479 } | |
480 | |
481 // Write the tasks.json file. | |
482 outFile := path.Join(root, specs.TASKS_CFG_FILE) | |
483 b, err := json.MarshalIndent(cfg, "", " ") | |
484 if err != nil { | |
485 glog.Fatal(err) | |
486 } | |
487 b = bytes.Replace(b, []byte("\\u003c"), []byte("<"), -1) | |
dogben
2016/09/30 17:35:14
Please add a comment.
borenet
2016/09/30 19:13:16
Done.
| |
488 if err := ioutil.WriteFile(outFile, b, os.ModePerm); err != nil { | |
489 glog.Fatal(err) | |
490 } | |
491 } | |
492 | |
493 // TODO(borenet): The below really belongs in its own file, probably next to the | |
494 // builder_name_schema.json file. | |
495 | |
496 // JobNameSchema is a struct used for (de)constructing Job names in a | |
497 // predictable format. | |
498 type JobNameSchema struct { | |
499 Schema map[string][]string `json:"builder_name_schema"` | |
500 Sep string `json:"builder_name_sep"` | |
501 } | |
502 | |
503 // NewJobNameSchema returns a JobNameSchema instance based on the given JSON | |
504 // file. | |
505 func NewJobNameSchema(jsonFile string) (*JobNameSchema, error) { | |
506 var rv JobNameSchema | |
507 f, err := os.Open(jsonFile) | |
508 if err != nil { | |
509 return nil, err | |
510 } | |
511 defer util.Close(f) | |
512 if err := json.NewDecoder(f).Decode(&rv); err != nil { | |
513 return nil, err | |
514 } | |
515 return &rv, nil | |
516 } | |
517 | |
518 // ParseJobName splits the given Job name into its component parts, according | |
519 // to the schema. | |
520 func (s *JobNameSchema) ParseJobName(n string) (map[string]string, error) { | |
521 split := strings.Split(n, s.Sep) | |
522 if len(split) < 2 { | |
523 return nil, fmt.Errorf("Invalid job name: %q", n) | |
524 } | |
525 role := split[0] | |
526 split = split[1:] | |
527 keys, ok := s.Schema[role] | |
528 if !ok { | |
529 return nil, fmt.Errorf("Invalid job name; %q is not a valid role .", role) | |
530 } | |
531 extraConfig := "" | |
532 if len(split) == len(keys)+1 { | |
533 extraConfig = split[len(split)-1] | |
534 split = split[:len(split)-1] | |
535 } | |
536 if len(split) != len(keys) { | |
537 return nil, fmt.Errorf("Invalid job name; %q has incorrect numbe r of parts.", n) | |
538 } | |
539 rv := make(map[string]string, len(keys)+2) | |
540 rv["role"] = role | |
541 if extraConfig != "" { | |
542 rv["extra_config"] = extraConfig | |
543 } | |
544 for i, k := range keys { | |
545 rv[k] = split[i] | |
546 } | |
547 return rv, nil | |
548 } | |
549 | |
550 // MakeJobName assembles the given parts of a Job name, according to the schema. | |
551 func (s *JobNameSchema) MakeJobName(parts map[string]string) (string, error) { | |
552 role, ok := parts["role"] | |
553 if !ok { | |
554 return "", fmt.Errorf("Invalid job parts; jobs must have a role. ") | |
555 } | |
556 keys, ok := s.Schema[role] | |
557 if !ok { | |
558 return "", fmt.Errorf("Invalid job parts; unknown role %q", role ) | |
559 } | |
560 rvParts := make([]string, 0, len(parts)) | |
561 rvParts = append(rvParts, role) | |
562 for _, k := range keys { | |
563 v, ok := parts[k] | |
564 if !ok { | |
565 return "", fmt.Errorf("Invalid job parts; missing %q", k ) | |
566 } | |
567 rvParts = append(rvParts, v) | |
568 } | |
569 if _, ok := parts["extra_config"]; ok { | |
570 rvParts = append(rvParts, parts["extra_config"]) | |
571 } | |
572 return strings.Join(rvParts, s.Sep), nil | |
573 } | |
OLD | NEW |