From d99afcb50312bd6cd2c055712206b7927d96e2d5 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 15:02:54 -0800 Subject: [PATCH 1/2] refactor(instance): remove container image label support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: Container runtime flags could be configured via OCI image labels (io.headjack.init, io.headjack.podman.flags, io.headjack.docker.flags). The instance manager fetched image metadata from the registry and merged label-based flags with config file flags. New behavior: Runtime flags are now exclusively configured via the config file (runtime.flags in config.yaml). The registry package and all label parsing code have been removed. This simplifies the codebase and removes the dependency on registry metadata fetching. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/docs/reference/images/labels.md | 205 ------------------ docs/docs/reference/images/overview.md | 4 - images/systemd/Dockerfile | 12 -- internal/cmd/root.go | 8 +- internal/flags/flags.go | 56 ----- internal/flags/flags_test.go | 112 +--------- internal/instance/manager.go | 97 +-------- internal/instance/manager_test.go | 278 ++++--------------------- internal/registry/client.go | 124 ----------- internal/registry/client_test.go | 151 -------------- internal/registry/mocks/client.go | 83 -------- internal/registry/registry.go | 54 ----- 12 files changed, 57 insertions(+), 1127 deletions(-) delete mode 100644 docs/docs/reference/images/labels.md delete mode 100644 internal/registry/client.go delete mode 100644 internal/registry/client_test.go delete mode 100644 internal/registry/mocks/client.go delete mode 100644 internal/registry/registry.go diff --git a/docs/docs/reference/images/labels.md b/docs/docs/reference/images/labels.md deleted file mode 100644 index da19392..0000000 --- a/docs/docs/reference/images/labels.md +++ /dev/null @@ -1,205 +0,0 @@ ---- -sidebar_position: 5 -title: OCI Labels -description: OCI label reference for custom images ---- - -# OCI Labels Reference - -Headjack uses OCI image labels to configure container runtime behavior. When building custom images, you can use these labels to control how Headjack runs your containers. - -## Overview - -Labels are key-value pairs embedded in container images. Headjack reads these labels at runtime to determine how to configure the container. - -Labels are set in Dockerfiles using the `LABEL` instruction: - -```dockerfile -LABEL io.headjack.init="/lib/systemd/systemd" -``` - -## Available Labels - -### io.headjack.init - -Specifies the command to run as PID 1 inside the container. - -| Property | Value | -|----------|-------| -| Key | `io.headjack.init` | -| Value type | String (command path) | -| Default | `sleep infinity` | - -#### Description - -By default, Headjack runs `sleep infinity` as PID 1 to keep the container alive while sessions run in the background. This label overrides that default with a custom init command. - -#### Example - -```dockerfile -# Use systemd as init -LABEL io.headjack.init="/lib/systemd/systemd" - -# Use a custom init script -LABEL io.headjack.init="/usr/local/bin/my-init.sh" -``` - -#### Usage in Official Images - -| Image | Value | -|-------|-------| -| `base` | Not set (uses default `sleep infinity`) | -| `systemd` | `/lib/systemd/systemd` | -| `dind` | Inherited from `systemd` | - ---- - -### io.headjack.podman.flags - -Specifies additional flags to pass to Podman when running the container. - -| Property | Value | -|----------|-------| -| Key | `io.headjack.podman.flags` | -| Value type | String (space-separated key=value pairs) | -| Default | None | - -#### Description - -This label allows images to specify Podman-specific runtime flags that are required for correct operation. Headjack parses the value and applies the flags when creating the container. - -#### Format - -The value is a space-separated list of key=value pairs: - -``` -key1=value1 key2=value2 -``` - -#### Supported Flags - -| Flag | Description | -|------|-------------| -| `systemd=always` | Enable systemd container mode | -| `systemd=true` | Enable systemd container mode if systemd is detected | - -#### Example - -```dockerfile -# Enable systemd mode -LABEL io.headjack.podman.flags="systemd=always" - -# Multiple flags -LABEL io.headjack.podman.flags="systemd=always privileged=true" -``` - -#### Usage in Official Images - -| Image | Value | -|-------|-------| -| `base` | Not set | -| `systemd` | `systemd=always` | -| `dind` | Inherited from `systemd` | - ---- - -### io.headjack.docker.flags - -Specifies additional flags to pass to Docker when running the container. - -| Property | Value | -|----------|-------| -| Key | `io.headjack.docker.flags` | -| Value type | String (space-separated key=value pairs) | -| Default | None | - -#### Description - -This label allows images to specify Docker-specific runtime flags that are required for correct operation. Headjack parses the value and applies the flags when creating the container. The format is the same as `io.headjack.podman.flags`. - -#### Example - -```dockerfile -# Enable privileged mode -LABEL io.headjack.docker.flags="privileged=true" -``` - -#### Usage in Official Images - -| Image | Value | -|-------|-------| -| `base` | Not set | -| `systemd` | `privileged=true cgroupns=host volume=/sys/fs/cgroup:/sys/fs/cgroup:rw` | -| `dind` | Inherited from `systemd` | - -## Building Custom Images - -When building custom images that extend the official Headjack images, labels are not automatically inherited. You must explicitly set any labels you need. - -### Extending the Base Image - -```dockerfile -FROM ghcr.io/gilmanlab/headjack:base - -# Add custom software -RUN apt-get update && apt-get install -y postgresql - -# No labels needed - base image uses default init -``` - -### Extending the Systemd Image - -```dockerfile -FROM ghcr.io/gilmanlab/headjack:systemd - -# Add custom systemd service -COPY myservice.service /etc/systemd/system/ -RUN systemctl enable myservice - -# Re-declare labels (not inherited) -LABEL io.headjack.init="/lib/systemd/systemd" -LABEL io.headjack.podman.flags="systemd=always" -LABEL io.headjack.docker.flags="privileged=true cgroupns=host volume=/sys/fs/cgroup:/sys/fs/cgroup:rw" -``` - -### Creating a Custom Init Image - -```dockerfile -FROM ghcr.io/gilmanlab/headjack:base - -# Add custom init script -COPY init.sh /usr/local/bin/init.sh -RUN chmod +x /usr/local/bin/init.sh - -# Configure Headjack to use custom init -LABEL io.headjack.init="/usr/local/bin/init.sh" -``` - -## Label Inspection - -You can inspect image labels using Docker or Podman: - -```bash -# Using Docker -docker inspect ghcr.io/gilmanlab/headjack:systemd --format='{{json .Config.Labels}}' | jq - -# Using Podman -podman inspect ghcr.io/gilmanlab/headjack:systemd --format='{{json .Config.Labels}}' | jq -``` - -Example output: - -```json -{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.podman.flags": "systemd=always", - "io.headjack.docker.flags": "privileged=true cgroupns=host volume=/sys/fs/cgroup:/sys/fs/cgroup:rw" -} -``` - -## See Also - -- [Overview](overview.md) - Image variant comparison -- [Base Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/base/Dockerfile) -- [Systemd Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/systemd/Dockerfile) -- [Docker-in-Docker Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/dind/Dockerfile) diff --git a/docs/docs/reference/images/overview.md b/docs/docs/reference/images/overview.md index 6ec0ad4..c2470bd 100644 --- a/docs/docs/reference/images/overview.md +++ b/docs/docs/reference/images/overview.md @@ -113,7 +113,3 @@ For complete image specifications, see the Dockerfiles in the repository: - [Base Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/base/Dockerfile) - [Systemd Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/systemd/Dockerfile) - [Docker-in-Docker Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/dind/Dockerfile) - -## See Also - -- [OCI Labels](labels.md) - Labels for custom image configuration diff --git a/images/systemd/Dockerfile b/images/systemd/Dockerfile index d8e7bf4..5241826 100644 --- a/images/systemd/Dockerfile +++ b/images/systemd/Dockerfile @@ -43,17 +43,5 @@ STOPSIGNAL SIGRTMIN+3 # Volume for cgroups (required for systemd) VOLUME ["/sys/fs/cgroup"] -# ============================================================================= -# Headjack Runtime Configuration Labels -# ============================================================================= - -# These labels inform Headjack how to run the image: -# - io.headjack.init: Command to run as PID 1 (overrides "sleep infinity" default) -# - io.headjack.podman.flags: Space-separated key=value pairs for Podman flags -# - io.headjack.docker.flags: Space-separated key=value pairs for Docker flags -LABEL io.headjack.init="/lib/systemd/systemd" -LABEL io.headjack.podman.flags="systemd=always" -LABEL io.headjack.docker.flags="privileged=true cgroupns=host volume=/sys/fs/cgroup:/sys/fs/cgroup:rw" - # systemd as init system CMD ["/lib/systemd/systemd"] diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a0832c6..38af073 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -21,7 +21,6 @@ import ( "github.com/jmgilman/headjack/internal/git" "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/multiplexer" - "github.com/jmgilman/headjack/internal/registry" ) // baseDeps lists the external binaries that must always be available. @@ -176,19 +175,16 @@ func initManager() error { // Use tmux as the terminal multiplexer mux := multiplexer.NewTmux(executor) - // Create registry client for fetching image metadata - regClient := registry.NewClient(registry.ClientConfig{}) - // Map runtime name to RuntimeType runtimeType := runtimeNameToType(runtimeName) - // Parse config flags for merging with image label flags + // Parse config flags configFlags, err := getConfigFlags() if err != nil { return err } - mgr = instance.NewManager(store, runtime, opener, mux, regClient, instance.ManagerConfig{ + mgr = instance.NewManager(store, runtime, opener, mux, instance.ManagerConfig{ WorktreesDir: worktreesDir, LogsDir: logsDir, RuntimeType: runtimeType, diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 1cf3998..089e15d 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "sort" - "strings" ) // Flags represents runtime flags as a key-value map. @@ -55,61 +54,6 @@ func FromConfig(cfg map[string]any) (Flags, error) { return result, nil } -// FromLabel parses a space-separated string of key=value pairs into Flags. -// Format: "key1=value1 key2=value2 boolkey=true barekey" -// -// Rules: -// - "key=value" → string value -// - "key=true" or "key=false" → bool value -// - "key" (bare, no =) → bool true -// - Repeated keys become []string (e.g., "vol=/a vol=/b" → {"vol": ["/a", "/b"]}) -// - Values containing = are handled correctly (splits on first = only) -func FromLabel(label string) (Flags, error) { - result := make(Flags) - if label == "" { - return result, nil - } - - for _, part := range strings.Fields(label) { - key, value, hasEquals := strings.Cut(part, "=") - if key == "" { - continue // Skip empty keys - } - - if !hasEquals { - // Bare key treated as boolean true (e.g., "privileged") - result[key] = true - continue - } - - // Check for boolean string values - switch strings.ToLower(value) { - case "true": - result[key] = true - continue - case "false": - result[key] = false - continue - } - - // Handle repeated keys by converting to array - if existing, ok := result[key]; ok { - switch e := existing.(type) { - case string: - result[key] = []string{e, value} - case []string: - result[key] = append(e, value) - default: - // Overwrite non-string values (e.g., bool) with the new string - result[key] = value - } - } else { - result[key] = value - } - } - return result, nil -} - // Merge combines two Flags maps with override taking precedence. // Keys in override replace keys in base. func Merge(base, override Flags) Flags { diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go index 13ac796..d7dcbd7 100644 --- a/internal/flags/flags_test.go +++ b/internal/flags/flags_test.go @@ -101,100 +101,6 @@ func TestFromConfig(t *testing.T) { }) } -func TestFromLabel(t *testing.T) { - t.Run("empty string returns empty flags", func(t *testing.T) { - result, err := FromLabel("") - - require.NoError(t, err) - assert.Empty(t, result) - }) - - t.Run("simple key=value pairs", func(t *testing.T) { - result, err := FromLabel("memory=2g systemd=always") - - require.NoError(t, err) - assert.Equal(t, "2g", result["memory"]) - assert.Equal(t, "always", result["systemd"]) - }) - - t.Run("boolean true value", func(t *testing.T) { - result, err := FromLabel("privileged=true") - - require.NoError(t, err) - assert.Equal(t, true, result["privileged"]) - }) - - t.Run("boolean false value", func(t *testing.T) { - result, err := FromLabel("debug=false") - - require.NoError(t, err) - assert.Equal(t, false, result["debug"]) - }) - - t.Run("boolean case insensitive", func(t *testing.T) { - result, err := FromLabel("a=TRUE b=False c=TRUE") - - require.NoError(t, err) - assert.Equal(t, true, result["a"]) - assert.Equal(t, false, result["b"]) - assert.Equal(t, true, result["c"]) - }) - - t.Run("bare key as boolean true", func(t *testing.T) { - result, err := FromLabel("privileged") - - require.NoError(t, err) - assert.Equal(t, true, result["privileged"]) - }) - - t.Run("repeated keys become array", func(t *testing.T) { - result, err := FromLabel("volume=/a:/b volume=/c:/d") - - require.NoError(t, err) - assert.Equal(t, []string{"/a:/b", "/c:/d"}, result["volume"]) - }) - - t.Run("three repeated keys", func(t *testing.T) { - result, err := FromLabel("env=A=1 env=B=2 env=C=3") - - require.NoError(t, err) - assert.Equal(t, []string{"A=1", "B=2", "C=3"}, result["env"]) - }) - - t.Run("value containing equals sign", func(t *testing.T) { - result, err := FromLabel("env=FOO=bar") - - require.NoError(t, err) - assert.Equal(t, "FOO=bar", result["env"]) - }) - - t.Run("mixed bare keys and key=value", func(t *testing.T) { - result, err := FromLabel("privileged systemd=always memory=2g") - - require.NoError(t, err) - assert.Equal(t, true, result["privileged"]) - assert.Equal(t, "always", result["systemd"]) - assert.Equal(t, "2g", result["memory"]) - }) - - t.Run("whitespace handling", func(t *testing.T) { - result, err := FromLabel(" memory=2g systemd=always ") - - require.NoError(t, err) - assert.Equal(t, "2g", result["memory"]) - assert.Equal(t, "always", result["systemd"]) - }) - - t.Run("repeated key overwrites bool with string array", func(t *testing.T) { - // First occurrence is bool, second is string - should become string - result, err := FromLabel("flag=true flag=value") - - require.NoError(t, err) - // The bool is overwritten by the string value - assert.Equal(t, "value", result["flag"]) - }) -} - func TestMerge(t *testing.T) { t.Run("nil inputs return empty flags", func(t *testing.T) { result := Merge(nil, nil) @@ -337,20 +243,6 @@ func TestToArgs(t *testing.T) { } func TestRoundTrip(t *testing.T) { - t.Run("label to flags to args", func(t *testing.T) { - label := "systemd=always privileged=true volume=/a:/b volume=/c:/d" - - flags, err := FromLabel(label) - require.NoError(t, err) - - args := ToArgs(flags) - - assert.Contains(t, args, "--systemd=always") - assert.Contains(t, args, "--privileged") - assert.Contains(t, args, "--volume=/a:/b") - assert.Contains(t, args, "--volume=/c:/d") - }) - t.Run("config to flags to args", func(t *testing.T) { cfg := map[string]any{ "memory": "2g", @@ -369,12 +261,12 @@ func TestRoundTrip(t *testing.T) { }) t.Run("merge then to args", func(t *testing.T) { - imageFlags, err := FromLabel("systemd=always memory=1g") + baseFlags, err := FromConfig(map[string]any{"systemd": "always", "memory": "1g"}) require.NoError(t, err) configFlags, err := FromConfig(map[string]any{"memory": "4g", "privileged": true}) require.NoError(t, err) - merged := Merge(imageFlags, configFlags) + merged := Merge(baseFlags, configFlags) args := ToArgs(merged) // Config should win for memory diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 4837b6c..128b22a 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "errors" "fmt" - "os" "path/filepath" "regexp" "strings" @@ -20,7 +19,6 @@ import ( "github.com/jmgilman/headjack/internal/logging" "github.com/jmgilman/headjack/internal/multiplexer" "github.com/jmgilman/headjack/internal/names" - "github.com/jmgilman/headjack/internal/registry" ) // containerNamePrefix is the prefix for all managed containers. @@ -61,11 +59,6 @@ type sessionMultiplexer interface { KillSession(ctx context.Context, sessionName string) error } -// registryClient is the internal interface for fetching image metadata. -type registryClient interface { - GetMetadata(ctx context.Context, ref string) (*registry.ImageMetadata, error) -} - // RuntimeType identifies the container runtime being used. type RuntimeType string @@ -92,7 +85,6 @@ type Manager struct { executor exec.Executor git gitOpener mux sessionMultiplexer - registry registryClient logPaths *logging.PathManager worktreesDir string runtimeType RuntimeType @@ -100,7 +92,7 @@ type Manager struct { } // NewManager creates a new instance manager. -func NewManager(store catalogStore, runtime containerRuntime, opener gitOpener, mux sessionMultiplexer, reg registryClient, cfg ManagerConfig) *Manager { +func NewManager(store catalogStore, runtime containerRuntime, opener gitOpener, mux sessionMultiplexer, cfg ManagerConfig) *Manager { runtimeType := cfg.RuntimeType if runtimeType == "" { runtimeType = RuntimeDocker @@ -119,7 +111,6 @@ func NewManager(store catalogStore, runtime containerRuntime, opener gitOpener, executor: cfg.Executor, git: opener, mux: mux, - registry: reg, logPaths: logging.NewPathManager(cfg.LogsDir), worktreesDir: cfg.WorktreesDir, runtimeType: runtimeType, @@ -139,69 +130,6 @@ func (m *Manager) Executor() exec.Executor { return m.executor } -// imageRuntimeConfig holds image-specific runtime configuration extracted from labels. -type imageRuntimeConfig struct { - Init string // Init command (default: "sleep infinity") - Flags flags.Flags // Runtime-specific flags parsed from label (e.g., "systemd=always") -} - -// Label constants for image runtime configuration. -const ( - labelInit = "io.headjack.init" - labelPodmanFlags = "io.headjack.podman.flags" - labelDockerFlags = "io.headjack.docker.flags" -) - -// getImageRuntimeConfig fetches image metadata and extracts runtime configuration from labels. -// Returns default values if the registry client is nil or metadata cannot be fetched. -// Runtime-specific flags are extracted based on the configured runtime type: -// - Podman: io.headjack.podman.flags -// - Docker: io.headjack.docker.flags -func (m *Manager) getImageRuntimeConfig(ctx context.Context, image string) imageRuntimeConfig { - cfg := imageRuntimeConfig{ - Init: "", // Empty means runtime will use default "sleep infinity" - } - - if m.registry == nil { - return cfg - } - - metadata, err := m.registry.GetMetadata(ctx, image) - if err != nil { - // Log warning - image will run with defaults (sleep infinity, no special flags) - // This may cause systemd images to fail if they require --systemd=always - fmt.Fprintf(os.Stderr, "warning: failed to fetch image metadata for %s: %v (using defaults)\n", image, err) - return cfg - } - - if metadata.Labels != nil { - if v, ok := metadata.Labels[labelInit]; ok { - cfg.Init = v - } - // Extract runtime-specific flags based on runtime type - var flagsLabel string - switch m.runtimeType { - case RuntimePodman: - flagsLabel = labelPodmanFlags - case RuntimeDocker: - flagsLabel = labelDockerFlags - } - if flagsLabel != "" { - if v, ok := metadata.Labels[flagsLabel]; ok { - parsedFlags, parseErr := flags.FromLabel(v) - if parseErr != nil { - fmt.Fprintf(os.Stderr, "warning: failed to parse %s flags from image %s: %v\n", - m.runtimeType, image, parseErr) - } else { - cfg.Flags = parsedFlags - } - } - } - } - - return cfg -} - // Create creates a new instance for the given repository and branch. func (m *Manager) Create(ctx context.Context, repoPath string, cfg CreateConfig) (*Instance, error) { // Open the repository @@ -260,7 +188,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg CreateConfig) runtime := m.selectRuntime(cfg.Runtime) // Build container run config based on mode (devcontainer vs vanilla) - runCfg := m.buildRunConfig(ctx, cfg, containerName, worktreePath) + runCfg := m.buildRunConfig(cfg, containerName, worktreePath) // Create container c, err := runtime.Run(ctx, runCfg) @@ -322,8 +250,8 @@ func (m *Manager) selectRuntime(override containerRuntime) containerRuntime { // buildRunConfig creates a container.RunConfig based on the creation mode. // For devcontainer mode (WorkspaceFolder set), it configures for devcontainer CLI. -// For vanilla mode, it fetches image metadata and merges flags. -func (m *Manager) buildRunConfig(ctx context.Context, cfg CreateConfig, containerName, worktreePath string) *container.RunConfig { +// For vanilla mode, it applies config flags. +func (m *Manager) buildRunConfig(cfg CreateConfig, containerName, worktreePath string) *container.RunConfig { // Devcontainer mode: minimal config, devcontainer CLI handles the rest if cfg.WorkspaceFolder != "" { return &container.RunConfig{ @@ -335,18 +263,14 @@ func (m *Manager) buildRunConfig(ctx context.Context, cfg CreateConfig, containe } } - // Vanilla mode: fetch image metadata and merge flags - imgCfg := m.getImageRuntimeConfig(ctx, cfg.Image) - mergedFlags := flags.Merge(imgCfg.Flags, m.configFlags) - + // Vanilla mode: use config flags only return &container.RunConfig{ Name: containerName, Image: cfg.Image, Mounts: []container.Mount{ {Source: worktreePath, Target: "/workspace", ReadOnly: false}, }, - Init: imgCfg.Init, - Flags: flags.ToArgs(mergedFlags), + Flags: flags.ToArgs(m.configFlags), } } @@ -645,12 +569,6 @@ func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, er return nil, shutdownErr } - // Fetch image metadata to get runtime configuration from labels - imgCfg := m.getImageRuntimeConfig(ctx, image) - - // Merge flags: config takes precedence over image labels - mergedFlags := flags.Merge(imgCfg.Flags, m.configFlags) - // Create new container containerName := m.containerName(entry.RepoID, entry.Branch) c, err := m.runtime.Run(ctx, &container.RunConfig{ @@ -659,8 +577,7 @@ func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, er Mounts: []container.Mount{ {Source: entry.Worktree, Target: "/workspace", ReadOnly: false}, }, - Init: imgCfg.Init, - Flags: flags.ToArgs(mergedFlags), + Flags: flags.ToArgs(m.configFlags), }) if err != nil { entry.Status = catalog.StatusError diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 264b5a4..670a3f5 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -18,8 +18,6 @@ import ( gitmocks "github.com/jmgilman/headjack/internal/git/mocks" "github.com/jmgilman/headjack/internal/multiplexer" muxmocks "github.com/jmgilman/headjack/internal/multiplexer/mocks" - "github.com/jmgilman/headjack/internal/registry" - registrymocks "github.com/jmgilman/headjack/internal/registry/mocks" ) // Test constants for repeated values. @@ -30,20 +28,20 @@ const ( func TestNewManager(t *testing.T) { t.Run("sets worktrees directory", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(nil, nil, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) require.NotNil(t, mgr) assert.Equal(t, "/data/worktrees", mgr.worktreesDir) }) t.Run("defaults RuntimeType to Docker when not specified", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(nil, nil, nil, nil, ManagerConfig{}) assert.Equal(t, RuntimeDocker, mgr.runtimeType) }) t.Run("respects explicit RuntimeType", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil, ManagerConfig{RuntimeType: RuntimePodman}) + mgr := NewManager(nil, nil, nil, nil, ManagerConfig{RuntimeType: RuntimePodman}) assert.Equal(t, RuntimePodman, mgr.runtimeType) }) @@ -87,7 +85,7 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, runtime, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) inst, err := mgr.Create(ctx, "/path/to/repo", CreateConfig{ Branch: "feature/auth", @@ -126,7 +124,7 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, nil, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) @@ -158,7 +156,7 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, nil, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) @@ -201,7 +199,7 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, runtime, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) @@ -238,7 +236,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) inst, err := mgr.Get(ctx, "abc123") @@ -255,7 +253,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.Get(ctx, "nonexistent") @@ -295,7 +293,7 @@ func TestManager_GetByBranch(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, opener, nil, ManagerConfig{}) inst, err := mgr.GetByBranch(ctx, "/path/to/repo", "main") @@ -319,7 +317,7 @@ func TestManager_GetByBranch(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, opener, nil, ManagerConfig{}) _, err := mgr.GetByBranch(ctx, "/path/to/repo", "nonexistent") @@ -348,7 +346,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) instances, err := mgr.List(ctx, ListFilter{}) @@ -371,7 +369,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) instances, err := mgr.List(ctx, ListFilter{Status: StatusRunning}) @@ -404,7 +402,7 @@ func TestManager_Stop(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) err := mgr.Stop(ctx, "abc123") @@ -420,7 +418,7 @@ func TestManager_Stop(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) err := mgr.Stop(ctx, "nonexistent") @@ -464,7 +462,7 @@ func TestManager_Remove(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, opener, nil, ManagerConfig{}) err := mgr.Remove(ctx, "abc123") @@ -482,7 +480,7 @@ func TestManager_Remove(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) err := mgr.Remove(ctx, "nonexistent") @@ -527,7 +525,7 @@ func TestManager_Recreate(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) inst, err := mgr.Recreate(ctx, "abc123", "newimage:v2") @@ -551,7 +549,7 @@ func TestManager_Recreate(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.Recreate(ctx, "nonexistent", "image") @@ -585,7 +583,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{ Command: []string{"bash", "-c", "echo hello"}, @@ -617,7 +615,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{}) @@ -642,7 +640,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{}) @@ -723,7 +721,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, mux, nil, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, runtime, nil, mux, ManagerConfig{LogsDir: logsDir}) session, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{}) @@ -769,7 +767,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, mux, nil, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, runtime, nil, mux, ManagerConfig{LogsDir: logsDir}) session, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{Name: "my-session"}) @@ -795,7 +793,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) _, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{Name: "existing-session"}) @@ -818,7 +816,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) _, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{}) @@ -832,7 +830,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.CreateSession(ctx, "nonexistent", &CreateSessionConfig{}) @@ -857,7 +855,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) session, err := mgr.GetSession(ctx, "abc12345", "second-session") @@ -877,7 +875,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetSession(ctx, "abc12345", "nonexistent") @@ -891,7 +889,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetSession(ctx, "nonexistent", "any") @@ -916,7 +914,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) sessions, err := mgr.ListSessions(ctx, "abc12345") @@ -936,7 +934,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) sessions, err := mgr.ListSessions(ctx, "abc12345") @@ -951,7 +949,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.ListSessions(ctx, "nonexistent") @@ -990,7 +988,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, nil, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, nil, nil, mux, ManagerConfig{LogsDir: logsDir}) err := mgr.KillSession(ctx, "abc12345", "my-session") @@ -1022,7 +1020,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) err := mgr.KillSession(ctx, "abc12345", "my-session") @@ -1039,7 +1037,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) err := mgr.KillSession(ctx, "abc12345", "nonexistent") @@ -1053,7 +1051,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) err := mgr.KillSession(ctx, "nonexistent", "any") @@ -1093,7 +1091,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "my-session") @@ -1112,7 +1110,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "nonexistent") @@ -1152,7 +1150,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "my-session") @@ -1182,7 +1180,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) session, err := mgr.GetMRUSession(ctx, "abc12345") @@ -1200,7 +1198,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetMRUSession(ctx, "abc12345") @@ -1214,7 +1212,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetMRUSession(ctx, "nonexistent") @@ -1222,190 +1220,6 @@ func TestManager_GetMRUSession(t *testing.T) { }) } -func TestGetImageRuntimeConfig(t *testing.T) { - ctx := context.Background() - - t.Run("returns defaults when registry is nil", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:latest") - - assert.Empty(t, cfg.Init) - assert.Nil(t, cfg.Flags) - }) - - t.Run("returns defaults when registry returns error", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return nil, errors.New("registry unavailable") - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:latest") - - assert.Empty(t, cfg.Init) - assert.Nil(t, cfg.Flags) - require.Len(t, reg.GetMetadataCalls(), 1) - }) - - t.Run("extracts init label", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - }) - - t.Run("extracts podman flags when using podman runtime", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.podman.flags": "systemd=always privileged=true", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - assert.Equal(t, "always", cfg.Flags["systemd"]) - assert.Equal(t, true, cfg.Flags["privileged"]) - }) - - t.Run("ignores podman flags when using docker runtime", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.podman.flags": "systemd=always privileged=true", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimeDocker, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - assert.Nil(t, cfg.Flags, "podman flags should be ignored for docker runtime") - }) - - t.Run("ignores docker flags when using podman runtime", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.docker.flags": "privileged=true memory=4g", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - assert.Nil(t, cfg.Flags, "docker flags should be ignored for podman runtime") - }) - - t.Run("extracts docker flags when using docker runtime", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.docker.flags": "privileged=true memory=4g", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimeDocker, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - assert.Equal(t, true, cfg.Flags["privileged"]) - assert.Equal(t, "4g", cfg.Flags["memory"]) - }) - - t.Run("ignores docker flags when using podman runtime", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: map[string]string{ - "io.headjack.init": "/lib/systemd/systemd", - "io.headjack.docker.flags": "privileged=true memory=4g", - }, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:systemd") - - assert.Equal(t, "/lib/systemd/systemd", cfg.Init) - assert.Nil(t, cfg.Flags, "docker flags should be ignored for podman runtime") - }) - - t.Run("returns empty config when labels are nil", func(t *testing.T) { - reg := ®istrymocks.ClientMock{ - GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - return ®istry.ImageMetadata{ - Labels: nil, - }, nil - }, - } - - mgr := NewManager(nil, nil, nil, nil, reg, ManagerConfig{ - RuntimeType: RuntimePodman, - }) - - cfg := mgr.getImageRuntimeConfig(ctx, "myimage:latest") - - assert.Empty(t, cfg.Init) - assert.Nil(t, cfg.Flags) - }) -} - func TestManager_GetGlobalMRUSession(t *testing.T) { ctx := context.Background() @@ -1438,7 +1252,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) result, err := mgr.GetGlobalMRUSession(ctx) @@ -1457,7 +1271,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetGlobalMRUSession(ctx) @@ -1471,7 +1285,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) _, err := mgr.GetGlobalMRUSession(ctx) diff --git a/internal/registry/client.go b/internal/registry/client.go deleted file mode 100644 index 5e6f88f..0000000 --- a/internal/registry/client.go +++ /dev/null @@ -1,124 +0,0 @@ -package registry - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "net/http" - "runtime" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/remote/transport" -) - -// client implements the Client interface using go-containerregistry. -type client struct { - config ClientConfig -} - -// NewClient creates a new registry client with the given configuration. -func NewClient(cfg ClientConfig) Client { - return &client{config: cfg} -} - -// GetMetadata fetches metadata for an image reference. -func (c *client) GetMetadata(ctx context.Context, ref string) (*ImageMetadata, error) { - // Build name options for reference parsing - var nameOpts []name.Option - if c.config.Insecure { - nameOpts = append(nameOpts, name.Insecure) - } - - // Parse the image reference - parsedRef, err := name.ParseReference(ref, nameOpts...) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidRef, err) - } - - // Build remote options - opts := []remote.Option{ - remote.WithAuthFromKeychain(authn.DefaultKeychain), - remote.WithContext(ctx), - // Use current platform to handle multi-arch images - remote.WithPlatform(v1.Platform{ - Architecture: runtime.GOARCH, - OS: "linux", - }), - } - - // Allow insecure (HTTP and skip TLS verify) connections if configured - if c.config.Insecure { - // Clone http.DefaultTransport to preserve proxy, keep-alive, and timeout settings. - // Fall back to a basic transport if the type assertion fails (shouldn't happen in practice). - var insecureTransport *http.Transport - if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { - insecureTransport = defaultTransport.Clone() - } else { - insecureTransport = &http.Transport{} - } - insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // intentional for insecure mode - opts = append(opts, remote.WithTransport(insecureTransport)) - } - - // Fetch the image - img, err := remote.Image(parsedRef, opts...) - if err != nil { - return nil, c.mapError(err) - } - - // Get the image digest - digest, err := img.Digest() - if err != nil { - return nil, fmt.Errorf("failed to get image digest: %w", err) - } - - // Get the config file (contains labels, architecture, OS, etc.) - configFile, err := img.ConfigFile() - if err != nil { - return nil, fmt.Errorf("failed to get image config: %w", err) - } - - // Build the metadata response - metadata := &ImageMetadata{ - Digest: digest.String(), - Labels: configFile.Config.Labels, - Architecture: configFile.Architecture, - OS: configFile.OS, - } - - // Set created time if available - if !configFile.Created.IsZero() { - metadata.Created = configFile.Created.Time - } - - return metadata, nil -} - -// mapError converts go-containerregistry errors to sentinel errors. -func (c *client) mapError(err error) error { - // Check for transport errors (HTTP status codes) - var transportErr *transport.Error - if errors.As(err, &transportErr) { - for _, diagnostic := range transportErr.Errors { - switch diagnostic.Code { - case transport.UnauthorizedErrorCode: - return fmt.Errorf("%w: %s", ErrUnauthorized, err) - case transport.ManifestUnknownErrorCode, transport.NameUnknownErrorCode: - return fmt.Errorf("%w: %s", ErrImageNotFound, err) - } - } - // Check HTTP status code as fallback - switch transportErr.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden: - return fmt.Errorf("%w: %s", ErrUnauthorized, err) - case http.StatusNotFound: - return fmt.Errorf("%w: %s", ErrImageNotFound, err) - } - } - - return fmt.Errorf("registry error: %w", err) -} diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go deleted file mode 100644 index 733dc18..0000000 --- a/internal/registry/client_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package registry - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - "github.com/google/go-containerregistry/pkg/v1/random" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/remote/transport" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewClient(t *testing.T) { - client := NewClient(ClientConfig{}) - require.NotNil(t, client) -} - -func TestClient_GetMetadata(t *testing.T) { - ctx := context.Background() - - t.Run("fetches metadata from registry", func(t *testing.T) { - // Start a test registry - reg := registry.New() - server := httptest.NewServer(reg) - defer server.Close() - - // Create and push a test image - img, err := random.Image(1024, 1) - require.NoError(t, err) - - // Get the registry host from the server URL - regHost := strings.TrimPrefix(server.URL, "http://") - ref, err := name.ParseReference(regHost + "/test/image:latest") - require.NoError(t, err) - - // Push the image to the test registry - err = remote.Write(ref, img) - require.NoError(t, err) - - // Test GetMetadata - client := NewClient(ClientConfig{Insecure: true}) - metadata, err := client.GetMetadata(ctx, regHost+"/test/image:latest") - - require.NoError(t, err) - assert.NotEmpty(t, metadata.Digest) - assert.True(t, strings.HasPrefix(metadata.Digest, "sha256:")) - // Note: random.Image may not set Architecture/OS, so we just verify the call succeeded - }) - - t.Run("returns ErrInvalidRef for malformed reference", func(t *testing.T) { - client := NewClient(ClientConfig{}) - _, err := client.GetMetadata(ctx, ":::invalid:::reference") - - assert.ErrorIs(t, err, ErrInvalidRef) - }) - - t.Run("returns ErrImageNotFound for missing image", func(t *testing.T) { - // Start a test registry - reg := registry.New() - server := httptest.NewServer(reg) - defer server.Close() - - regHost := strings.TrimPrefix(server.URL, "http://") - client := NewClient(ClientConfig{Insecure: true}) - _, err := client.GetMetadata(ctx, regHost+"/nonexistent/image:latest") - - assert.ErrorIs(t, err, ErrImageNotFound) - }) -} - -func TestClient_mapError(t *testing.T) { - c := &client{} - - t.Run("maps UNAUTHORIZED error code to ErrUnauthorized", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusUnauthorized, - Errors: []transport.Diagnostic{ - {Code: transport.UnauthorizedErrorCode}, - }, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrUnauthorized) - }) - - t.Run("maps 401 status to ErrUnauthorized", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusUnauthorized, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrUnauthorized) - }) - - t.Run("maps 403 status to ErrUnauthorized", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusForbidden, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrUnauthorized) - }) - - t.Run("maps MANIFEST_UNKNOWN error code to ErrImageNotFound", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusNotFound, - Errors: []transport.Diagnostic{ - {Code: transport.ManifestUnknownErrorCode}, - }, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrImageNotFound) - }) - - t.Run("maps NAME_UNKNOWN error code to ErrImageNotFound", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusNotFound, - Errors: []transport.Diagnostic{ - {Code: transport.NameUnknownErrorCode}, - }, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrImageNotFound) - }) - - t.Run("maps 404 status to ErrImageNotFound", func(t *testing.T) { - transportErr := &transport.Error{ - StatusCode: http.StatusNotFound, - } - result := c.mapError(transportErr) - - assert.ErrorIs(t, result, ErrImageNotFound) - }) - - t.Run("wraps unknown errors", func(t *testing.T) { - unknownErr := errors.New("some unknown error") - result := c.mapError(unknownErr) - - assert.Contains(t, result.Error(), "registry error") - assert.ErrorIs(t, result, unknownErr) - }) -} diff --git a/internal/registry/mocks/client.go b/internal/registry/mocks/client.go deleted file mode 100644 index 610a3c9..0000000 --- a/internal/registry/mocks/client.go +++ /dev/null @@ -1,83 +0,0 @@ -// Code generated by moq; DO NOT EDIT. -// github.com/matryer/moq - -package mocks - -import ( - "context" - "sync" - - "github.com/jmgilman/headjack/internal/registry" -) - -// Ensure, that ClientMock does implement registry.Client. -// If this is not the case, regenerate this file with moq. -var _ registry.Client = &ClientMock{} - -// ClientMock is a mock implementation of registry.Client. -// -// func TestSomethingThatUsesClient(t *testing.T) { -// -// // make and configure a mocked registry.Client -// mockedClient := &ClientMock{ -// GetMetadataFunc: func(ctx context.Context, ref string) (*registry.ImageMetadata, error) { -// panic("mock out the GetMetadata method") -// }, -// } -// -// // use mockedClient in code that requires registry.Client -// // and then make assertions. -// -// } -type ClientMock struct { - // GetMetadataFunc mocks the GetMetadata method. - GetMetadataFunc func(ctx context.Context, ref string) (*registry.ImageMetadata, error) - - // calls tracks calls to the methods. - calls struct { - // GetMetadata holds details about calls to the GetMetadata method. - GetMetadata []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Ref is the ref argument value. - Ref string - } - } - lockGetMetadata sync.RWMutex -} - -// GetMetadata calls GetMetadataFunc. -func (mock *ClientMock) GetMetadata(ctx context.Context, ref string) (*registry.ImageMetadata, error) { - if mock.GetMetadataFunc == nil { - panic("ClientMock.GetMetadataFunc: method is nil but Client.GetMetadata was just called") - } - callInfo := struct { - Ctx context.Context - Ref string - }{ - Ctx: ctx, - Ref: ref, - } - mock.lockGetMetadata.Lock() - mock.calls.GetMetadata = append(mock.calls.GetMetadata, callInfo) - mock.lockGetMetadata.Unlock() - return mock.GetMetadataFunc(ctx, ref) -} - -// GetMetadataCalls gets all the calls that were made to GetMetadata. -// Check the length with: -// -// len(mockedClient.GetMetadataCalls()) -func (mock *ClientMock) GetMetadataCalls() []struct { - Ctx context.Context - Ref string -} { - var calls []struct { - Ctx context.Context - Ref string - } - mock.lockGetMetadata.RLock() - calls = mock.calls.GetMetadata - mock.lockGetMetadata.RUnlock() - return calls -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go deleted file mode 100644 index 6e1cda0..0000000 --- a/internal/registry/registry.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package registry provides operations for fetching OCI image metadata from container registries. -package registry - -import ( - "context" - "errors" - "time" -) - -// Sentinel errors for registry operations. -var ( - // ErrImageNotFound is returned when the requested image does not exist. - ErrImageNotFound = errors.New("image not found") - - // ErrUnauthorized is returned when authentication fails. - ErrUnauthorized = errors.New("unauthorized") - - // ErrInvalidRef is returned when the image reference is malformed. - ErrInvalidRef = errors.New("invalid image reference") -) - -// ImageMetadata contains OCI image metadata fetched from a registry. -type ImageMetadata struct { - // Digest is the image's content-addressable digest (e.g., "sha256:..."). - Digest string - - // Labels from the image config (OCI annotations). - Labels map[string]string - - // Created is when the image was created. - Created time.Time - - // Architecture is the CPU architecture (e.g., "amd64", "arm64"). - Architecture string - - // OS is the operating system (e.g., "linux"). - OS string -} - -// ClientConfig configures the registry client. -type ClientConfig struct { - // Insecure allows HTTP (non-TLS) connections to registries. - Insecure bool -} - -// Client fetches image metadata from OCI registries. -// -//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/client.go . Client -type Client interface { - // GetMetadata fetches metadata for an image reference. - // The reference can be a tag (e.g., "ghcr.io/foo/bar:latest") - // or digest (e.g., "ghcr.io/foo/bar@sha256:..."). - GetMetadata(ctx context.Context, ref string) (*ImageMetadata, error) -} From e6304dfad056b1a6a2bf82e685fee7565820e478 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 15:05:49 -0800 Subject: [PATCH 2/2] fix(docs): remove labels.md from sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: Sidebar referenced the deleted labels.md file, causing Docusaurus build to fail on Cloudflare. New behavior: Sidebar only references the overview page for container images. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/sidebars.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 7e75f92..85fb3c9 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -118,7 +118,6 @@ const sidebars: SidebarsConfig = { }, items: [ 'reference/images/overview', - 'reference/images/labels', ], }, ],