1package cli
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "time"
16
17 "github.com/spf13/cobra"
18
19 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
20 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
21 "github.com/bnema/gordon/internal/domain"
22 "github.com/bnema/gordon/pkg/validation"
23)
24
25var newImageOpsFn = newImageOpsFromFlags
26
27// buildConfig holds image build settings for push commands.
28type buildConfig struct {
29 Enabled bool
30 Platform string
31 Dockerfile string
32 BuildArgs []string
33}
34
35// imagePush holds resolved image identity for push operations.
36type imagePush struct {
37 Registry string
38 ImageName string
39 SourceRef string
40 Version string
41 VersionRef string
42 LatestRef string
43}
44
45// pushRequest holds all inputs for the push command.
46type pushRequest struct {
47 ImageArg string
48 Tag string
49 Build buildConfig
50}
51
52func newPushCmd() *cobra.Command {
53 var (
54 build bool
55 platform string
56 tag string
57 dockerfile string
58 buildArgs []string
59 )
60
61 cmd := &cobra.Command{
62 Use: "push [image]",
63 Short: "Tag and push an image to the Gordon registry",
64 Long: `Tags a local image for the Gordon registry and pushes it.
65Uses git tags for versioning. Push transfers OCI content only: it never
66deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
67the manifest that references the pushed tag.
68
69Examples:
70 gordon images push myapp --build --remote ...
71 gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
72 gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
73 gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
74 Args: cobra.MaximumNArgs(1),
75 RunE: func(cmd *cobra.Command, args []string) error {
76 var imageArg string
77 if len(args) > 0 {
78 imageArg = args[0]
79 }
80 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
81 ImageArg: imageArg,
82 Tag: tag,
83 Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
84 })
85 },
86 }
87
88 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
89 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
90 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
91 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
92 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
93
94 return cmd
95}
96
97func resolveDockerfile(dockerfile string, build bool) (string, error) {
98 if dockerfile != "" && !build {
99 return "", fmt.Errorf("--file can only be used with --build")
100 }
101 if dockerfile == "" {
102 return "Dockerfile", nil
103 }
104 return dockerfile, nil
105}
106
107func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
108 versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
109 latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
110 return versionRef, latestRef
111}
112
113type pushArgKind string
114
115const (
116 pushArgKindImage pushArgKind = "image"
117 pushArgKindLegacyDomain pushArgKind = "legacy-domain"
118)
119
120type classifiedPushArg struct {
121 kind pushArgKind
122 sourceRef string
123 repository string
124 legacyDomain string
125}
126
127func classifyPushArgument(arg string) classifiedPushArg {
128 if looksLikeLegacyDomain(arg) {
129 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
130 }
131
132 _, repository, _ := parseImageRef(arg)
133 if repository == "" {
134 repository, _ = validation.ParseImageReference(arg)
135 }
136 return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
137}
138
139func looksLikeLegacyDomain(arg string) bool {
140 if arg == "" || strings.Contains(arg, "/") {
141 return false
142 }
143 host := arg
144 if idx := strings.Index(host, ":"); idx != -1 {
145 host = host[:idx]
146 }
147 return strings.Contains(host, ".")
148}
149
150// resolveImageTarget determines the registry and image name from the input.
151// A bare image name (no registry host) is pushed to the target Gordon's
152// registry domain. A fully qualified reference must name that same
153// registry, so the Gordon registry credentials never leave the target.
154// Push transfers OCI content only and never triggers deploys or route
155// lookups. The deploy target comes from the app manifest applied
156// separately via `gordon apps apply`.
157func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
158 if imageArg == "" {
159 imageArg, err = detectImageName(dockerfile)
160 if err != nil {
161 return "", "", "", err
162 }
163 if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
164 return "", "", "", err
165 }
166 }
167 if looksLikeLegacyDomain(imageArg) {
168 return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
169 }
170 classified := classifyPushArgument(imageArg)
171 if classified.repository == "" {
172 return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg)
173 }
174
175 targetRegistry, err := targetRegistryDomain(ctx, handle)
176 if err != nil {
177 return "", "", "", err
178 }
179
180 registry, imageName, _ = parseImageRef(classified.sourceRef)
181 if registry == "" {
182 if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil {
183 return "", "", "", err
184 }
185 return targetRegistry, classified.repository, classified.sourceRef, nil
186 }
187 if !sameRegistryHost(registry, targetRegistry) {
188 return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName)
189 }
190 return registry, imageName, classified.sourceRef, nil
191}
192
193// targetRegistryDomain asks the target Gordon instance for its registry domain.
194// Remote targets publish it on /admin/auth/verify, which any admin-scoped
195// token can read, so push needs no admin:status:read scope. The local admin
196// socket and servers that predate that field are asked through /admin/status.
197func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) {
198 if handle.isRemote {
199 verify, err := handle.plane.VerifyAuth(ctx)
200 if isForbidden(err) {
201 return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err)
202 }
203 if err != nil {
204 return "", fmt.Errorf("failed to read target registry domain: %w", err)
205 }
206 if !verify.Valid {
207 return "", errors.New("failed to read target registry domain: token is not valid")
208 }
209 if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" {
210 return registryDomain, nil
211 }
212 }
213
214 status, err := handle.plane.GetStatus(ctx)
215 if isForbidden(err) && handle.isRemote {
216 return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err)
217 }
218 if err != nil {
219 return "", fmt.Errorf("failed to read target registry domain: %w", err)
220 }
221 registryDomain := strings.TrimSpace(status.RegistryDomain)
222 if registryDomain == "" {
223 return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain")
224 }
225 return registryDomain, nil
226}
227
228func isForbidden(err error) bool {
229 httpErr, ok := errors.AsType[*remote.HTTPError](err)
230 return ok && httpErr.StatusCode == http.StatusForbidden
231}
232
233func sameRegistryHost(a, b string) bool {
234 normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") }
235 return normalize(a) == normalize(b)
236}
237
238// isRegistryHost applies Docker's rule for the first path component of an
239// image reference: it names a registry only if it contains a dot or a port,
240// or is "localhost".
241func isRegistryHost(component string) bool {
242 return strings.ContainsAny(component, ".:") || component == "localhost"
243}
244
245// resolveVersion determines and validates the version tag.
246func resolveVersion(ctx context.Context, tag string) (string, error) {
247 version := determineVersion(ctx, tag)
248 if version != "latest" {
249 if err := validation.ValidateReference(version); err != nil {
250 return "", fmt.Errorf("invalid version tag %q: %w", version, err)
251 }
252 }
253 return version, nil
254}
255
256func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
257 dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
258 if err != nil {
259 return err
260 }
261 defer handle.close()
262
263 version, err := resolveVersion(ctx, req.Tag)
264 if err != nil {
265 return err
266 }
267 if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
268 return err
269 }
270
271 registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile)
272 if err != nil {
273 return err
274 }
275
276 img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
277 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
278
279 imageOps, err := newPushImageOps(inferredRemote)
280 if err != nil {
281 return err
282 }
283
284 if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
285 return err
286 }
287 if version != "latest" {
288 if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
289 return err
290 }
291 }
292 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
293 if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
294 return err
295 }
296
297 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
298 return err
299 }
300 return nil
301}
302
303func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
304 dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
305 if err != nil {
306 return "", nil, nil, err
307 }
308 inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
309 if err != nil {
310 return "", nil, nil, err
311 }
312 if inferredRemote != nil {
313 handle = newRemoteControlPlaneHandle(inferredRemote)
314 if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
315 return "", nil, nil, err
316 }
317 return dockerfile, inferredRemote, handle, nil
318 }
319 handle, err = resolveControlPlane(cliConfigPath)
320 if err != nil {
321 return "", nil, nil, err
322 }
323 return dockerfile, nil, handle, nil
324}
325
326func validateBuildArgsList(buildArgs []string) error {
327 for _, ba := range buildArgs {
328 if err := validateBuildArg(ba); err != nil {
329 return err
330 }
331 }
332 return nil
333}
334
335func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
336 if inferredRemote != nil {
337 return newImageOpsForResolvedRemote(inferredRemote)
338 }
339 return newImageOpsFn()
340}
341
342func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
343 if build.Enabled {
344 return buildAndPush(ctx, out, imageOps, build, img)
345 }
346 return tagAndPush(ctx, out, imageOps, img)
347}
348
349// detectImageName resolves the image name from Dockerfile labels or the
350// current directory name.
351func detectImageName(dockerfile string) (string, error) {
352 // Try Dockerfile labels
353 labels := parseDockerfileLabels(dockerfile)
354 if d := labels[domain.LabelDomain]; d != "" {
355 // Strip the registry prefix if it looks like a full image ref
356 _, name, _ := parseImageRef(d)
357 if name != "" {
358 return name, nil
359 }
360 return d, nil
361 }
362
363 // Fall back to current directory name
364 cwd, err := os.Getwd()
365 if err != nil {
366 return "", fmt.Errorf("cannot detect image name: %w", err)
367 }
368
369 dirName := filepath.Base(cwd)
370 if dirName == "." || dirName == "/" {
371 return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
372 }
373
374 return dirName, nil
375}
376
377// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
378// Returns a map of label key -> value. Only parses gordon.* labels.
379func parseDockerfileLabels(dockerfile string) map[string]string {
380 labels := make(map[string]string)
381
382 f, err := os.Open(dockerfile)
383 if err != nil {
384 return labels
385 }
386 defer f.Close()
387
388 scanner := bufio.NewScanner(f)
389 for scanner.Scan() {
390 line := strings.TrimSpace(scanner.Text())
391
392 // Skip comments and empty lines
393 if line == "" || strings.HasPrefix(line, "#") {
394 continue
395 }
396
397 // Match LABEL instructions
398 if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
399 continue
400 }
401
402 // Parse "LABEL key=value" or "LABEL key=\"value\""
403 labelContent := strings.TrimSpace(line[6:])
404 for _, pair := range splitLabelPairs(labelContent) {
405 key, value, ok := parseLabelPair(pair)
406 if ok && strings.HasPrefix(key, "gordon.") {
407 labels[key] = value
408 }
409 }
410 }
411
412 return labels
413}
414
415// splitLabelPairs splits LABEL content into key=value pairs,
416// handling quoted values that may contain spaces.
417func splitLabelPairs(content string) []string {
418 var pairs []string
419 var current strings.Builder
420 inQuote := false
421 quoteChar := byte(0)
422
423 for i := 0; i < len(content); i++ {
424 ch := content[i]
425 if inQuote {
426 current.WriteByte(ch)
427 if ch == quoteChar {
428 inQuote = false
429 }
430 } else if ch == '"' || ch == '\'' {
431 inQuote = true
432 quoteChar = ch
433 current.WriteByte(ch)
434 } else if ch == ' ' || ch == '\t' {
435 if current.Len() > 0 {
436 pairs = append(pairs, current.String())
437 current.Reset()
438 }
439 } else {
440 current.WriteByte(ch)
441 }
442 }
443 if current.Len() > 0 {
444 pairs = append(pairs, current.String())
445 }
446
447 return pairs
448}
449
450// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
451func parseLabelPair(pair string) (key, value string, ok bool) {
452 key, value, ok = strings.Cut(pair, "=")
453 if !ok {
454 return "", "", false
455 }
456 // Strip surrounding quotes
457 value = strings.Trim(value, "\"'")
458 return key, value, true
459}
460
461func determineVersion(ctx context.Context, tag string) string {
462 version := tag
463 if version == "" {
464 version = versionFromTagRefs(os.Getenv)
465 }
466 if version == "" {
467 version = getGitVersion(ctx)
468 }
469 if version == "" {
470 version = "latest"
471 }
472 return version
473}
474
475func versionFromTagRefs(getenv func(string) string) string {
476 if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
477 if tag := parseTagRef(ref); tag != "" {
478 return tag
479 }
480 }
481
482 if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
483 if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
484 return tag
485 }
486 }
487
488 if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
489 return tag
490 }
491
492 if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
493 if tag := parseTagRef(ref); tag != "" {
494 return tag
495 }
496 }
497
498 return ""
499}
500
501func parseTagRef(ref string) string {
502 ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
503 if !strings.HasPrefix(ref, "refs/tags/") {
504 return ""
505 }
506 tag := strings.TrimPrefix(ref, "refs/tags/")
507 if tag == "" {
508 return ""
509 }
510 return tag
511}
512
513func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
514 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
515 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
516 }
517
518 // Build and load into local daemon (NOT --push).
519 // The native registry push client handles chunked uploads to stay
520 // within Cloudflare's 100MB per-request limit.
521 if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
522 return err
523 }
524 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
525 return err
526 }
527
528 if err := cliWriteLine(out, "Pushing..."); err != nil {
529 return err
530 }
531 if err := ops.Push(ctx, img.LatestRef); err != nil {
532 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
533 }
534 if img.Version != "latest" {
535 if err := ops.Push(ctx, img.VersionRef); err != nil {
536 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
537 }
538 }
539
540 return nil
541}
542
543// standardBuildArgs returns the standard set of git-related build args as
544// explicit KEY=VALUE pairs. User-supplied args are appended after and take
545// precedence (Docker uses the last occurrence of a duplicate key).
546func standardBuildArgs(ctx context.Context, version string) []string {
547 gitSHA := resolveGitSHA(ctx)
548 buildTime := time.Now().UTC().Format(time.RFC3339)
549 return []string{
550 "VERSION=" + version,
551 "GIT_TAG=" + version,
552 "GIT_SHA=" + gitSHA,
553 "BUILD_TIME=" + buildTime,
554 }
555}
556
557// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
558func resolveGitSHA(ctx context.Context) string {
559 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
560 if err != nil {
561 return "unknown"
562 }
563 return strings.TrimSpace(string(out))
564}
565
566// buildImageArgs constructs the docker buildx build arguments.
567// Uses --load instead of --push so the image is loaded into the local
568// daemon, allowing docker push to handle the upload with chunked requests.
569func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
570 args := []string{
571 "buildx", "build",
572 "--platform", platform,
573 "-f", dockerfile,
574 "-t", latestRef,
575 }
576 if version != "latest" {
577 args = append(args, "-t", versionRef)
578 }
579
580 // Inject standard git build args as explicit KEY=VALUE pairs.
581 // User-supplied --build-arg flags are appended AFTER so they override defaults.
582 for _, ba := range standardBuildArgs(ctx, version) {
583 args = append(args, "--build-arg", ba)
584 }
585 for _, ba := range buildArgs {
586 args = append(args, "--build-arg", ba)
587 }
588
589 args = append(args, "--load", ".")
590 return args
591}
592
593func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
594 localImage := img.SourceRef
595 if localImage == "" {
596 return errors.New("push source image reference cannot be empty")
597 }
598
599 if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
600 return err
601 }
602 exists, err := ops.Exists(ctx, localImage)
603 if err != nil {
604 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
605 }
606 if !exists {
607 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
608 }
609
610 if err := cliWriteLine(out, "Tagging..."); err != nil {
611 return err
612 }
613 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
614 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
615 }
616 if img.Version != "latest" {
617 if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
618 return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
619 }
620 }
621
622 if err := cliWriteLine(out, "Pushing..."); err != nil {
623 return err
624 }
625 if err := ops.Push(ctx, img.VersionRef); err != nil {
626 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
627 }
628 if img.Version != "latest" {
629 if err := ops.Push(ctx, img.LatestRef); err != nil {
630 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
631 }
632 }
633 return nil
634}
635
636func isInteractiveTerminal() bool {
637 term := os.Getenv("TERM")
638 if term == "" || term == "dumb" {
639 return false
640 }
641 info, err := os.Stdout.Stat()
642 if err != nil {
643 return false
644 }
645 return (info.Mode() & os.ModeCharDevice) != 0
646}
647
648// parseImageRef splits "registry/name:tag" into components. It returns empty
649// values when the reference does not start with a registry host.
650func parseImageRef(image string) (registry, name, tag string) {
651 // Handle "reg.example.com/app:latest" or "reg.example.com/app"
652 parts := strings.SplitN(image, "/", 2)
653 if len(parts) != 2 || !isRegistryHost(parts[0]) {
654 return "", "", ""
655 }
656 registry = parts[0]
657 nameTag := parts[1]
658 if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
659 return registry, nameTag[:idx], nameTag[idx+1:]
660 }
661 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
662 name = nameTag[:idx]
663 tag = nameTag[idx+1:]
664 } else {
665 name = nameTag
666 tag = "latest"
667 }
668 return
669}
670
671// getGitVersion returns git describe output, or empty string if unavailable.
672// When it falls back it prints a warning to stderr so the user knows the
673// image will be tagged "latest" rather than a real version.
674func getGitVersion(ctx context.Context) string {
675 out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
676 if err != nil {
677 fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err)
678 return ""
679 }
680 return strings.TrimSpace(string(out))
681}
682
683// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
684var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
685
686func validateBuildArg(arg string) error {
687 if !buildArgPattern.MatchString(arg) {
688 return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
689 }
690 return nil
691}