detached 8fd3087

fix: cancel extension installer subprocesses

commit
8fd3087f5d27d0842bff587436d6adf5d7346471
author
brice · · 2026-08-05 03:50 UTC
parent
df3ccd3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +50 -16
M Makefile +2 -1
@@ -50,7 +50,8 @@ vet:
5050 $(GO) vet ./...
5151
5252 fmt-check:
53- @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1)
53+ @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \
54+ if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi
5455
5556 lint: vet fmt-check
5657
@@ -50,7 +50,8 @@ vet:
50 $(GO) vet ./...50 $(GO) vet ./...
51 51
52 fmt-check:52 fmt-check:
53- @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1)53+ @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \
54+ if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi
54 55
55 lint: vet fmt-check56 lint: vet fmt-check
56 57
M docs/extensions.md +5 -4
@@ -85,8 +85,9 @@ The `examples/extensions/` directory in the repo is reference code, not a defaul
8585 zot ext install --build=go path/to/zot/examples/extensions/hello
8686
8787 # or build it in the source tree for a one-run development load
88-cd path/to/zot/examples/extensions/hello && go build -o hello .
89-zot --ext path/to/zot/examples/extensions/hello
88+cd path/to/zot/examples/extensions/hello
89+go build -o hello .
90+zot --ext .
9091 ```
9192
9293 `zot ext install` never builds source code implicitly. For local installs it
@@ -605,8 +606,8 @@ Reply with `shutdown_ack` and then exit.
605606 ```
606607 zot ext list list installed extensions and their state
607608 zot ext doctor diagnose load, registration, and conflict issues
608-zot ext install [--build=go] <path|git-url>
609- copy / clone into $ZOT_HOME/extensions/
609+zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
610+zot ext install --build=go <local-path> build local Go source, then install
610611 zot ext remove <name> delete an extension directory
611612 zot ext enable <name> re-enable a disabled extension
612613 zot ext disable <name> disable without removing
@@ -85,8 +85,9 @@ The `examples/extensions/` directory in the repo is reference code, not a defaul
85 zot ext install --build=go path/to/zot/examples/extensions/hello85 zot ext install --build=go path/to/zot/examples/extensions/hello
86 86
87 # or build it in the source tree for a one-run development load87 # or build it in the source tree for a one-run development load
88-cd path/to/zot/examples/extensions/hello && go build -o hello .88+cd path/to/zot/examples/extensions/hello
89-zot --ext path/to/zot/examples/extensions/hello89+go build -o hello .
90+zot --ext .
90 ```91 ```
91 92
92 `zot ext install` never builds source code implicitly. For local installs it93 `zot ext install` never builds source code implicitly. For local installs it
@@ -605,8 +606,8 @@ Reply with `shutdown_ack` and then exit.
605 ```606 ```
606 zot ext list list installed extensions and their state607 zot ext list list installed extensions and their state
607 zot ext doctor diagnose load, registration, and conflict issues608 zot ext doctor diagnose load, registration, and conflict issues
608-zot ext install [--build=go] <path|git-url>609+zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
609- copy / clone into $ZOT_HOME/extensions/610+zot ext install --build=go <local-path> build local Go source, then install
610 zot ext remove <name> delete an extension directory611 zot ext remove <name> delete an extension directory
611 zot ext enable <name> re-enable a disabled extension612 zot ext enable <name> re-enable a disabled extension
612 zot ext disable <name> disable without removing613 zot ext disable <name> disable without removing
M packages/agent/args.go +1 -1
@@ -423,7 +423,7 @@ func PrintHelp(version string) {
423423 )
424424 section("extensions",
425425 row{"zot ext list", "list installed extensions"},
426- row{"zot ext install <path|url>", "install from a local path or URL"},
426+ row{"zot ext install <path|git-url>", "install from a local path or Git URL"},
427427 row{"zot ext install --build=go <path>", "build local Go source, then install"},
428428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
429429 row{"zot ext help", "show all extension subcommands"},
@@ -423,7 +423,7 @@ func PrintHelp(version string) {
423 )423 )
424 section("extensions",424 section("extensions",
425 row{"zot ext list", "list installed extensions"},425 row{"zot ext list", "list installed extensions"},
426- row{"zot ext install <path|url>", "install from a local path or URL"},426+ row{"zot ext install <path|git-url>", "install from a local path or Git URL"},
427 row{"zot ext install --build=go <path>", "build local Go source, then install"},427 row{"zot ext install --build=go <path>", "build local Go source, then install"},
428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
429 row{"zot ext help", "show all extension subcommands"},429 row{"zot ext help", "show all extension subcommands"},
M packages/agent/cli.go +11 -1
@@ -620,12 +620,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
620620
621621 // Run is the top-level entrypoint for the zot binary.
622622 func Run(rawArgs []string, version string) error {
623+ // Extension installs can invoke external build and clone processes.
624+ // Give those processes a cancellation context without changing signal
625+ // handling for the interactive modes below.
626+ extCtx := context.Background()
627+ stopExtContext := func() {}
628+ if len(rawArgs) > 0 && rawArgs[0] == "ext" {
629+ extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
630+ }
631+ defer stopExtContext()
632+
623633 // Subcommand router: `zot bot ...` is handled separately so the
624634 // generic flag parser doesn't reject "bot" as a positional arg.
625635 if handled, err := runBotCommand(rawArgs, version); handled {
626636 return err
627637 }
628- if handled, err := runExtCommand(rawArgs, version); handled {
638+ if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
629639 return err
630640 }
631641 if handled, err := runUpdateCommand(rawArgs, version); handled {
@@ -620,12 +620,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
620 620
621 // Run is the top-level entrypoint for the zot binary.621 // Run is the top-level entrypoint for the zot binary.
622 func Run(rawArgs []string, version string) error {622 func Run(rawArgs []string, version string) error {
623+ // Extension installs can invoke external build and clone processes.
624+ // Give those processes a cancellation context without changing signal
625+ // handling for the interactive modes below.
626+ extCtx := context.Background()
627+ stopExtContext := func() {}
628+ if len(rawArgs) > 0 && rawArgs[0] == "ext" {
629+ extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
630+ }
631+ defer stopExtContext()
632+
623 // Subcommand router: `zot bot ...` is handled separately so the633 // Subcommand router: `zot bot ...` is handled separately so the
624 // generic flag parser doesn't reject "bot" as a positional arg.634 // generic flag parser doesn't reject "bot" as a positional arg.
625 if handled, err := runBotCommand(rawArgs, version); handled {635 if handled, err := runBotCommand(rawArgs, version); handled {
626 return err636 return err
627 }637 }
628- if handled, err := runExtCommand(rawArgs, version); handled {638+ if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
629 return err639 return err
630 }640 }
631 if handled, err := runUpdateCommand(rawArgs, version); handled {641 if handled, err := runUpdateCommand(rawArgs, version); handled {
M packages/agent/extcmd.go +14 -9
@@ -23,7 +23,7 @@ import (
2323 // (handled=true, err) if rawArgs starts with "ext"; otherwise
2424 // (handled=false, nil) so the main router falls through to the
2525 // regular flag parser.
26-func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
26+func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
2727 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
2828 return false, nil
2929 }
@@ -45,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
4545 case "remove", "rm":
4646 return true, extRemove(rawArgs[2:])
4747 case "install":
48- return true, extInstall(rawArgs[2:])
48+ return true, extInstallContext(ctx, rawArgs[2:])
4949 case "help", "-h", "--help":
5050 printExtHelp()
5151 return true, nil
@@ -473,6 +473,10 @@ func extRemove(args []string) error {
473473 // Builds are never inferred or run implicitly; --build=go is an explicit
474474 // opt-in for local Go extensions.
475475 func extInstall(args []string) error {
476+ return extInstallContext(context.Background(), args)
477+}
478+
479+func extInstallContext(ctx context.Context, args []string) error {
476480 opts, err := parseExtInstallArgs(args)
477481 if err != nil {
478482 return err
@@ -487,7 +491,7 @@ func extInstall(args []string) error {
487491 if opts.builder != "" {
488492 return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
489493 }
490- return installGitExtension(src, dest)
494+ return installGitExtension(ctx, src, dest)
491495 }
492496
493497 // Local path: must be a directory containing extension.json.
@@ -523,7 +527,7 @@ func extInstall(args []string) error {
523527 return fmt.Errorf("copy extension: %w", err)
524528 }
525529 if opts.builder != "" {
526- if err := buildLocalExtension(absSrc, staged, manifest); err != nil {
530+ if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
527531 return err
528532 }
529533 }
@@ -591,7 +595,7 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) {
591595 return opts, nil
592596 }
593597
594-func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manifest) error {
598+func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
595599 if manifest.Exec == "" {
596600 return errors.New("cannot build a theme-only extension; it does not declare an executable")
597601 }
@@ -605,7 +609,7 @@ func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manife
605609 }
606610
607611 fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
608- cmd := exec.Command("go", "build", "-trimpath", "-o", stagedExec, ".")
612+ cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
609613 cmd.Dir = sourceDir
610614 cmd.Stdout = os.Stderr
611615 cmd.Stderr = os.Stderr
@@ -634,7 +638,7 @@ func extensionRelativeExecPath(dir, execName string) (string, error) {
634638 return rel, nil
635639 }
636640
637-func installGitExtension(src, dest string) error {
641+func installGitExtension(ctx context.Context, src, dest string) error {
638642 stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-")
639643 if err != nil {
640644 return fmt.Errorf("create install staging directory: %w", err)
@@ -642,7 +646,8 @@ func installGitExtension(src, dest string) error {
642646 defer os.RemoveAll(stageRoot)
643647
644648 cloneDir := filepath.Join(stageRoot, "extension")
645- cmd := exec.Command("git", "clone", "--depth", "1", src, cloneDir)
649+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
650+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
646651 cmd.Stdout = os.Stderr
647652 cmd.Stderr = os.Stderr
648653 if err := cmd.Run(); err != nil {
@@ -684,7 +689,7 @@ func readExtensionManifest(dir string) (extensions.Manifest, error) {
684689 if manifest.Name != strings.TrimSpace(manifest.Name) {
685690 return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
686691 }
687- if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) {
692+ if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
688693 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
689694 }
690695 if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
@@ -23,7 +23,7 @@ import (
23 // (handled=true, err) if rawArgs starts with "ext"; otherwise23 // (handled=true, err) if rawArgs starts with "ext"; otherwise
24 // (handled=false, nil) so the main router falls through to the24 // (handled=false, nil) so the main router falls through to the
25 // regular flag parser.25 // regular flag parser.
26-func runExtCommand(rawArgs []string, version string) (handled bool, err error) {26+func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
27 if len(rawArgs) == 0 || rawArgs[0] != "ext" {27 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
28 return false, nil28 return false, nil
29 }29 }
@@ -45,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
45 case "remove", "rm":45 case "remove", "rm":
46 return true, extRemove(rawArgs[2:])46 return true, extRemove(rawArgs[2:])
47 case "install":47 case "install":
48- return true, extInstall(rawArgs[2:])48+ return true, extInstallContext(ctx, rawArgs[2:])
49 case "help", "-h", "--help":49 case "help", "-h", "--help":
50 printExtHelp()50 printExtHelp()
51 return true, nil51 return true, nil
@@ -473,6 +473,10 @@ func extRemove(args []string) error {
473 // Builds are never inferred or run implicitly; --build=go is an explicit473 // Builds are never inferred or run implicitly; --build=go is an explicit
474 // opt-in for local Go extensions.474 // opt-in for local Go extensions.
475 func extInstall(args []string) error {475 func extInstall(args []string) error {
476+ return extInstallContext(context.Background(), args)
477+}
478+
479+func extInstallContext(ctx context.Context, args []string) error {
476 opts, err := parseExtInstallArgs(args)480 opts, err := parseExtInstallArgs(args)
477 if err != nil {481 if err != nil {
478 return err482 return err
@@ -487,7 +491,7 @@ func extInstall(args []string) error {
487 if opts.builder != "" {491 if opts.builder != "" {
488 return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")492 return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
489 }493 }
490- return installGitExtension(src, dest)494+ return installGitExtension(ctx, src, dest)
491 }495 }
492 496
493 // Local path: must be a directory containing extension.json.497 // Local path: must be a directory containing extension.json.
@@ -523,7 +527,7 @@ func extInstall(args []string) error {
523 return fmt.Errorf("copy extension: %w", err)527 return fmt.Errorf("copy extension: %w", err)
524 }528 }
525 if opts.builder != "" {529 if opts.builder != "" {
526- if err := buildLocalExtension(absSrc, staged, manifest); err != nil {530+ if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
527 return err531 return err
528 }532 }
529 }533 }
@@ -591,7 +595,7 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) {
591 return opts, nil595 return opts, nil
592 }596 }
593 597
594-func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manifest) error {598+func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
595 if manifest.Exec == "" {599 if manifest.Exec == "" {
596 return errors.New("cannot build a theme-only extension; it does not declare an executable")600 return errors.New("cannot build a theme-only extension; it does not declare an executable")
597 }601 }
@@ -605,7 +609,7 @@ func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manife
605 }609 }
606 610
607 fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)611 fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
608- cmd := exec.Command("go", "build", "-trimpath", "-o", stagedExec, ".")612+ cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
609 cmd.Dir = sourceDir613 cmd.Dir = sourceDir
610 cmd.Stdout = os.Stderr614 cmd.Stdout = os.Stderr
611 cmd.Stderr = os.Stderr615 cmd.Stderr = os.Stderr
@@ -634,7 +638,7 @@ func extensionRelativeExecPath(dir, execName string) (string, error) {
634 return rel, nil638 return rel, nil
635 }639 }
636 640
637-func installGitExtension(src, dest string) error {641+func installGitExtension(ctx context.Context, src, dest string) error {
638 stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-")642 stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-")
639 if err != nil {643 if err != nil {
640 return fmt.Errorf("create install staging directory: %w", err)644 return fmt.Errorf("create install staging directory: %w", err)
@@ -642,7 +646,8 @@ func installGitExtension(src, dest string) error {
642 defer os.RemoveAll(stageRoot)646 defer os.RemoveAll(stageRoot)
643 647
644 cloneDir := filepath.Join(stageRoot, "extension")648 cloneDir := filepath.Join(stageRoot, "extension")
645- cmd := exec.Command("git", "clone", "--depth", "1", src, cloneDir)649+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
650+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
646 cmd.Stdout = os.Stderr651 cmd.Stdout = os.Stderr
647 cmd.Stderr = os.Stderr652 cmd.Stderr = os.Stderr
648 if err := cmd.Run(); err != nil {653 if err := cmd.Run(); err != nil {
@@ -684,7 +689,7 @@ func readExtensionManifest(dir string) (extensions.Manifest, error) {
684 if manifest.Name != strings.TrimSpace(manifest.Name) {689 if manifest.Name != strings.TrimSpace(manifest.Name) {
685 return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)690 return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
686 }691 }
687- if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) {692+ if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
688 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)693 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
689 }694 }
690 if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {695 if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
M packages/agent/extcmd_test.go +17 -0
@@ -278,6 +278,23 @@ func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) {
278278 }
279279 }
280280
281+func TestExtInstallRejectsDotManifestName(t *testing.T) {
282+ home := t.TempDir()
283+ t.Setenv("ZOT_HOME", home)
284+ src := filepath.Join(t.TempDir(), "dot-name")
285+ if err := os.MkdirAll(src, 0o755); err != nil {
286+ t.Fatal(err)
287+ }
288+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil {
289+ t.Fatal(err)
290+ }
291+
292+ err := extInstall([]string{src})
293+ if err == nil || !strings.Contains(err.Error(), "invalid extension name") {
294+ t.Fatalf("error = %v, want invalid-name validation error", err)
295+ }
296+}
297+
281298 func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
282299 home := t.TempDir()
283300 t.Setenv("ZOT_HOME", home)
@@ -278,6 +278,23 @@ func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) {
278 }278 }
279 }279 }
280 280
281+func TestExtInstallRejectsDotManifestName(t *testing.T) {
282+ home := t.TempDir()
283+ t.Setenv("ZOT_HOME", home)
284+ src := filepath.Join(t.TempDir(), "dot-name")
285+ if err := os.MkdirAll(src, 0o755); err != nil {
286+ t.Fatal(err)
287+ }
288+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil {
289+ t.Fatal(err)
290+ }
291+
292+ err := extInstall([]string{src})
293+ if err == nil || !strings.Contains(err.Error(), "invalid extension name") {
294+ t.Fatalf("error = %v, want invalid-name validation error", err)
295+ }
296+}
297+
281 func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {298 func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
282 home := t.TempDir()299 home := t.TempDir()
283 t.Setenv("ZOT_HOME", home)300 t.Setenv("ZOT_HOME", home)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close