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/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', ], }, ], 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) -}