From 07f6010fde6e4fd3d43ccb21f46e3ec3213ade62 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 13:16:19 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(workspace):=20remote=20shared=20backen?= =?UTF-8?q?d=20=E2=80=94=20run=20the=20shared=20stack=20on=20a=20remote=20?= =?UTF-8?q?Docker=20host=20(spec=2021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the shared stack to run on a remote Docker host (an SSH/TCP `docker context` or a DOCKER_HOST endpoint) instead of only the local daemon. Ref-counting, provisioning, and DNS/alias are backend-agnostic; this swaps WHERE containers run. Default is local, byte-for-byte unchanged. - internal/docker: a Backend seam (ComposeEnv/Reachability/NewClient/ RemoteReachable). Local = zero value (unchanged). Remote binds moby to the resolved endpoint and keys the ledger by the context name/host so remote rows never bleed into local counts, like the WSL2 Desktop-vs-dockerd split. - internal/docker: Compose gains ContextEnv, appended after the secret Env, pinning every compose verb to the endpoint (DOCKER_HOST/DOCKER_CONTEXT). - internal/config: a workspace `backend:` selector (+ store global default); validated (dockerhost scheme; context XOR host). - orchestrate/up + cli/up: thread the backend into the client, ledger key, and every compose driver. Remote host-side Postgres provisioning is guarded with a clear error (scoped follow-up — needs an SSH tunnel). - doctor: a backend.remote reachability probe with remediation. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/backend.go | 28 ++++ internal/cli/doctor.go | 29 +++- internal/cli/doctor_backend_test.go | 57 +++++++ internal/cli/up.go | 10 +- internal/config/backend_test.go | 91 +++++++++++ internal/config/model.go | 25 ++- internal/config/validate.go | 33 ++++ internal/docker/backend.go | 194 ++++++++++++++++++++++++ internal/docker/backend_test.go | 163 ++++++++++++++++++++ internal/docker/compose.go | 30 +++- internal/orchestrate/backend_up_test.go | 125 +++++++++++++++ internal/orchestrate/up.go | 26 +++- internal/store/store.go | 4 + 13 files changed, 803 insertions(+), 12 deletions(-) create mode 100644 internal/cli/backend.go create mode 100644 internal/cli/doctor_backend_test.go create mode 100644 internal/config/backend_test.go create mode 100644 internal/docker/backend.go create mode 100644 internal/docker/backend_test.go create mode 100644 internal/orchestrate/backend_up_test.go diff --git a/internal/cli/backend.go b/internal/cli/backend.go new file mode 100644 index 0000000..b1ac161 --- /dev/null +++ b/internal/cli/backend.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/store" +) + +// backendFor resolves the Docker backend (spec 21) for a loaded workspace: WHERE +// the shared stack (and, in the all-remote topology, the project stacks) run. The +// precedence is workspace.yaml `backend:` → the machine-global store default → +// local. A local backend is the default and reproduces today's behavior verbatim. +// +// The read-only docker client and the compose CLI are then bound to the returned +// backend (Backend.NewClient / Compose.ContextEnv), and the state ledger is keyed +// by the backend's context so remote rows never bleed into local counts. +func backendFor(m *config.Model) docker.Backend { + if m != nil && m.Workspace.Backend.IsRemote() { + b := m.Workspace.Backend + return docker.Backend{Context: b.Context, Host: b.Host} + } + // Fall back to the machine-global default (best-effort: a missing/broken store + // simply yields the local backend). + if cfg, ok, err := store.Load(); err == nil && ok && cfg.Backend.IsRemote() { + return docker.Backend{Context: cfg.Backend.Context, Host: cfg.Backend.Host} + } + return docker.Backend{} +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index e894248..682c56b 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -171,6 +171,7 @@ type doctorSession struct { model *config.Model // nil when cwd is not a workspace ctxName string lockPath string + backend docker.Backend // the resolved Docker backend (spec 21); zero = local } // openDoctorSession opens the docker client and state ledger once (best-effort) @@ -187,7 +188,11 @@ func openDoctorSession(cmd *cobra.Command) (*doctorSession, func()) { if m, err := config.Load(cwd); err == nil { s.model = m } - if c, err := docker.NewClient(ctx); err == nil { + // Resolve the Docker backend (spec 21) so doctor probes the endpoint the rest + // of the tool will actually target — a remote context/DOCKER_HOST when the + // workspace (or the store default) selects one, else the local daemon. + s.backend = backendFor(s.model) + if c, err := s.backend.NewClient(ctx); err == nil { s.client = c s.ctxName = c.ContextName() } else { @@ -208,6 +213,21 @@ func openDoctorSession(cmd *cobra.Command) (*doctorSession, func()) { } } +// remoteBackendProbe returns the backend.remote reachability check (spec 21), +// or ok=false for the local backend (nothing remote to probe — the docker-daemon +// preflight already covers the local endpoint). Extracted so it is testable +// without the full probe matrix (which needs a live ledger handle). +func (s *doctorSession) remoteBackendProbe(ctx context.Context) (probe, bool) { + if !s.backend.IsRemote() { + return probe{}, false + } + c := s.backend.RemoteReachable(ctx, s.client) + if c.Name == "" { + return probe{}, false + } + return plain(withCategory(c, catCritical)), true +} + // manager builds a workspace.Manager over the session's shared handles. Reconcile // (the state.refs fix) uses only DB/Docker/LockPath, so a nil Model is fine. func (s *doctorSession) manager() *workspace.Manager { @@ -275,6 +295,13 @@ func (s *doctorSession) probes(ctx context.Context) []probe { } } + // backend.remote — the configured remote backend is reachable (spec 21). Only + // emitted when a remote context/DOCKER_HOST is selected; the local backend adds + // nothing here (the docker-daemon preflight above already covers it). + if p, ok := s.remoteBackendProbe(ctx); ok { + probes = append(probes, p) + } + // state.ledger — the ledger opens/migrates cleanly (critical). if s.dbErr != nil { probes = append(probes, plain(docker.Check{ diff --git a/internal/cli/doctor_backend_test.go b/internal/cli/doctor_backend_test.go new file mode 100644 index 0000000..c6d4cd0 --- /dev/null +++ b/internal/cli/doctor_backend_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "github.com/open-source-cloud/devstack/internal/docker" +) + +// TestDoctorRemoteBackendProbeReachable: with a remote backend selected and a +// reachable client, doctor emits a green backend.remote check reporting the +// engine version. +func TestDoctorRemoteBackendProbeReachable(t *testing.T) { + s := &doctorSession{ + backend: docker.Backend{Context: "prod"}, + client: &docker.MockClient{Context: "prod", Server: "27.1.1"}, + } + p, ok := s.remoteBackendProbe(context.Background()) + if !ok { + t.Fatal("no backend.remote probe emitted for a remote backend") + } + if p.check.ID != "backend.remote" || p.check.Status != docker.StatusOK || p.check.Detail != "Engine v27.1.1" { + t.Fatalf("backend.remote = %+v, want ok / Engine v27.1.1", p.check) + } + if p.check.Category != catCritical { + t.Errorf("category = %q, want %q", p.check.Category, catCritical) + } +} + +// TestDoctorRemoteBackendProbeUnreachable: an unreachable remote endpoint yields +// a failing backend.remote check with an actionable remediation. +func TestDoctorRemoteBackendProbeUnreachable(t *testing.T) { + s := &doctorSession{ + backend: docker.Backend{Host: "ssh://dev@box"}, + client: &docker.MockClient{PingErr: errors.New("dial ssh: no route to host")}, + } + p, ok := s.remoteBackendProbe(context.Background()) + if !ok { + t.Fatal("no backend.remote probe emitted for a remote backend") + } + if p.check.Status != docker.StatusFail || p.check.Remediation == "" { + t.Fatalf("backend.remote = %+v, want fail with remediation", p.check) + } +} + +// TestDoctorLocalBackendNoRemoteProbe: the default local backend adds no +// backend.remote probe (the docker-daemon preflight already covers it). +func TestDoctorLocalBackendNoRemoteProbe(t *testing.T) { + s := &doctorSession{ + backend: docker.LocalBackend(), + client: &docker.MockClient{Context: "default"}, + } + if _, ok := s.remoteBackendProbe(context.Background()); ok { + t.Fatal("local backend emitted a backend.remote probe") + } +} diff --git a/internal/cli/up.go b/internal/cli/up.go index 0e22f80..a308810 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -209,7 +209,7 @@ func downProject(ctx context.Context, d orchestrate.UpDeps, project string) erro } } - cp := docker.Compose{Project: "devstack-" + project, File: composeFile, Dir: outDir, Runner: docker.ExecRunner{}} + cp := docker.Compose{Project: "devstack-" + project, File: composeFile, Dir: outDir, Runner: docker.ExecRunner{}, ContextEnv: d.Backend.ComposeEnv()} if err := cp.Down(ctx, false); err != nil { return err } @@ -233,7 +233,11 @@ func buildUpDeps(cmd *cobra.Command) (orchestrate.UpDeps, func(), error) { return zero, nil, err } ctx := cmd.Context() - dc, err := docker.NewClient(ctx) + // Resolve the Docker backend (spec 21): local (default) or a remote context/ + // DOCKER_HOST. The read-only client and the ledger's context key both bind to + // this backend, so remote rows never bleed into the local counts. + backend := backendFor(model) + dc, err := backend.NewClient(ctx) if err != nil { return zero, nil, fmt.Errorf("docker client: %w", err) } @@ -245,7 +249,7 @@ func buildUpDeps(cmd *cobra.Command) (orchestrate.UpDeps, func(), error) { lockPath := filepath.Join(xdg.RuntimeDir(), "devstack.lock") mgr := &workspace.Manager{Model: model, DB: db, Docker: dc, Source: builtinSource(), LockPath: lockPath} d := orchestrate.UpDeps{ - Model: model, DB: db, Docker: dc, Manager: mgr, + Model: model, DB: db, Docker: dc, Manager: mgr, Backend: backend, Source: mgr.Source, LockPath: lockPath, } closeFn := func() { diff --git a/internal/config/backend_test.go b/internal/config/backend_test.go new file mode 100644 index 0000000..6be4361 --- /dev/null +++ b/internal/config/backend_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "strings" + "testing" +) + +// minimalWS is a valid single-file workspace (no projects) with the given +// backend: block spliced in. A shared postgres keeps it non-trivial. +func minimalWS(backendBlock string) string { + return `apiVersion: devstack/v1 +kind: Workspace +name: acme +` + backendBlock + ` +shared: + postgres: + template: postgres + params: + version: "16" +` +} + +func loadWS(t *testing.T, backendBlock string) (*Model, error) { + t.Helper() + root := writeTree(t, map[string]string{"workspace.yaml": minimalWS(backendBlock)}) + return LoadAt(root) +} + +func TestBackendDefaultLocal(t *testing.T) { + m, err := loadWS(t, "") + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + if m.Workspace.Backend != nil { + t.Errorf("Backend = %+v, want nil (default local)", m.Workspace.Backend) + } + if m.Workspace.Backend.IsRemote() { + t.Error("nil Backend.IsRemote() = true, want false") + } +} + +func TestBackendContextValid(t *testing.T) { + m, err := loadWS(t, "backend:\n context: prod-cluster") + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + if m.Workspace.Backend == nil || m.Workspace.Backend.Context != "prod-cluster" { + t.Fatalf("Backend = %+v, want context=prod-cluster", m.Workspace.Backend) + } + if !m.Workspace.Backend.IsRemote() { + t.Error("Backend.IsRemote() = false, want true") + } +} + +func TestBackendHostValid(t *testing.T) { + m, err := loadWS(t, "backend:\n host: ssh://dev@build-host") + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + if m.Workspace.Backend == nil || m.Workspace.Backend.Host != "ssh://dev@build-host" { + t.Fatalf("Backend = %+v, want host=ssh://dev@build-host", m.Workspace.Backend) + } +} + +func TestBackendHostBadScheme(t *testing.T) { + _, err := loadWS(t, "backend:\n host: dev@build-host") + if err == nil { + t.Fatal("expected validation error for a scheme-less DOCKER_HOST") + } + if !strings.Contains(err.Error(), "dockerhost") && !strings.Contains(err.Error(), "Host") { + t.Errorf("error = %q, want it to mention the host field/rule", err) + } +} + +func TestBackendContextAndHostMutuallyExclusive(t *testing.T) { + _, err := loadWS(t, "backend:\n context: prod\n host: ssh://dev@box") + if err == nil { + t.Fatal("expected error when both context and host are set") + } + if !strings.Contains(err.Error(), "not both") { + t.Errorf("error = %q, want it to explain context/host are mutually exclusive", err) + } +} + +func TestBackendHostSchemes(t *testing.T) { + for _, host := range []string{"ssh://u@h", "tcp://1.2.3.4:2376", "unix:///var/run/docker.sock"} { + if _, err := loadWS(t, "backend:\n host: "+host); err != nil { + t.Errorf("host %q rejected: %v", host, err) + } + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 15787a5..8a86b0a 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -31,11 +31,34 @@ type Workspace struct { MemoryBudgetMB int `yaml:"memoryBudgetMB"` // spec 12/18 — warn when active services' memoryMB sum exceeds this Secrets Secrets `yaml:"secrets"` Network Network `yaml:"network"` - Hooks Hooks `yaml:"hooks"` // spec 11 — workspace-scope lifecycle hooks + Backend *BackendConfig `yaml:"backend"` // spec 21 — where the shared stack runs (nil = local) + Hooks Hooks `yaml:"hooks"` // spec 11 — workspace-scope lifecycle hooks Shared map[string]SharedSvc `yaml:"shared" validate:"dive"` Projects []ProjectRef `yaml:"projects" validate:"dive"` } +// BackendConfig selects WHERE the shared stack runs (spec 21). nil / the zero +// value means the LOCAL Docker daemon (the active context / DOCKER_HOST) — the +// default and fully backward-compatible. Exactly one of Context or Host may be +// set to target a remote host: +// - Context names a `docker context` (typically an ssh:// one, which inherits +// the user's SSH config/agent/ProxyJump for free — DECISIONS D9). +// - Host is a raw DOCKER_HOST endpoint (ssh://, tcp://, unix://). +// +// The ledger is already keyed by Docker context (spec 08), so a remote endpoint +// simply keys its own rows — no cross-context count bleed with the local daemon. +// The mutual-exclusion of Context vs Host is enforced by the cross-field +// validator (validateBackend); the host scheme by the `dockerhost` field rule. +type BackendConfig struct { + Context string `yaml:"context"` + Host string `yaml:"host" validate:"omitempty,dockerhost"` +} + +// IsRemote reports whether this backend targets a non-local endpoint. +func (b *BackendConfig) IsRemote() bool { + return b != nil && (b.Context != "" || b.Host != "") +} + // Profiles selects the env OVERLAY (config layering), distinct from the service // slices in spec 12. Default overlay is `dev`. type Profiles struct { diff --git a/internal/config/validate.go b/internal/config/validate.go index e79850a..5d352d1 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -44,9 +44,21 @@ func newValidator() *validator.Validate { _ = v.RegisterValidation("platform", func(fl validator.FieldLevel) bool { return platformRE.MatchString(fl.Field().String()) }) + // dockerhost: a DOCKER_HOST endpoint for the remote-backend selector (spec 21). + // Paired with omitempty so an unset field is allowed; only a non-empty value is + // checked against the schemes docker understands. + _ = v.RegisterValidation("dockerhost", func(fl validator.FieldLevel) bool { + return dockerHostRE.MatchString(fl.Field().String()) + }) return v } +// dockerHostRE matches the DOCKER_HOST endpoint schemes the docker CLI accepts +// (spec 21). ssh:// is the primary remote path (inherits the user's SSH setup); +// tcp:// (optionally TLS-guarded) and the local unix://npipe:// sockets round it +// out. Deliberately strict so a typo'd endpoint fails at config-load, not mid-up. +var dockerHostRE = regexp.MustCompile(`^(ssh|tcp|unix|npipe|fd)://.+`) + // platformRE matches a compose `platform:` selector: os/arch with an optional // variant (e.g. linux/amd64, linux/arm64/v8). var platformRE = regexp.MustCompile(`^[a-z0-9]+/[a-z0-9]+(/[a-z0-9]+)?$`) @@ -84,9 +96,30 @@ func validateModel(m *Model, ws *source, projSrc map[string]*source) error { if err := validateResources(m, projSrc); err != nil { return err } + if err := validateBackend(m, ws); err != nil { + return err + } return detectCycles(m) } +// validateBackend enforces the spec-21 remote-backend selector rules that a +// single field tag cannot express: Context and Host are mutually exclusive (both +// set is ambiguous — DOCKER_CONTEXT and DOCKER_HOST would contend). An unset +// block (nil) is the default local backend and always valid. Positioned to +// workspace.yaml so the error renders as file:line:col like every other config +// error. +func validateBackend(m *Model, ws *source) error { + b := m.Workspace.Backend + if b == nil { + return nil + } + if b.Context != "" && b.Host != "" { + return ws.errAt("$.backend", + "backend: set either `context` or `host`, not both (they select the remote Docker endpoint two different ways)") + } + return nil +} + // validateResources checks the spec-27 declarative `resources:` block on each // project: every `uses` targets a declared shared instance, `kind` is one the // target engine's provisioner supports (for known engines — unknown/custom diff --git a/internal/docker/backend.go b/internal/docker/backend.go new file mode 100644 index 0000000..810f5cc --- /dev/null +++ b/internal/docker/backend.go @@ -0,0 +1,194 @@ +package docker + +import ( + "context" + "fmt" + "os/exec" + "strings" + + moby "github.com/moby/moby/client" +) + +// Backend selects WHERE the shared stack (and, in the all-remote topology, the +// project stacks) run — the local Docker daemon or a remote host reached over an +// SSH/TCP `docker context` or a DOCKER_HOST endpoint (spec 21). It is the seam +// that lets internal/workspace stay written against capabilities rather than a +// socket: the ref-counting, provisioning, and DNS/alias model are backend- +// agnostic; a Backend only swaps the Docker endpoint the CLI + read-only SDK +// target, and the reachability strategy for host-side consumers. +// +// The zero value is the LOCAL backend and reproduces today's behavior verbatim +// (default socket / the active docker context, host-routable published ports). +// This is the default path and MUST stay byte-for-byte unchanged. +// +// Exactly one of Context or Host is set for a remote backend: +// - Context names a `docker context` (typically an `ssh://` one, which inherits +// the user's ~/.ssh/config, agent, and ProxyJump for free — DECISIONS D9). +// - Host is a raw DOCKER_HOST endpoint (ssh://, tcp://, unix://). +// +// Scope note (spec 21 is a frontier spec): this pass delivers the single-user, +// SSH-context, all-remote topology with the local flock still serializing a +// single developer's invocations. The distributed lock (two developers, two +// machines) and per-user tenant isolation on a shared cluster are the ~8w +// follow-up and are deliberately NOT implemented here (see the PR body). +type Backend struct { + // Context is the `docker context` name to target (empty = not selected). + Context string + // Host is the DOCKER_HOST endpoint to target (empty = not selected). + Host string +} + +// Reachability classifies how a host-side consumer (host pgx, a psql/GUI) can +// reach the shared services, which drives the port/URL strategy (spec 21 +// §Network reachability). +type Reachability string + +const ( + // HostRoutable: services can publish host ports the developer's machine can + // dial on loopback (the local backend, and Docker Desktop's port proxy). + HostRoutable Reachability = "host-routable" + // ViaProxy: a remote bridge network is on another host entirely and is NOT + // host-routable — host access must go through the proxy/tunnel (an SSH local + // -forward or cloudflared), never a local loopback published port. + ViaProxy Reachability = "via-proxy" +) + +// LocalBackend is the explicit local (default socket / active context) backend. +func LocalBackend() Backend { return Backend{} } + +// IsRemote reports whether this backend targets a non-local endpoint. +func (b Backend) IsRemote() bool { return b.Context != "" || b.Host != "" } + +// Reachability returns the host-reachability class for this backend. Remote +// backends are ViaProxy (the remote bridge is not host-routable); the local +// backend is HostRoutable. +func (b Backend) Reachability() Reachability { + if b.IsRemote() { + return ViaProxy + } + return HostRoutable +} + +// String is a short, stable description for log/doctor output. +func (b Backend) String() string { + switch { + case b.Host != "": + return "remote host " + b.Host + case b.Context != "": + return "remote context " + b.Context + default: + return "local" + } +} + +// ComposeEnv returns the extra environment entries that pin the `docker compose` +// CLI (and any `docker` invocation) to this backend's endpoint. They are appended +// to the child process env by the Runner (exec.Cmd.Env), never written to disk — +// exactly how resolved secrets are threaded (§7.5). Empty for the local backend +// so the default path stays byte-for-byte unchanged. +// +// A remote Host sets DOCKER_HOST; a remote Context sets DOCKER_CONTEXT. They are +// mutually exclusive (validated in config) so the two env vars never contend. +func (b Backend) ComposeEnv() []string { + switch { + case b.Host != "": + return []string{"DOCKER_HOST=" + b.Host} + case b.Context != "": + return []string{"DOCKER_CONTEXT=" + b.Context} + default: + return nil + } +} + +// NewClient builds the READ-ONLY Engine SDK client bound to this backend's +// endpoint. The local backend defers to the package NewClient (FromEnv), so its +// context-name resolution — and therefore the ledger key — is unchanged. A remote +// backend binds moby to the resolved endpoint and keys the ledger by the context +// name (or the host endpoint) so remote rows never bleed into the local counts, +// exactly like the WSL2 Desktop-vs-dockerd split (DECISIONS D6, spec 08). +// +// Construction does NOT contact the daemon — call Ping (or the doctor reachability +// probe) to verify the endpoint is reachable. This matters more for a remote +// backend: an unreachable SSH host must fail with a clear, actionable error, not a +// crash mid-up. +func (b Backend) NewClient(ctx context.Context) (Client, error) { + switch { + case b.Host != "": + cli, err := moby.New(moby.WithHost(b.Host), moby.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("connect to remote docker host %q: %w", b.Host, err) + } + return &mobyClient{cli: cli, ctxName: b.Host}, nil + case b.Context != "": + endpoint, err := contextEndpoint(ctx, b.Context) + if err != nil { + return nil, err + } + cli, err := moby.New(moby.WithHost(endpoint), moby.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("connect to docker context %q (%s): %w", b.Context, endpoint, err) + } + // Key the ledger by the context NAME (stable across endpoint edits), not the + // resolved endpoint, so `docker context` rebinds don't fork the counts. + return &mobyClient{cli: cli, ctxName: b.Context}, nil + default: + return NewClient(ctx) + } +} + +// contextEndpoint resolves a `docker context`'s Docker endpoint (e.g. +// ssh://user@host) by shelling the docker CLI, which already knows how to read +// ~/.docker/contexts. Kept behind the CLI (rather than re-parsing the context +// store) so the user's existing context definitions are the single source of +// truth. A missing/mistyped context surfaces the CLI's own error verbatim. +func contextEndpoint(ctx context.Context, name string) (string, error) { + out, err := exec.CommandContext(ctx, "docker", "context", "inspect", name, + "--format", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + return "", &CmdError{ + Cmd: "docker context inspect " + name, + Err: err, + Stderr: fmt.Sprintf("docker context %q not found or has no docker endpoint; "+ + "create it with `docker context create %s --docker host=ssh://user@host`", name, name), + } + } + endpoint := strings.TrimSpace(string(out)) + if endpoint == "" { + return "", fmt.Errorf("docker context %q resolves to an empty endpoint", name) + } + return endpoint, nil +} + +// RemoteReachable probes a remote backend's endpoint and returns a doctor Check. +// For the local backend it returns a zero Check with an empty Name (callers skip +// empty-named checks) — there is nothing remote to probe. The client should be +// one built via Backend.NewClient; a nil client means construction already failed. +func (b Backend) RemoteReachable(ctx context.Context, client Client) Check { + if !b.IsRemote() { + return Check{} + } + name := "remote backend (" + b.String() + ")" + remediation := "verify the endpoint: `docker --context " + b.Context + " info` (or DOCKER_HOST) works, " + + "the remote daemon is running, and your SSH access (~/.ssh/config, agent) is set up" + if b.Host != "" { + remediation = "verify `docker -H " + b.Host + " info` works and the remote daemon is reachable" + } + if client == nil { + return Check{ + Name: name, ID: "backend.remote", Category: "critical", + Status: StatusFail, Detail: "could not construct a docker client for the remote endpoint", + Remediation: remediation, + } + } + if err := client.Ping(ctx); err != nil { + return Check{ + Name: name, ID: "backend.remote", Category: "critical", + Status: StatusFail, Detail: err.Error(), Remediation: remediation, + } + } + detail := "reachable" + if sv, err := client.ServerVersion(ctx); err == nil { + detail = "Engine v" + sv + } + return Check{Name: name, ID: "backend.remote", Category: "critical", Status: StatusOK, Detail: detail} +} diff --git a/internal/docker/backend_test.go b/internal/docker/backend_test.go new file mode 100644 index 0000000..dcdf0f6 --- /dev/null +++ b/internal/docker/backend_test.go @@ -0,0 +1,163 @@ +package docker + +import ( + "context" + "errors" + "testing" +) + +func TestBackendIsRemoteAndReachability(t *testing.T) { + tests := []struct { + name string + b Backend + remote bool + reach Reachability + compose []string + }{ + {"local zero value", Backend{}, false, HostRoutable, nil}, + {"remote context", Backend{Context: "prod"}, true, ViaProxy, []string{"DOCKER_CONTEXT=prod"}}, + {"remote host", Backend{Host: "ssh://dev@box"}, true, ViaProxy, []string{"DOCKER_HOST=ssh://dev@box"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.b.IsRemote(); got != tt.remote { + t.Errorf("IsRemote() = %v, want %v", got, tt.remote) + } + if got := tt.b.Reachability(); got != tt.reach { + t.Errorf("Reachability() = %v, want %v", got, tt.reach) + } + got := tt.b.ComposeEnv() + if len(got) != len(tt.compose) { + t.Fatalf("ComposeEnv() = %v, want %v", got, tt.compose) + } + for i := range got { + if got[i] != tt.compose[i] { + t.Errorf("ComposeEnv()[%d] = %q, want %q", i, got[i], tt.compose[i]) + } + } + }) + } +} + +// TestLocalBackendComposeEnvEmpty pins the default-path invariant: the local +// backend adds NOTHING to the compose environment, so local behavior is +// byte-for-byte unchanged. +func TestLocalBackendComposeEnvEmpty(t *testing.T) { + if env := LocalBackend().ComposeEnv(); env != nil { + t.Fatalf("local ComposeEnv() = %v, want nil", env) + } + if LocalBackend().IsRemote() { + t.Fatal("LocalBackend().IsRemote() = true, want false") + } +} + +func TestBackendString(t *testing.T) { + tests := []struct { + b Backend + want string + }{ + {Backend{}, "local"}, + {Backend{Context: "prod"}, "remote context prod"}, + {Backend{Host: "ssh://dev@box"}, "remote host ssh://dev@box"}, + } + for _, tt := range tests { + if got := tt.b.String(); got != tt.want { + t.Errorf("Backend%+v.String() = %q, want %q", tt.b, got, tt.want) + } + } +} + +// TestComposeContextEnvMerge verifies the ContextEnv threads through to the +// runner AFTER the secret Env, so a remote endpoint pins compose without +// clobbering resolved-secret values — using a fake runner (no real docker). +func TestComposeContextEnvMerge(t *testing.T) { + fr := &fakeEnvRunner{} + cp := Compose{ + Project: "devstack-app", + File: "compose.yaml", + Dir: "/tmp/x", + Env: []string{"SECRET=shh"}, + ContextEnv: Backend{Context: "prod"}.ComposeEnv(), + Runner: fr, + } + if err := cp.Up(context.Background()); err != nil { + t.Fatalf("Up: %v", err) + } + want := []string{"SECRET=shh", "DOCKER_CONTEXT=prod"} + if len(fr.env) != len(want) { + t.Fatalf("runner env = %v, want %v", fr.env, want) + } + for i := range want { + if fr.env[i] != want[i] { + t.Errorf("env[%d] = %q, want %q", i, fr.env[i], want[i]) + } + } + // The docker CLI args are unchanged by the backend selection (endpoint is env, + // not an arg) — the up verb is still `compose -p ... -f ... up -d`. + if len(fr.args) == 0 || fr.args[0] != "compose" { + t.Errorf("args = %v, want to start with compose", fr.args) + } +} + +// TestComposeLocalEnvUnchanged pins that a local Compose (no ContextEnv) passes +// exactly its secret Env — the default path is untouched. +func TestComposeLocalEnvUnchanged(t *testing.T) { + fr := &fakeEnvRunner{} + cp := Compose{Project: "p", File: "f", Env: []string{"SECRET=shh"}, Runner: fr} + if err := cp.Down(context.Background(), false); err != nil { + t.Fatalf("Down: %v", err) + } + if len(fr.env) != 1 || fr.env[0] != "SECRET=shh" { + t.Fatalf("runner env = %v, want [SECRET=shh]", fr.env) + } +} + +func TestRemoteReachable(t *testing.T) { + ctx := context.Background() + + // Local backend → empty check (nothing remote to probe). + if c := LocalBackend().RemoteReachable(ctx, &MockClient{}); c.Name != "" { + t.Errorf("local RemoteReachable Name = %q, want empty", c.Name) + } + + b := Backend{Context: "prod"} + + // Nil client → fail (construction already failed upstream). + if c := b.RemoteReachable(ctx, nil); c.Status != StatusFail || c.ID != "backend.remote" { + t.Errorf("nil-client check = %+v, want fail/backend.remote", c) + } + + // Ping error → fail with the error detail and a remediation. + pingErr := &MockClient{PingErr: errors.New("dial tcp: connection refused")} + if c := b.RemoteReachable(ctx, pingErr); c.Status != StatusFail || c.Remediation == "" { + t.Errorf("ping-error check = %+v, want fail with remediation", c) + } + + // Reachable → ok, reporting the engine version. + ok := &MockClient{Server: "27.1.1"} + c := b.RemoteReachable(ctx, ok) + if c.Status != StatusOK { + t.Fatalf("reachable check = %+v, want ok", c) + } + if c.Detail != "Engine v27.1.1" { + t.Errorf("detail = %q, want Engine v27.1.1", c.Detail) + } +} + +// fakeEnvRunner records the env + args it was called with (no real exec). +type fakeEnvRunner struct { + env []string + args []string +} + +func (f *fakeEnvRunner) Run(_ context.Context, env []string, _ /*dir*/, _ /*name*/ string, args ...string) error { + f.env = env + f.args = args + return nil +} + +func (f *fakeEnvRunner) Output(_ context.Context, env []string, _, _ string, args ...string) ([]byte, error) { + f.env = env + f.args = args + return nil, nil +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go index c9fd78e..0c3dacb 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -101,7 +101,25 @@ type Compose struct { Overrides []string // additional -f overlays, applied in order after File (up-time only) Dir string // working dir (build contexts resolve relative to it) Env []string // extra env (resolved secrets), appended to os.Environ - Runner Runner + // ContextEnv pins the compose CLI to a specific Docker backend/endpoint + // (DOCKER_HOST / DOCKER_CONTEXT), from Backend.ComposeEnv() (spec 21). Empty for + // the local backend. Kept separate from Env so a remote endpoint and resolved + // secrets compose cleanly; both are appended to the child env, never written to + // a file. ContextEnv is applied AFTER Env so the endpoint selection always wins. + ContextEnv []string + Runner Runner +} + +// env returns the extra environment the Runner appends to os.Environ for every +// verb: the resolved-secret Env followed by the backend-selecting ContextEnv. +func (c *Compose) env() []string { + if len(c.ContextEnv) == 0 { + return c.Env + } + out := make([]string, 0, len(c.Env)+len(c.ContextEnv)) + out = append(out, c.Env...) + out = append(out, c.ContextEnv...) + return out } // NewCompose builds a Compose driver using the real exec runner. @@ -131,7 +149,7 @@ func (c *Compose) base() []string { func (c *Compose) Up(ctx context.Context, services ...string) error { args := append(c.base(), "up", "-d") args = append(args, services...) - return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) + return c.Runner.Run(ctx, c.env(), c.Dir, "docker", args...) } // Down stops and removes the stack's containers (and its default network). @@ -142,7 +160,7 @@ func (c *Compose) Down(ctx context.Context, volumes bool) error { if volumes { args = append(args, "--volumes") } - return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) + return c.Runner.Run(ctx, c.env(), c.Dir, "docker", args...) } // Stop pauses the stack (or named services) without removing containers — used @@ -150,7 +168,7 @@ func (c *Compose) Down(ctx context.Context, volumes bool) error { func (c *Compose) Stop(ctx context.Context, services ...string) error { args := append(c.base(), "stop") args = append(args, services...) - return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) + return c.Runner.Run(ctx, c.env(), c.Dir, "docker", args...) } // Exec runs `docker compose exec` for a service, letting compose resolve the @@ -170,7 +188,7 @@ func (c *Compose) Exec(ctx context.Context, service string, interactive bool, cm } args = append(args, service) args = append(args, cmd...) - return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) + return c.Runner.Run(ctx, c.env(), c.Dir, "docker", args...) } // Build rebuilds the named services (with --no-cache for the selective-rebuild @@ -181,5 +199,5 @@ func (c *Compose) Build(ctx context.Context, noCache bool, services ...string) e args = append(args, "--no-cache") } args = append(args, services...) - return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) + return c.Runner.Run(ctx, c.env(), c.Dir, "docker", args...) } diff --git a/internal/orchestrate/backend_up_test.go b/internal/orchestrate/backend_up_test.go new file mode 100644 index 0000000..9381ee7 --- /dev/null +++ b/internal/orchestrate/backend_up_test.go @@ -0,0 +1,125 @@ +package orchestrate + +import ( + "context" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" +) + +// TestBuildUpRemoteThreadsContextEnv: with a remote backend and --no-provision +// (so the host-provisioning guard doesn't fire), every compose verb — the shared +// stack up AND the project up — is pinned to the remote endpoint via DOCKER_CONTEXT, +// while the local default path adds nothing. Uses the mock client + fake runner +// (no real remote). +func TestBuildUpRemoteThreadsContextEnv(t *testing.T) { + d, fr, db := upFixture(t) + d.Backend = docker.Backend{Context: "prod-cluster"} + d.NoProvision = true // host-side provisioning over remote is the flagged follow-up + + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + recs, err := saga.Run(context.Background(), phases) + if err != nil { + t.Fatalf("saga: %v\n%+v", err, recs) + } + if AnyFailed(recs) { + t.Fatalf("a phase failed: %+v", recs) + } + + // Both the shared stack up and the project (devstack-app) up ran, and each was + // pinned to the remote endpoint via DOCKER_CONTEXT. + if env := fr.envForUp("devstack-app"); env == nil { + t.Fatal("expected the project stack to be brought up") + } else if !contains(env, "DOCKER_CONTEXT=prod-cluster") { + t.Errorf("project up env = %v, want DOCKER_CONTEXT=prod-cluster", env) + } + if env := fr.envForUp(generate.SharedStackName); env == nil { + t.Fatal("expected the shared stack to be brought up") + } else if !contains(env, "DOCKER_CONTEXT=prod-cluster") { + t.Errorf("shared up env = %v, want DOCKER_CONTEXT=prod-cluster", env) + } + // No DOCKER_HOST leaked (context path only). + for _, env := range fr.envs { + for _, e := range env { + if strings.HasPrefix(e, "DOCKER_HOST=") { + t.Errorf("unexpected DOCKER_HOST entry %q for a context-selected backend", e) + } + } + } +} + +// TestBuildUpLocalAddsNoBackendEnv pins the default-path invariant: a local +// backend threads NO DOCKER_CONTEXT/DOCKER_HOST into compose — byte-for-byte +// unchanged from before spec 21. +func TestBuildUpLocalAddsNoBackendEnv(t *testing.T) { + d, fr, db := upFixture(t) + // d.Backend is the zero value (local) — set explicitly for clarity. + d.Backend = docker.LocalBackend() + + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + if _, err := saga.Run(context.Background(), phases); err != nil { + t.Fatalf("saga: %v", err) + } + for _, env := range fr.envs { + for _, e := range env { + if strings.HasPrefix(e, "DOCKER_CONTEXT=") || strings.HasPrefix(e, "DOCKER_HOST=") { + t.Errorf("local backend leaked backend env %q", e) + } + } + } +} + +// TestBuildUpRemoteProvisionGuard: a remote backend with host-side provisioning +// required (a project uses shared Postgres, provisioning NOT skipped) fails the +// shared phase with a clear, actionable error rather than silently publishing a +// loopback port the laptop cannot reach (spec 21 §Network reachability). +func TestBuildUpRemoteProvisionGuard(t *testing.T) { + d, _, db := upFixture(t) + d.Backend = docker.Backend{Host: "ssh://dev@build-host"} + // NoProvision stays false → the app's shared Postgres is a provision target. + + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + recs, err := saga.Run(context.Background(), phases) + if err == nil { + t.Fatal("expected the saga to fail on the remote-provision guard") + } + var sharedRec *Record + for i := range recs { + if recs[i].Phase == "shared" { + sharedRec = &recs[i] + } + } + if sharedRec == nil || sharedRec.Status != StatusFailed { + t.Fatalf("shared phase = %+v, want failed", sharedRec) + } + if sharedRec.Error == nil { + t.Fatal("failed shared phase carried no error message") + } + msg := *sharedRec.Error + if !strings.Contains(msg, "not supported yet") || !strings.Contains(msg, "--no-provision") { + t.Errorf("guard error = %q, want it to flag the follow-up and suggest --no-provision", msg) + } +} + +func contains(s []string, want string) bool { + for _, x := range s { + if x == want { + return true + } + } + return false +} diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 68b34f1..4b66b0f 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -46,7 +46,14 @@ type UpDeps struct { Source template.TemplateSource LockPath string - Runner docker.Runner // compose CLI runner (nil → docker.ExecRunner) + Runner docker.Runner // compose CLI runner (nil → docker.ExecRunner) + // Backend selects WHERE the stack runs (spec 21): the local daemon (zero value, + // default) or a remote `docker context`/DOCKER_HOST endpoint. Its ComposeEnv() + // pins every compose invocation to that endpoint; its Reachability() gates the + // host-port publish (a remote bridge is not host-routable). The read-only + // d.Docker client and the state ledger's context key are already bound to this + // backend by the caller (buildUpDeps). + Backend docker.Backend Env map[string]string // generate env (nil → process env, via generate default) Profile string // env-overlay profile for ${profile} (generate); "" → workspace default Profiles []string // spec-12 SERVICE SLICES (--profile, repeatable); empty → defaultProfile/all @@ -401,6 +408,21 @@ func sharedPhase(d UpDeps, projects, names, provInstances []string) Phase { Project: generate.SharedStackName, File: filepath.Join(outDir, generate.ComposeFile), Dir: outDir, Runner: d.Runner, + ContextEnv: d.Backend.ComposeEnv(), + } + // Remote backend: a remote bridge network is NOT host-routable, so we do + // NOT publish a loopback provision port — host-side pgx cannot reach it + // (spec 21 §Network reachability). Provisioning the shared Postgres over a + // remote backend needs an SSH local-forward/tunnel, which is the flagged + // follow-up; fail clearly rather than silently publish a port the laptop + // can't dial. `up` still works for the non-provisioned remote shared stack + // (redis/minio, or postgres with no host-provisioned consumer) and with + // --no-provision. + if len(prov) > 0 && d.Backend.Reachability() == docker.ViaProxy { + return nil, fmt.Errorf("provisioning the shared Postgres over a %s is not supported yet: "+ + "a remote shared network is not host-routable, so host-side provisioning needs an SSH tunnel "+ + "(spec 21 follow-up). Re-run with --no-provision, or provision from the remote host", + d.Backend.String()) } // Publish each provisioned Postgres on 127.0.0.1: via an // up-time overlay so host-side pgx (the provision phase) can reach it, @@ -491,6 +513,8 @@ func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string, ser // compose-up process env (Compose substitutes the valueless keys); they // are never written to a file (§7.5). Env: secretEnv[project], + // Pin compose to the selected backend endpoint (spec 21); empty for local. + ContextEnv: d.Backend.ComposeEnv(), } } return Phase{ diff --git a/internal/store/store.go b/internal/store/store.go index fd2d2f7..0854170 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -88,6 +88,10 @@ type Config struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Shared map[string]config.SharedSvc `yaml:"shared"` + // Backend is the machine-global default for WHERE the shared stack runs (spec + // 21): a `docker context` or DOCKER_HOST endpoint. nil = the local daemon. A + // workspace.yaml `backend:` block, when present, overrides this per workspace. + Backend *config.BackendConfig `yaml:"backend,omitempty"` // Telemetry is the per-user/per-machine opt-in usage-telemetry consent // (spec 20). It lives here — never in workspace.yaml (must not be committed) // and never in state.db (it's user policy, not ledger state). Default OFF: a From ddaa46562c452402bfb00d85ac9f7764760b73f5 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 13:22:56 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(template):=20versioned=20OCI=20templat?= =?UTF-8?q?e=20registry=20=E2=80=94=20push/add/update/diff/verify=20(spec?= =?UTF-8?q?=2019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/registry: a pure-Go (oras-go/v2, CGO-free) OCI template-registry seam behind a TargetResolver interface (real remote.Repository w/ docker creds; tests round-trip through an in-memory oras store, no network). Deterministic bundle tar packaging (content-addressed digest), mandatory digest verification on pull, and a cosign Verifier/Signer seam (shells cosign, matching internal/selfupdate's CGO-free precedent; mock-able for tests). Graduate the template.go stubs to real commands: push, add (tag→digest pin + verify + digest-keyed cache + lockfile entry, lock-guarded), update (re-resolve w/ --dry-run), diff (render-diff pinned vs remote), verify (re-pull + signature), and ls. Store config gains a digest-pinned `templates:` lockfile; remote templates are chained into the generate/lint/test source (embedded < store < remote), digest-pinned and offline-first so generation stays byte-deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 5 +- go.sum | 2 + internal/cli/generate.go | 20 +- internal/cli/template.go | 14 +- internal/cli/template_registry.go | 561 ++++++++++++++++++++++ internal/cli/template_registry_helpers.go | 146 ++++++ internal/cli/template_registry_test.go | 240 +++++++++ internal/registry/bundle.go | 206 ++++++++ internal/registry/pull.go | 92 ++++ internal/registry/reference.go | 174 +++++++ internal/registry/reference_test.go | 70 +++ internal/registry/registry.go | 142 ++++++ internal/registry/registry_test.go | 150 ++++++ internal/registry/target.go | 128 +++++ internal/registry/verify.go | 160 ++++++ internal/registry/verify_test.go | 80 +++ internal/store/store.go | 3 + internal/store/templates.go | 89 ++++ 18 files changed, 2269 insertions(+), 13 deletions(-) create mode 100644 internal/cli/template_registry.go create mode 100644 internal/cli/template_registry_helpers.go create mode 100644 internal/cli/template_registry_test.go create mode 100644 internal/registry/bundle.go create mode 100644 internal/registry/pull.go create mode 100644 internal/registry/reference.go create mode 100644 internal/registry/reference_test.go create mode 100644 internal/registry/registry.go create mode 100644 internal/registry/registry_test.go create mode 100644 internal/registry/target.go create mode 100644 internal/registry/verify.go create mode 100644 internal/registry/verify_test.go create mode 100644 internal/store/templates.go diff --git a/go.mod b/go.mod index 695e5f1..0ad14ef 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,8 @@ require ( github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 github.com/nats-io/nats.go v1.52.0 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.10.2 github.com/twmb/franz-go v1.21.4 github.com/twmb/franz-go/pkg/kadm v1.18.0 @@ -35,6 +37,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/term v0.44.0 modernc.org/sqlite v1.52.0 + oras.land/oras-go/v2 v2.6.1 ) require ( @@ -95,8 +98,6 @@ require ( github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index e0f5360..5958e65 100644 --- a/go.sum +++ b/go.sum @@ -298,5 +298,7 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs= +oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 926ccc1..1a3c74c 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -13,14 +13,26 @@ import ( ) // builtinSource is the template source used by generation and the template -// tooling: custom templates in the store (~/.devstack/templates) override the -// embedded built-ins by name; the embedded set is always the fallback. +// tooling. Resolution priority is embedded < store < remote (first match wins in +// the chain, so the highest-priority source is listed first): a digest-pinned +// REMOTE template (spec 19) overrides a store template, which overrides an +// embedded built-in of the same name. A cold/missing remote cache contributes +// nothing, keeping generation offline-first and deterministic (with no remote +// templates registered the chain is byte-identical to the pre-spec-19 behavior). func builtinSource() template.TemplateSource { embedded := template.NewFSSource(templates.FS) + var chain []template.TemplateSource + if remote := remoteTemplateSource(); remote != nil { + chain = append(chain, remote) + } if dir := userTemplatesDir(); dir != "" { - return template.NewChainSource(template.NewFSSource(os.DirFS(dir)), embedded) + chain = append(chain, template.NewFSSource(os.DirFS(dir))) + } + if len(chain) == 0 { + return embedded } - return embedded + chain = append(chain, embedded) + return template.NewChainSource(chain...) } // newGenerateCmd wires `devstack generate` — the M1 deterministic pipeline entry diff --git a/internal/cli/template.go b/internal/cli/template.go index 64656cf..2467182 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -27,13 +27,13 @@ func newTemplateCmd(g *GlobalOpts) *cobra.Command { newTemplateTestCmd(g), newTemplateInitCmd(g), newTemplateNewCmd(g), - // Reserved remote-registry verbs (spec 19, v2) — tree-only stubs so - // help/completions stay consistent (spec 26 / spec 07). - stub("push", "Publish a template to a remote registry", "v2 (spec 19)"), - stub("add", "Add a remote template source", "v2 (spec 19)"), - stub("update", "Update cached remote templates", "v2 (spec 19)"), - stub("diff", "Diff a local template against its remote", "v2 (spec 19)"), - stub("verify", "Verify a remote template's signature", "v2 (spec 19)"), + // Versioned OCI template registry (spec 19). + newTemplatePushCmd(g), + newTemplateAddCmd(g), + newTemplateUpdateCmd(g), + newTemplateDiffCmd(g), + newTemplateVerifyCmd(g), + newTemplateLsCmd(g), ) return cmd } diff --git a/internal/cli/template_registry.go b/internal/cli/template_registry.go new file mode 100644 index 0000000..52914f2 --- /dev/null +++ b/internal/cli/template_registry.go @@ -0,0 +1,561 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/registry" + "github.com/open-source-cloud/devstack/internal/store" + "github.com/open-source-cloud/devstack/internal/template" +) + +// maxTemplateSchemaVersion is the newest template-bundle schemaVersion this binary +// understands. A pinned bundle declaring a NEWER schema is refused on `add` with an +// "upgrade devstack" message rather than a silent partial render (spec 19 AC). +const maxTemplateSchemaVersion = 1 + +// newRegistryClient is the registry-client seam. Production builds an oras client +// that talks to real registries with docker credentials; tests swap in a client +// backed by an in-memory store so the whole add/update/diff flow round-trips with +// no network. +var newRegistryClient = func() (*registry.Client, error) { return registry.New() } + +// remoteTemplateSource returns a TemplateSource over the digest-pinned cache for +// every registered remote template whose content is present, or nil when none are +// registered/cached. It is chained AHEAD of the store + embedded sources so a +// registered remote name wins (embedded < store < remote), while a cold or missing +// cache entry simply contributes nothing (generation stays offline-first). +func remoteTemplateSource() template.TemplateSource { + cfg, ok, err := store.Load() + if err != nil || !ok || len(cfg.Templates) == 0 { + return nil + } + var sources []template.TemplateSource + for _, rt := range cfg.Templates { + dir, err := store.TemplateCacheDir(rt.Digest) + if err != nil { + continue + } + if fi, err := os.Stat(filepath.Join(dir, rt.Name, template.TemplateFile)); err != nil || fi.IsDir() { + continue // cold cache — `template update` will populate it + } + sources = append(sources, template.NewFSSource(os.DirFS(dir))) + } + if len(sources) == 0 { + return nil + } + return template.NewChainSource(sources...) +} + +// newTemplatePushCmd graduates `template push ` (spec 19). +func newTemplatePushCmd(g *GlobalOpts) *cobra.Command { + var ( + sign bool + keyPath string + ) + cmd := &cobra.Command{ + Use: "push ", + Short: "Package a template directory and push it as an OCI artifact", + Long: "push packages a template bundle (template.yaml + optional build/ tree + golden.yaml)\n" + + "as a DETERMINISTIC OCI artifact, pushes it to (e.g. ghcr.io/OWNER/NAME:TAG), and\n" + + "prints the resolved sha256 manifest digest. Auth rides ~/.docker/config.json (or a\n" + + "GITHUB_TOKEN for ghcr.io); with --sign the artifact is cosign-signed.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + dir, refStr := args[0], args[1] + ref, err := registry.ParseReference(refStr) + if err != nil { + return err + } + if ref.Tag == "" { + return fmt.Errorf("push requires a tag, e.g. %s:1.0.0", ref.Name()) + } + client, err := newRegistryClient() + if err != nil { + return err + } + ctx := context.Background() + desc, err := client.Push(ctx, ref, dir) + if err != nil { + return err + } + if sign { + if err := registry.SignArtifact(ctx, nil, desc, keyPath); err != nil { + return err + } + } + if g.JSON { + return writeJSON(cmd, desc) + } + if !g.Quiet { + w := cmd.OutOrStdout() + fmt.Fprintf(w, "pushed %s\n", desc.Ref) + fmt.Fprintf(w, " digest: %s\n", desc.Digest) + if sign { + fmt.Fprintln(w, " signed: cosign") + } + } + return nil + }, + } + cmd.Flags().BoolVar(&sign, "sign", false, "cosign-sign the pushed artifact (requires the cosign binary)") + cmd.Flags().StringVar(&keyPath, "key", "", "cosign private key for keyed signing (default: keyless)") + return cmd +} + +// newTemplateAddCmd graduates `template add ` (spec 19). +func newTemplateAddCmd(g *GlobalOpts) *cobra.Command { + var ( + allowFloating bool + policy registry.VerifyPolicy + ) + cmd := &cobra.Command{ + Use: "add ", + Short: "Register a remote template, pinning its resolved digest", + Long: "add resolves 's tag to a manifest digest, verifies it (digest always;\n" + + "cosign signature when --identity/--issuer or --key is given), caches the bundle\n" + + "under the digest-keyed template cache, and records a pinned `templates:` entry in\n" + + "the store config so the template resolves by name in generation and lint/test.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := registry.ParseReference(args[0]) + if err != nil { + return err + } + if ref.IsFloatingTag() && !allowFloating { + return fmt.Errorf("refusing to pin a floating tag %q (breaks determinism); pass --allow-floating to override", ref.Tag) + } + return addOrUpdate(cmd, g, ref, policy) + }, + } + cmd.Flags().BoolVar(&allowFloating, "allow-floating", false, "allow pinning a floating tag like :latest") + registerVerifyFlags(cmd, &policy) + return cmd +} + +// newTemplateUpdateCmd graduates `template update [name]` (spec 19). +func newTemplateUpdateCmd(g *GlobalOpts) *cobra.Command { + var ( + toTag string + dryRun bool + ) + cmd := &cobra.Command{ + Use: "update [name]", + Short: "Re-resolve pinned templates to a new digest (opt --to )", + Long: "update re-resolves each registered template's tag (or --to ) to a fresh\n" + + "digest, refreshes the digest-keyed cache, and rewrites the lockfile. With\n" + + "--dry-run nothing is written — it only reports which pins would move.", + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ok, err := store.Load() + if err != nil { + return err + } + if !ok || len(cfg.Templates) == 0 { + return fmt.Errorf("no remote templates registered — add one with `%s template add `", rootName(cmd)) + } + var targets []store.RemoteTemplate + if len(args) == 1 { + rt, found := cfg.Template(args[0]) + if !found { + return fmt.Errorf("template %q is not registered", args[0]) + } + targets = []store.RemoteTemplate{rt} + } else { + targets = cfg.Templates + } + + type change struct { + Name string `json:"name"` + OldDigest string `json:"oldDigest"` + NewDigest string `json:"newDigest"` + Version string `json:"version"` + Changed bool `json:"changed"` + } + var changes []change + for _, rt := range targets { + ref, err := registry.ParseRepository(rt.Source) + if err != nil { + return fmt.Errorf("template %q: bad source %q: %w", rt.Name, rt.Source, err) + } + ref.Tag = rt.Version + if toTag != "" { + ref.Tag = toTag + } + ref.Digest = "" // re-resolve from the tag + ch := change{Name: rt.Name, OldDigest: rt.Digest, Version: ref.Tag} + if dryRun { + client, err := newRegistryClient() + if err != nil { + return err + } + desc, err := client.ResolveDigest(context.Background(), ref) + if err != nil { + return err + } + ch.NewDigest = desc.Digest + ch.Changed = desc.Digest != rt.Digest + changes = append(changes, ch) + continue + } + // A real update goes through the same pin+cache+lockfile path as add. + desc, err := doPin(context.Background(), ref, registry.VerifyPolicy{}) + if err != nil { + return err + } + ch.NewDigest = desc.Digest + ch.Changed = desc.Digest != rt.Digest + changes = append(changes, ch) + } + + if g.JSON { + return writeJSON(cmd, map[string]any{"dryRun": dryRun, "templates": changes}) + } + if !g.Quiet { + w := cmd.OutOrStdout() + for _, c := range changes { + switch { + case !c.Changed: + fmt.Fprintf(w, "%-16s up to date (%s)\n", c.Name, shortManifestDigest(c.NewDigest)) + case dryRun: + fmt.Fprintf(w, "%-16s %s → %s (would update)\n", c.Name, shortManifestDigest(c.OldDigest), shortManifestDigest(c.NewDigest)) + default: + fmt.Fprintf(w, "%-16s %s → %s (updated)\n", c.Name, shortManifestDigest(c.OldDigest), shortManifestDigest(c.NewDigest)) + } + } + } + return nil + }, + } + cmd.Flags().StringVar(&toTag, "to", "", "re-resolve to this tag instead of the recorded version") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report pin changes without writing") + return cmd +} + +// newTemplateDiffCmd graduates `template diff ` (spec 19). +func newTemplateDiffCmd(g *GlobalOpts) *cobra.Command { + var against string + cmd := &cobra.Command{ + Use: "diff ", + Short: "Render-diff a pinned template against its latest remote tag", + Long: "diff renders the pinned (cached) template and the current remote tag (or --against\n" + + ") and reports whether the resolved digests — and the rendered compose — differ.\n" + + "It writes nothing.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + cfg, ok, err := store.Load() + if err != nil { + return err + } + var rt store.RemoteTemplate + if ok { + rt, ok = cfg.Template(name) + } + if !ok { + return fmt.Errorf("template %q is not registered", name) + } + ref, err := registry.ParseRepository(rt.Source) + if err != nil { + return err + } + ref.Tag = rt.Version + if against != "" { + ref.Tag = against + } + ref.Digest = "" + + client, err := newRegistryClient() + if err != nil { + return err + } + latest, err := client.ResolveDigest(context.Background(), ref) + if err != nil { + return err + } + digestDrift := latest.Digest != rt.Digest + + // Render the pinned (cached) template and, on drift, the remote one, and + // compare the resolved compose so the diff is meaningful, not just a hash. + oldRender, oldErr := renderPinned(name, rt.Digest) + var newRender []byte + var renderDrift bool + if digestDrift { + pulled, perr := client.Pull(context.Background(), refWithTagDigest(ref, latest.Digest)) + if perr != nil { + return perr + } + newRender, err = renderPulled(name, pulled) + if err != nil { + return err + } + renderDrift = string(oldRender) != string(newRender) + } + + if g.JSON { + return writeJSON(cmd, map[string]any{ + "name": name, "pinnedDigest": rt.Digest, "remoteDigest": latest.Digest, + "remoteTag": ref.Tag, "digestDrift": digestDrift, "renderDrift": renderDrift, + }) + } + w := cmd.OutOrStdout() + if oldErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: pinned template not cached (run `%s template update`): %v\n", rootName(cmd), oldErr) + } + if !digestDrift { + fmt.Fprintf(w, "%s: up to date at %s (%s)\n", name, ref.Tag, shortManifestDigest(rt.Digest)) + return nil + } + fmt.Fprintf(w, "%s: DRIFT %s → %s (tag %s)\n", name, shortManifestDigest(rt.Digest), shortManifestDigest(latest.Digest), ref.Tag) + if renderDrift { + fmt.Fprintf(w, " rendered compose differs — review before `%s template update %s`\n", rootName(cmd), name) + } else { + fmt.Fprintln(w, " digest moved but the rendered compose is identical") + } + return nil + }, + } + cmd.Flags().StringVar(&against, "against", "", "compare against this remote tag (default: the recorded version)") + return cmd +} + +// newTemplateVerifyCmd graduates `template verify [name]` (spec 19). +func newTemplateVerifyCmd(g *GlobalOpts) *cobra.Command { + var policy registry.VerifyPolicy + cmd := &cobra.Command{ + Use: "verify [name]", + Short: "Re-pull pinned templates and re-check digest (and signature)", + Long: "verify re-pulls each registered template at its pinned digest and asserts no\n" + + "drift; with --identity/--issuer (keyless) or --key (keyed) it also re-checks the\n" + + "cosign signature. A mismatch or bad signature is a hard error.", + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ok, err := store.Load() + if err != nil { + return err + } + if !ok || len(cfg.Templates) == 0 { + return fmt.Errorf("no remote templates registered") + } + var targets []store.RemoteTemplate + if len(args) == 1 { + rt, found := cfg.Template(args[0]) + if !found { + return fmt.Errorf("template %q is not registered", args[0]) + } + targets = []store.RemoteTemplate{rt} + } else { + targets = cfg.Templates + } + client, err := newRegistryClient() + if err != nil { + return err + } + ctx := context.Background() + type result struct { + Name string `json:"name"` + Digest string `json:"digest"` + Verified bool `json:"verified"` + Signed bool `json:"signed"` + } + var results []result + for _, rt := range targets { + ref, err := registry.ParseRepository(rt.Source) + if err != nil { + return err + } + ref.Tag = rt.Version + ref.Digest = rt.Digest + pulled, err := client.Pull(ctx, ref) // enforces digest == pin + if err != nil { + return fmt.Errorf("template %q: %w", rt.Name, err) + } + if err := registry.VerifySignature(ctx, nil, pulled.Descriptor, policy); err != nil { + return fmt.Errorf("template %q: %w", rt.Name, err) + } + results = append(results, result{Name: rt.Name, Digest: rt.Digest, Verified: true, Signed: !policy.IsZero()}) + } + if g.JSON { + return writeJSON(cmd, map[string]any{"ok": true, "templates": results}) + } + if !g.Quiet { + w := cmd.OutOrStdout() + for _, r := range results { + sig := "" + if r.Signed { + sig = " + signature" + } + fmt.Fprintf(w, "%-16s verified %s%s\n", r.Name, shortManifestDigest(r.Digest), sig) + } + } + return nil + }, + } + registerVerifyFlags(cmd, &policy) + return cmd +} + +// newTemplateLsCmd lists registered remote templates + digests + cache state. +func newTemplateLsCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "ls", + Short: "List pinned remote templates and their cache state", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, ok, _ := store.Load() + type row struct { + Name string `json:"name"` + Source string `json:"source"` + Version string `json:"version"` + Digest string `json:"digest"` + Cached bool `json:"cached"` + } + var rows []row + if ok { + for _, rt := range cfg.Templates { + rows = append(rows, row{rt.Name, rt.Source, rt.Version, rt.Digest, templateCached(rt)}) + } + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) + if g.JSON { + return writeJSON(cmd, map[string]any{"templates": rows}) + } + w := cmd.OutOrStdout() + if len(rows) == 0 { + if !g.Quiet { + fmt.Fprintln(w, "no remote templates registered") + } + return nil + } + for _, r := range rows { + state := "cached" + if !r.Cached { + state = "cold" + } + fmt.Fprintf(w, "%-16s %s@%s [%s]\n", r.Name, r.Source, r.Version, state) + fmt.Fprintf(w, " %s (%s)\n", r.Digest, state) + } + return nil + }, + } +} + +// addOrUpdate pins a ref, verifies + caches the bundle, and records the lockfile +// entry — the shared body of `add`. +func addOrUpdate(cmd *cobra.Command, g *GlobalOpts, ref registry.Reference, policy registry.VerifyPolicy) error { + desc, err := doPin(context.Background(), ref, policy) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, desc) + } + if !g.Quiet { + w := cmd.OutOrStdout() + fmt.Fprintf(w, "added %s as %q\n", desc.Ref, desc.Name) + fmt.Fprintf(w, " digest: %s\n", desc.Digest) + if policy.IsZero() { + fmt.Fprintln(w, " note: templates are digest-pinned but UNSIGNED — pass --identity/--issuer or --key to require a cosign signature") + } else { + fmt.Fprintln(w, " signature: verified") + } + } + return nil +} + +// doPin performs the lock-guarded pin: resolve+pull, schema-gate, verify signature, +// atomically populate the digest cache, and upsert the store lockfile entry. It is +// the single writer of both the cache and the lockfile (spec 19 §"add/update/verify +// take the flock around the cache write"). +func doPin(ctx context.Context, ref registry.Reference, policy registry.VerifyPolicy) (registry.Descriptor, error) { + client, err := newRegistryClient() + if err != nil { + return registry.Descriptor{}, err + } + pulled, err := client.Pull(ctx, ref) + if err != nil { + return registry.Descriptor{}, err + } + desc := pulled.Descriptor + + // Signature gate before anything is trusted / unpacked. + if err := registry.VerifySignature(ctx, nil, desc, policy); err != nil { + return registry.Descriptor{}, err + } + + var result registry.Descriptor + lockErr := lock.WithLock(ctx, lockPath(), func() error { + cacheDir, err := store.TemplateCacheDir(desc.Digest) + if err != nil { + return err + } + name, err := materializeBundle(pulled.Tar, cacheDir) + if err != nil { + return err + } + // Schema-version gate: refuse a bundle newer than this binary understands. + sv, err := bundleSchemaVersion(cacheDir, name) + if err != nil { + return err + } + if sv > maxTemplateSchemaVersion { + return fmt.Errorf("template %q declares schemaVersion %d but this devstack understands up to %d — upgrade devstack to use this template", name, sv, maxTemplateSchemaVersion) + } + + cfg, ok, err := store.Load() + if err != nil { + return err + } + if !ok { + c := store.DefaultConfig() + cfg = &c + } + cfg.UpsertTemplate(store.RemoteTemplate{ + Name: name, + Source: registry.Scheme + ref.Registry + "/" + ref.Repository, + Version: ref.Tag, + Digest: desc.Digest, + SchemaVersion: sv, + }) + if err := cfg.Save(); err != nil { + return err + } + result = registry.Descriptor{ + Ref: registry.Scheme + ref.Registry + "/" + ref.Repository + "@" + desc.Digest, + Repository: ref.Name(), + Tag: ref.Tag, + Digest: desc.Digest, + SchemaVersion: sv, + Name: name, + Size: desc.Size, + } + return nil + }) + if lockErr != nil { + return registry.Descriptor{}, lockErr + } + return result, nil +} + +// registerVerifyFlags wires the cosign policy flags onto a command. Enabled is +// derived in a PreRunE: any of the three flags being set turns verification on +// (an explicit signature policy), otherwise the digest pin stands alone. +func registerVerifyFlags(cmd *cobra.Command, p *registry.VerifyPolicy) { + cmd.Flags().StringVar(&p.IdentityRegexp, "identity", "", "require a keyless cosign signature whose identity matches this regexp") + cmd.Flags().StringVar(&p.OIDCIssuer, "issuer", "", "require this keyless cosign OIDC issuer") + cmd.Flags().StringVar(&p.KeyPath, "key", "", "require a keyed cosign signature verifiable with this public key") + orig := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + p.Enabled = p.IdentityRegexp != "" || p.OIDCIssuer != "" || p.KeyPath != "" + if orig != nil { + return orig(c, args) + } + return nil + } +} diff --git a/internal/cli/template_registry_helpers.go b/internal/cli/template_registry_helpers.go new file mode 100644 index 0000000..ebb3684 --- /dev/null +++ b/internal/cli/template_registry_helpers.go @@ -0,0 +1,146 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/goccy/go-yaml" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/registry" + "github.com/open-source-cloud/devstack/internal/store" + "github.com/open-source-cloud/devstack/internal/template" +) + +// materializeBundle unpacks a bundle tar into the digest-keyed cache dir, atomically +// and idempotently. When cacheDir already holds the bundle (a prior pull of the same +// digest), it is reused as-is — the digest is the content address, so identical +// digest ⇒ identical content. Otherwise the tar is unpacked into a sibling temp dir +// and renamed into place, so a kill mid-unpack never leaves a half-populated, +// wrongly-trusted cache entry (spec 19 §"unpack atomically"). Returns the bundle's +// template name (its single top-level directory). +func materializeBundle(tarData []byte, cacheDir string) (string, error) { + // Fast path: already cached — find the single top-level template dir. + if name, ok := cachedBundleName(cacheDir); ok { + return name, nil + } + if err := os.MkdirAll(filepath.Dir(cacheDir), 0o755); err != nil { + return "", err + } + tmp, err := os.MkdirTemp(filepath.Dir(cacheDir), ".tmp-tmpl-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmp) + + name, err := registry.UnpackBundle(tarData, tmp) + if err != nil { + return "", err + } + // Rename the populated temp dir into the final cache dir. If a concurrent pin + // won the race (dir now exists), reuse it. + if err := os.Rename(tmp, cacheDir); err != nil { + if _, statErr := os.Stat(cacheDir); statErr == nil { + return name, nil + } + return "", err + } + return name, nil +} + +// cachedBundleName returns the single template-name subdir of a populated cache +// dir, or ("", false) when the dir is absent/empty. +func cachedBundleName(cacheDir string) (string, bool) { + entries, err := os.ReadDir(cacheDir) + if err != nil { + return "", false + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(cacheDir, e.Name(), template.TemplateFile)); err == nil { + return e.Name(), true + } + } + return "", false +} + +// bundleSchemaVersion reads the cached template's declared schemaVersion. +func bundleSchemaVersion(cacheDir, name string) (int, error) { + raw, err := os.ReadFile(filepath.Join(cacheDir, name, template.TemplateFile)) + if err != nil { + return 0, err + } + var m struct { + SchemaVersion int `yaml:"schemaVersion"` + } + if err := yaml.Unmarshal(raw, &m); err != nil { + return 0, fmt.Errorf("template %q: %s: %w", name, template.TemplateFile, err) + } + return m.SchemaVersion, nil +} + +// renderPinned renders the pinned (cached) template's single-service compose, +// resolving its extends chain through the built-in source (a base may be a +// built-in). Used by `template diff` to compare against the remote render. +func renderPinned(name, digest string) ([]byte, error) { + dir, err := store.TemplateCacheDir(digest) + if err != nil { + return nil, err + } + src := template.NewChainSource(template.NewFSSource(os.DirFS(dir)), builtinSource()) + return renderVia(src, name) +} + +// renderPulled renders a freshly-pulled bundle by unpacking it to a temp dir and +// resolving through the built-in source. Used by `template diff`. +func renderPulled(name string, pulled registry.Pulled) ([]byte, error) { + tmp, err := os.MkdirTemp("", "devstack-diff-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + top, err := registry.UnpackBundle(pulled.Tar, tmp) + if err != nil { + return nil, err + } + src := template.NewChainSource(template.NewFSSource(os.DirFS(tmp)), builtinSource()) + return renderVia(src, top) +} + +// renderVia resolves name through src and renders its single-service compose. +func renderVia(src template.TemplateSource, name string) ([]byte, error) { + res, err := template.Resolve(src, name, nil) + if err != nil { + return nil, err + } + return generate.LintResolved(name, res) +} + +// refWithTagDigest returns a copy of ref pinned to digest (tag preserved). +func refWithTagDigest(ref registry.Reference, digest string) registry.Reference { + ref.Digest = digest + return ref +} + +// templateCached reports whether a registered template's content is present in the +// digest cache. +func templateCached(rt store.RemoteTemplate) bool { + dir, err := store.TemplateCacheDir(rt.Digest) + if err != nil { + return false + } + _, ok := cachedBundleName(dir) + return ok +} + +// shortDigest renders the first 12 hex chars of a sha256:… digest for display. +func shortManifestDigest(d string) string { + const prefix = "sha256:" + if len(d) > len(prefix)+12 { + return d[len(prefix) : len(prefix)+12] + } + return d +} diff --git a/internal/cli/template_registry_test.go b/internal/cli/template_registry_test.go new file mode 100644 index 0000000..3d77daf --- /dev/null +++ b/internal/cli/template_registry_test.go @@ -0,0 +1,240 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "oras.land/oras-go/v2/content/memory" + + "github.com/open-source-cloud/devstack/internal/registry" + "github.com/open-source-cloud/devstack/internal/store" +) + +// registryHarness isolates the store home, digest cache, and lock dir, and points +// the registry-client seam at a single shared in-memory oras store so the whole +// push/add/update/diff/verify flow round-trips with no network. +func registryHarness(t *testing.T) { + t.Helper() + tmp := t.TempDir() + t.Setenv("DEVSTACK_HOME", filepath.Join(tmp, "home")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, "cache")) + t.Setenv("XDG_RUNTIME_DIR", filepath.Join(tmp, "run")) + + mem := memory.New() + resolver := func(context.Context, registry.Reference) (registry.Target, error) { return mem, nil } + orig := newRegistryClient + newRegistryClient = func() (*registry.Client, error) { return registry.NewWithResolver(resolver), nil } + t.Cleanup(func() { newRegistryClient = orig }) +} + +// writeCLIBundle scaffolds a template bundle dir named `name` with the given image +// tag baked into template.yaml, and returns its path. +func writeCLIBundle(t *testing.T, name, image string, schemaVersion int) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + manifest := "schemaVersion: " + itoa(schemaVersion) + "\ndescription: \"t\"\nservice:\n image: " + image + "\n" + if err := os.WriteFile(filepath.Join(dir, "template.yaml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + digits := "" + for i > 0 { + digits = string(rune('0'+i%10)) + digits + i /= 10 + } + return digits +} + +func TestTemplateAddPinsDigest(t *testing.T) { + registryHarness(t) + dir := writeCLIBundle(t, "acmepg", "postgres:16", 1) + + // push then add. + if out, err := runCmd(t, "template", "push", dir, "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatalf("push: %v\n%s", err, out) + } + out, err := runCmd(t, "template", "add", "oci://ghcr.io/acme/acmepg:1.0.0", "--json") + if err != nil { + t.Fatalf("add: %v\n%s", err, out) + } + var desc registry.Descriptor + if err := json.Unmarshal([]byte(out), &desc); err != nil { + t.Fatalf("parse add json: %v\n%s", err, out) + } + if desc.Name != "acmepg" || !strings.HasPrefix(desc.Digest, "sha256:") { + t.Fatalf("unexpected add descriptor: %+v", desc) + } + + // The store config carries a pinned entry: the human version + the digest. + cfg, ok, err := store.Load() + if err != nil || !ok { + t.Fatalf("store load: ok=%v err=%v", ok, err) + } + rt, found := cfg.Template("acmepg") + if !found { + t.Fatal("acmepg not registered in the store config") + } + if rt.Version != "1.0.0" { + t.Errorf("recorded version = %q, want 1.0.0 (provenance)", rt.Version) + } + if rt.Digest != desc.Digest { + t.Errorf("lockfile digest %q != resolved %q — generation must fetch by digest", rt.Digest, desc.Digest) + } + + // The bundle content is cached under the digest key and resolves by name. + cacheDir, _ := store.TemplateCacheDir(rt.Digest) + if _, err := os.Stat(filepath.Join(cacheDir, "acmepg", "template.yaml")); err != nil { + t.Errorf("bundle not cached under digest key: %v", err) + } + if src := remoteTemplateSource(); src == nil || !src.Has("acmepg") { + t.Error("registered remote template does not resolve in the source chain") + } +} + +func TestTemplateAddRejectsNewerSchema(t *testing.T) { + registryHarness(t) + dir := writeCLIBundle(t, "future", "redis:7", maxTemplateSchemaVersion+1) + if out, err := runCmd(t, "template", "push", dir, "oci://ghcr.io/acme/future:1.0.0"); err != nil { + t.Fatalf("push: %v\n%s", err, out) + } + _, err := runCmd(t, "template", "add", "oci://ghcr.io/acme/future:1.0.0") + if err == nil { + t.Fatal("adding a bundle with a newer schemaVersion must fail with an upgrade message") + } + if !strings.Contains(err.Error(), "upgrade devstack") { + t.Errorf("error should tell the user to upgrade, got: %v", err) + } +} + +func TestTemplateAddRejectsFloatingTag(t *testing.T) { + registryHarness(t) + if _, err := runCmd(t, "template", "add", "oci://ghcr.io/acme/x:latest"); err == nil { + t.Fatal("add must refuse a floating :latest tag without --allow-floating") + } +} + +func TestTemplateUpdateAndDiffDetectDrift(t *testing.T) { + registryHarness(t) + + pgV1 := writeCLIBundle(t, "acmepg", "postgres:16", 1) + if _, err := runCmd(t, "template", "push", pgV1, "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatal(err) + } + if _, err := runCmd(t, "template", "add", "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatal(err) + } + cfg, _, _ := store.Load() + rt, _ := cfg.Template("acmepg") + oldDigest := rt.Digest + + // Re-push DIFFERENT content to the SAME tag: the pinned workspace is unaffected + // until update, but diff detects the drift. + pgV2 := writeCLIBundle(t, "acmepg", "postgres:17", 1) + if _, err := runCmd(t, "template", "push", pgV2, "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatal(err) + } + + diffOut, err := runCmd(t, "template", "diff", "acmepg", "--json") + if err != nil { + t.Fatalf("diff: %v\n%s", err, diffOut) + } + var d struct { + DigestDrift bool `json:"digestDrift"` + RenderDrift bool `json:"renderDrift"` + } + if err := json.Unmarshal([]byte(diffOut), &d); err != nil { + t.Fatalf("parse diff json: %v\n%s", err, diffOut) + } + if !d.DigestDrift || !d.RenderDrift { + t.Fatalf("diff should report digest + render drift, got %+v", d) + } + + // dry-run update reports the move but does NOT rewrite the lockfile. + if _, err := runCmd(t, "template", "update", "acmepg", "--dry-run"); err != nil { + t.Fatal(err) + } + cfg, _, _ = store.Load() + rt, _ = cfg.Template("acmepg") + if rt.Digest != oldDigest { + t.Error("--dry-run must not rewrite the pinned digest") + } + + // A real update rewrites the pin to the new digest. + if _, err := runCmd(t, "template", "update", "acmepg"); err != nil { + t.Fatal(err) + } + cfg, _, _ = store.Load() + rt, _ = cfg.Template("acmepg") + if rt.Digest == oldDigest { + t.Error("update must move the pinned digest to the re-pushed content") + } + + // After update, diff is clean again. + if out, _ := runCmd(t, "template", "diff", "acmepg"); !strings.Contains(out, "up to date") { + t.Errorf("diff after update should be up to date, got: %s", out) + } +} + +func TestTemplateVerifySignaturePolicy(t *testing.T) { + registryHarness(t) + dir := writeCLIBundle(t, "acmepg", "postgres:16", 1) + if _, err := runCmd(t, "template", "push", dir, "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatal(err) + } + if _, err := runCmd(t, "template", "add", "oci://ghcr.io/acme/acmepg:1.0.0"); err != nil { + t.Fatal(err) + } + + // Inject a fake verifier accepting the signature: verify with a keyless policy passes. + origV := registry.DefaultVerifier + registry.DefaultVerifier = &acceptVerifier{} + t.Cleanup(func() { registry.DefaultVerifier = origV }) + if out, err := runCmd(t, "template", "verify", "acmepg", "--identity", "^https://github.com/acme/.+$", "--issuer", "https://token.actions.githubusercontent.com"); err != nil { + t.Fatalf("verify with good signature should pass: %v\n%s", err, out) + } + + // A rejecting verifier fails verify. + registry.DefaultVerifier = &rejectVerifier{} + if _, err := runCmd(t, "template", "verify", "acmepg", "--identity", "^https://github.com/acme/.+$", "--issuer", "https://token.actions.githubusercontent.com"); err == nil { + t.Fatal("verify with a bad signature must fail") + } + + // Without a policy, verify only re-checks the digest pin (no cosign needed). + if out, err := runCmd(t, "template", "verify", "acmepg"); err != nil { + t.Fatalf("digest-only verify should pass without cosign: %v\n%s", err, out) + } +} + +// acceptVerifier / rejectVerifier are fake registry.Verifiers for CLI tests. +type acceptVerifier struct{} + +func (acceptVerifier) Available() bool { return true } +func (acceptVerifier) Verify(context.Context, string, registry.VerifyPolicy) error { + return nil +} + +type rejectVerifier struct{} + +func (rejectVerifier) Available() bool { return true } +func (rejectVerifier) Verify(context.Context, string, registry.VerifyPolicy) error { + return errBadSig +} + +var errBadSig = &cliError{"signature mismatch"} + +type cliError struct{ s string } + +func (e *cliError) Error() string { return e.s } diff --git a/internal/registry/bundle.go b/internal/registry/bundle.go new file mode 100644 index 0000000..1db8bb6 --- /dev/null +++ b/internal/registry/bundle.go @@ -0,0 +1,206 @@ +package registry + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/goccy/go-yaml" +) + +// bundleEpoch is the fixed modification time stamped on every packed tar entry so +// the artifact digest never depends on filesystem timestamps. A constant non-zero +// epoch (rather than the tar zero value) keeps the PAX header stable across +// archive/tar versions. +func bundleEpoch() time.Time { return time.Unix(0, 0).UTC() } + +// TemplateFile is the manifest at the root of every template bundle. Duplicated +// from internal/template to avoid an import cycle (template imports nothing from +// registry, but the CLI wires them together). +const TemplateFile = "template.yaml" + +// bundleMode is the fixed file mode stamped on every packed entry so the tar (and +// thus the artifact digest) is independent of the author's umask. Directories get +// the exec bit; regular files are 0644. +const ( + bundleFileMode = 0o644 + bundleDirMode = 0o755 +) + +// maxBundleBytes caps the unpacked bundle size to defuse a decompression/pull +// bomb from an untrusted registry (templates are small — a few KB). +const maxBundleBytes = 32 << 20 // 32 MiB + +// bundleMeta is the subset of template.yaml the registry cares about: the +// bundle-schema version gate (spec 19 §"What travels"). It is parsed from the +// UNrendered manifest (the meta fields never carry template actions). +type bundleMeta struct { + SchemaVersion int `yaml:"schemaVersion"` +} + +// PackBundle reads a template directory and returns a DETERMINISTIC tar of it, +// rooted at "/…" where name is dir's base (so the unpacked cache dir has a +// single top-level template-name directory, exactly like the embedded FSSource +// layout). Entries are emitted in sorted path order with zeroed timestamps and +// fixed ownership/mode so re-packing identical content yields byte-identical bytes +// — the artifact digest is a pure function of content (spec 19 determinism AC). +// +// The directory MUST contain a template.yaml; its schemaVersion is recorded so a +// consumer can reject a bundle newer than it understands. +func PackBundle(dir string) (data []byte, name string, schemaVersion int, err error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, "", 0, err + } + name = filepath.Base(abs) + if !isValidName(name) { + return nil, "", 0, fmt.Errorf("template bundle dir %q is not a valid template name (single path segment, no slash/..)", name) + } + + manifest, err := os.ReadFile(filepath.Join(abs, TemplateFile)) + if err != nil { + return nil, "", 0, fmt.Errorf("template bundle %q: missing %s: %w", name, TemplateFile, err) + } + var meta bundleMeta + if err := yaml.Unmarshal(manifest, &meta); err != nil { + return nil, "", 0, fmt.Errorf("template bundle %q: %s is not valid YAML: %w", name, TemplateFile, err) + } + schemaVersion = meta.SchemaVersion + + // Collect every regular file under dir, keyed by its slash path relative to + // dir, then sort for deterministic emission. + files := map[string][]byte{} + err = filepath.WalkDir(abs, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if d.IsDir() { + return nil + } + rel, rerr := filepath.Rel(abs, p) + if rerr != nil { + return rerr + } + b, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + files[filepath.ToSlash(rel)] = b + return nil + }) + if err != nil { + return nil, "", 0, err + } + + rels := make([]string, 0, len(files)) + for k := range files { + rels = append(rels, k) + } + sort.Strings(rels) + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, rel := range rels { + body := files[rel] + hdr := &tar.Header{ + Name: path.Join(name, rel), + Mode: bundleFileMode, + Size: int64(len(body)), + Typeflag: tar.TypeReg, + // Zeroed metadata for reproducibility. + ModTime: bundleEpoch(), + Format: tar.FormatPAX, + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, "", 0, err + } + if _, err := tw.Write(body); err != nil { + return nil, "", 0, err + } + } + if err := tw.Close(); err != nil { + return nil, "", 0, err + } + return buf.Bytes(), name, schemaVersion, nil +} + +// UnpackBundle extracts a bundle tar into destDir (which must not yet exist), +// creating a "/…" tree. It refuses path traversal and oversize payloads. It +// returns the template name (the single top-level directory in the tar). +// +// The caller is responsible for atomicity (unpack into a temp dir, then rename) so +// a kill mid-pull never leaves a half-populated, wrongly-trusted cache dir +// (spec 19 §"OCI digests are over the canonical manifest"). +func UnpackBundle(data []byte, destDir string) (name string, err error) { + if err := os.MkdirAll(destDir, bundleDirMode); err != nil { + return "", err + } + tr := tar.NewReader(bytes.NewReader(data)) + var total int64 + top := map[string]struct{}{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("read bundle tar: %w", err) + } + if hdr.Typeflag != tar.TypeReg { + continue // bundles carry only regular files + } + clean := path.Clean(hdr.Name) + if clean == "." || strings.HasPrefix(clean, "..") || strings.HasPrefix(clean, "/") || strings.Contains(clean, "/../") { + return "", fmt.Errorf("bundle entry %q escapes the destination", hdr.Name) + } + seg := strings.SplitN(clean, "/", 2) + top[seg[0]] = struct{}{} + + total += hdr.Size + if total > maxBundleBytes { + return "", fmt.Errorf("bundle exceeds %d bytes — refusing to unpack", maxBundleBytes) + } + target := filepath.Join(destDir, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(target), bundleDirMode); err != nil { + return "", err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, bundleFileMode) + if err != nil { + return "", err + } + if _, err := io.CopyN(f, tr, hdr.Size); err != nil { + f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + } + if len(top) != 1 { + return "", fmt.Errorf("bundle must contain exactly one top-level template directory, found %d", len(top)) + } + for n := range top { + name = n + } + if _, err := os.Stat(filepath.Join(destDir, name, TemplateFile)); err != nil { + return "", fmt.Errorf("bundle %q has no %s", name, TemplateFile) + } + return name, nil +} + +// isValidName mirrors template.ValidRef without importing it (a template name is a +// single path segment; dots allowed, no slash/".."). +func isValidName(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + return name == path.Base(name) && !filepath.IsAbs(name) && !strings.ContainsAny(name, "/\\") +} diff --git a/internal/registry/pull.go b/internal/registry/pull.go new file mode 100644 index 0000000..da6b674 --- /dev/null +++ b/internal/registry/pull.go @@ -0,0 +1,92 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// Pulled is the outcome of a Pull: the resolved Descriptor plus the raw bundle tar +// bytes (the single layer). The caller unpacks the tar atomically into the digest +// cache. +type Pulled struct { + Descriptor Descriptor + // Tar is the bundle tar (the artifact's single layer), verified against the + // layer digest by oras during fetch. + Tar []byte +} + +// Pull resolves ref, fetches its manifest + single bundle layer, and returns the +// tar bytes. Digest verification is MANDATORY and always-on: when ref carries a +// pinned digest, the resolved manifest digest MUST equal it or the pull is refused +// (nothing is unpacked). oras additionally verifies every fetched blob against its +// descriptor digest, so a corrupt/tampered layer is rejected before it reaches the +// caller (spec 19 §"Digest verification is mandatory"). +func (c *Client) Pull(ctx context.Context, ref Reference) (Pulled, error) { + target, err := c.newTarget(ctx, ref) + if err != nil { + return Pulled{}, err + } + + manDesc, manBytes, err := oras.FetchBytes(ctx, target, ref.shortRef(), oras.DefaultFetchBytesOptions) + if err != nil { + return Pulled{}, wrapAuth(err, ref, "fetch manifest") + } + // Pin enforcement: a pinned ref must resolve to exactly that manifest digest. + if ref.Digest != "" && manDesc.Digest.String() != ref.Digest { + return Pulled{}, fmt.Errorf("digest mismatch for %s: expected %s, registry served %s — refusing (tampered or re-pushed tag)", ref.Name(), ref.Digest, manDesc.Digest.String()) + } + + var man ocispec.Manifest + if err := json.Unmarshal(manBytes, &man); err != nil { + return Pulled{}, fmt.Errorf("parse manifest for %s: %w", ref.Name(), err) + } + if man.ArtifactType != "" && man.ArtifactType != ArtifactType && man.Config.MediaType != ArtifactType { + return Pulled{}, fmt.Errorf("%s is not a devstack template artifact (artifactType %q)", ref.Name(), man.ArtifactType) + } + layer, err := bundleLayer(man) + if err != nil { + return Pulled{}, fmt.Errorf("%s: %w", ref.Name(), err) + } + + tarData, err := content.FetchAll(ctx, target, layer) + if err != nil { + return Pulled{}, wrapAuth(err, ref, "fetch bundle layer") + } + + desc := Descriptor{ + Ref: refWithDigest(ref, manDesc.Digest.String()).String(), + Repository: ref.Name(), + Tag: ref.Tag, + Digest: manDesc.Digest.String(), + SchemaVersion: schemaVersionFromManifest(man), + Name: man.Annotations[annotationTemplateName], + Size: manDesc.Size, + } + return Pulled{Descriptor: desc, Tar: tarData}, nil +} + +// bundleLayer returns the single template-tar layer of a bundle manifest. +func bundleLayer(man ocispec.Manifest) (ocispec.Descriptor, error) { + for _, l := range man.Layers { + if l.MediaType == LayerMediaType { + return l, nil + } + } + if len(man.Layers) == 1 { + return man.Layers[0], nil + } + return ocispec.Descriptor{}, fmt.Errorf("manifest has no %s layer", LayerMediaType) +} + +// schemaVersionFromManifest reads the bundle schemaVersion annotation (0 if absent +// or malformed — the caller re-reads it from the unpacked template.yaml too). +func schemaVersionFromManifest(man ocispec.Manifest) int { + var v int + _, _ = fmt.Sscanf(man.Annotations[annotationSchemaVersion], "%d", &v) + return v +} diff --git a/internal/registry/reference.go b/internal/registry/reference.go new file mode 100644 index 0000000..f24b8c5 --- /dev/null +++ b/internal/registry/reference.go @@ -0,0 +1,174 @@ +// Package registry is devstack's OCI template-registry seam (spec 19). It packages +// a template bundle (template.yaml + optional build/ tree + golden.yaml) as a +// deterministic OCI artifact, pushes/pulls it addressed by name:tag and pinned by +// the manifest digest, and verifies its signature — all behind a small interface +// so the CLI never touches oras directly and tests round-trip through an in-memory +// store with no network. +// +// The pure-Go client is oras.land/oras-go/v2 (Apache-2.0, CGO-free), preserving +// the single-static-binary constraint. Registry auth rides the existing docker / +// ORAS credential store (~/.docker/config.json, OS helpers, GITHUB_TOKEN) — we do +// NOT invent a devstack token store (spec 19 §"Verified constraints"). +// +// Determinism is the whole point: a render is reproducible across a team only if +// template content is addressed by DIGEST, not a moving tag. Pull verifies the +// pulled manifest's computed digest against the pinned digest before anything is +// unpacked, and the bundle tar is byte-deterministic (see bundle.go). +package registry + +import ( + "fmt" + "strings" + + "github.com/opencontainers/go-digest" +) + +// Scheme is the optional URI scheme accepted on a template ref. +const Scheme = "oci://" + +// Reference is a parsed OCI reference: a registry host, a repository path, and +// EITHER a tag OR a digest (a ref may carry both — `repo:tag@sha256:…` — in which +// case both are populated and the digest wins for fetching). +type Reference struct { + // Registry is the host[:port] (e.g. "ghcr.io", "localhost:5000"). + Registry string + // Repository is the path under the registry (e.g. "acme/templates"). + Repository string + // Tag is the human-facing tag (e.g. "1.4.0"), possibly empty when a bare + // digest ref was given. + Tag string + // Digest is the pinned manifest digest (e.g. "sha256:9c8b…"), possibly empty + // when only a tag was given (it is resolved+pinned at `template add`). + Digest string +} + +// ParseReference parses an `oci://host/repo:tag`, `host/repo:tag`, +// `host/repo@sha256:…`, or `host/repo:tag@sha256:…` reference. The `oci://` +// scheme is optional and stripped. Registry and repository are required. +func ParseReference(ref string) (Reference, error) { + orig := ref + ref = strings.TrimPrefix(ref, Scheme) + if ref == "" { + return Reference{}, fmt.Errorf("empty registry reference") + } + + var r Reference + // Split off an optional @digest first (it always trails). + if at := strings.LastIndex(ref, "@"); at >= 0 { + dig := ref[at+1:] + if err := digest.Digest(dig).Validate(); err != nil { + return Reference{}, fmt.Errorf("invalid digest %q in %q: %w", dig, orig, err) + } + r.Digest = dig + ref = ref[:at] + } + + // The registry host is the first path segment and MUST look like a host + // (contain a "." or ":" or be "localhost") so `repo:tag` is not mistaken for + // `host/repo`. + slash := strings.IndexByte(ref, '/') + if slash < 0 { + return Reference{}, fmt.Errorf("reference %q is missing a registry host (want host/repo:tag)", orig) + } + r.Registry = ref[:slash] + rest := ref[slash+1:] + if !looksLikeHost(r.Registry) { + return Reference{}, fmt.Errorf("reference %q has no registry host — %q is not a hostname (want e.g. ghcr.io/owner/name:tag)", orig, r.Registry) + } + + // A tag, if present, trails the LAST colon in the remaining path (repo paths + // never contain colons; ports were already consumed by the registry host). + if colon := strings.LastIndex(rest, ":"); colon >= 0 { + r.Tag = rest[colon+1:] + rest = rest[:colon] + } + r.Repository = rest + if r.Repository == "" { + return Reference{}, fmt.Errorf("reference %q is missing a repository path", orig) + } + if r.Tag == "" && r.Digest == "" { + return Reference{}, fmt.Errorf("reference %q must carry a tag or a digest", orig) + } + return r, nil +} + +// ParseRepository parses a bare `oci://host/repo` (or `host/repo`) with NO tag or +// digest — the form recorded in the lockfile's `source:` field. `update`/`diff`/ +// `verify` parse it and then re-attach the recorded tag/digest before fetching. +func ParseRepository(s string) (Reference, error) { + s = strings.TrimPrefix(s, Scheme) + slash := strings.IndexByte(s, '/') + if slash < 0 { + return Reference{}, fmt.Errorf("repository %q is missing a registry host (want host/repo)", s) + } + r := Reference{Registry: s[:slash], Repository: s[slash+1:]} + if !looksLikeHost(r.Registry) || r.Repository == "" { + return Reference{}, fmt.Errorf("invalid repository %q (want e.g. ghcr.io/owner/name)", s) + } + return r, nil +} + +// looksLikeHost reports whether s is plausibly a registry host: it contains a dot +// or a port colon, or is exactly "localhost". +func looksLikeHost(s string) bool { + return s == "localhost" || strings.ContainsAny(s, ".:") +} + +// IsFloatingTag reports whether the tag is a mutable "floating" tag (":latest") +// that breaks determinism when fetched at generation. `template add` rejects it +// without --allow-floating (spec 19 §"A moving tag breaks determinism"). +func (r Reference) IsFloatingTag() bool { + return r.Tag == "latest" +} + +// Name is the "registry/repository" identity without any tag or digest — the key +// used to build an oras Target for the repository. +func (r Reference) Name() string { + return r.Registry + "/" + r.Repository +} + +// TagRef renders "registry/repository:tag" (used as the oras copy destination +// reference on push and the resolve reference on pull-by-tag). +func (r Reference) TagRef() string { + return r.Name() + ":" + r.Tag +} + +// DigestRef renders "registry/repository@digest" (the pinned pull reference). +func (r Reference) DigestRef() string { + return r.Name() + "@" + r.Digest +} + +// FetchRef returns the reference oras should resolve: the digest when pinned +// (reproducible), else the tag. Generation always pins a digest; `add`/`diff` +// may resolve a bare tag. +func (r Reference) FetchRef() string { + if r.Digest != "" { + return r.DigestRef() + } + return r.TagRef() +} + +// shortRef is the tag-or-digest oras resolves against a repository Target (the +// registry/repo is implied by the Target). A bare tag resolves uniformly across +// remote registries and the local memory/OCI stores tests use; a bare digest is +// prefixed with "@" so a live registry treats it as a digest. Pull always has a +// tag (a pin is minted FROM a tag), so it resolves by tag then asserts the digest +// matches the pin — never depending on a store resolving a raw digest. +func (r Reference) shortRef() string { + if r.Tag != "" { + return r.Tag + } + return "@" + r.Digest +} + +// String renders the canonical `oci://` form for display and lockfile provenance. +func (r Reference) String() string { + out := Scheme + r.Name() + if r.Tag != "" { + out += ":" + r.Tag + } + if r.Digest != "" { + out += "@" + r.Digest + } + return out +} diff --git a/internal/registry/reference_test.go b/internal/registry/reference_test.go new file mode 100644 index 0000000..1c6d693 --- /dev/null +++ b/internal/registry/reference_test.go @@ -0,0 +1,70 @@ +package registry + +import "testing" + +func TestParseReference(t *testing.T) { + tests := []struct { + in string + wantReg string + wantRepo string + wantTag string + wantDig string + wantErr bool + }{ + {in: "oci://ghcr.io/acme/templates:1.4.0", wantReg: "ghcr.io", wantRepo: "acme/templates", wantTag: "1.4.0"}, + {in: "ghcr.io/acme/templates:1.4.0", wantReg: "ghcr.io", wantRepo: "acme/templates", wantTag: "1.4.0"}, + {in: "localhost:5000/t:1.0", wantReg: "localhost:5000", wantRepo: "t", wantTag: "1.0"}, + { + in: "ghcr.io/acme/postgres@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + wantReg: "ghcr.io", wantRepo: "acme/postgres", + wantDig: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + in: "ghcr.io/acme/postgres:1.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + wantReg: "ghcr.io", wantRepo: "acme/postgres", wantTag: "1.0", + wantDig: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + {in: "", wantErr: true}, + {in: "templates:1.4.0", wantErr: true}, // no registry host + {in: "ghcr.io/acme/templates", wantErr: true}, // no tag or digest + {in: "ghcr.io/acme/x@sha256:short", wantErr: true}, // bad digest + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + r, err := ParseReference(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("want error for %q, got %+v", tt.in, r) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Registry != tt.wantReg || r.Repository != tt.wantRepo || r.Tag != tt.wantTag || r.Digest != tt.wantDig { + t.Errorf("ParseReference(%q) = %+v, want reg=%s repo=%s tag=%s dig=%s", + tt.in, r, tt.wantReg, tt.wantRepo, tt.wantTag, tt.wantDig) + } + }) + } +} + +func TestFetchRefPrefersDigest(t *testing.T) { + r := Reference{Registry: "ghcr.io", Repository: "a/b", Tag: "1.0", Digest: "sha256:deadbeef"} + if got := r.FetchRef(); got != "ghcr.io/a/b@sha256:deadbeef" { + t.Errorf("FetchRef = %q, want the digest ref", got) + } + r.Digest = "" + if got := r.FetchRef(); got != "ghcr.io/a/b:1.0" { + t.Errorf("FetchRef = %q, want the tag ref when unpinned", got) + } +} + +func TestIsFloatingTag(t *testing.T) { + if !(Reference{Tag: "latest"}).IsFloatingTag() { + t.Error(":latest must be flagged as floating") + } + if (Reference{Tag: "1.4.0"}).IsFloatingTag() { + t.Error("a pinned version tag is not floating") + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..91e040c --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,142 @@ +package registry + +import ( + "context" + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" +) + +// Media types for a devstack template artifact. The custom artifactType marks the +// artifact as opaque to non-devstack tooling (so a registry UI won't mis-render it +// as a runnable image), and each layer's mediaType is our own tar type. +const ( + ArtifactType = "application/vnd.devstack.template.v1" + ConfigMediaType = "application/vnd.devstack.template.config.v1+json" + LayerMediaType = "application/vnd.devstack.template.layer.v1.tar" +) + +// createdAnnotation is pinned to a fixed value so PackManifest is reproducible +// (spec 19 determinism AC): the manifest digest is a pure function of content. +const createdAnnotation = "1970-01-01T00:00:00Z" + +// Manifest annotations carrying the bundle's identity + schema gate. +const ( + annotationTemplateName = "land.devstack.template.name" + annotationSchemaVersion = "land.devstack.template.schemaVersion" +) + +// Descriptor is the resolved identity of a pushed/pulled artifact: the human tag +// (provenance only) plus the manifest digest that is actually fetched and verified. +type Descriptor struct { + Ref string `json:"ref"` // canonical oci:// form (with digest) + Repository string `json:"repository"` // registry/repository + Tag string `json:"tag,omitempty"` // human tag (NOT used to fetch) + Digest string `json:"digest"` // sha256:… manifest digest (the pin) + SchemaVersion int `json:"schemaVersion"` // template-bundle schemaVersion + Name string `json:"name"` // template name inside the bundle + Size int64 `json:"size"` // manifest size in bytes +} + +// Target is the subset of oras a registry client needs: a content store that can +// be tagged and resolved. A live *remote.Repository and an in-memory store both +// satisfy it, so tests round-trip with no network. +type Target = oras.Target + +// TargetResolver returns an oras Target for a repository (host/path, no tag). The +// production resolver builds an authenticated *remote.Repository; tests return a +// shared store so push/pull round-trips offline. Behind this seam the CLI never +// constructs a remote.Repository itself. +type TargetResolver func(ctx context.Context, ref Reference) (Target, error) + +// Client packages, pushes, resolves and pulls template bundles. It is stateless +// apart from the TargetResolver seam. +type Client struct { + newTarget TargetResolver +} + +// New returns a Client whose TargetResolver talks to real registries, reading +// credentials from the docker/ORAS credential store (~/.docker/config.json + OS +// helpers) with a GITHUB_TOKEN fallback for GHCR. No devstack-specific token store. +func New() (*Client, error) { + res, err := defaultTargetResolver() + if err != nil { + return nil, err + } + return &Client{newTarget: res}, nil +} + +// NewWithResolver returns a Client backed by a custom TargetResolver (tests inject +// a memory/OCI store). +func NewWithResolver(r TargetResolver) *Client { + return &Client{newTarget: r} +} + +// Push packages the template directory at dir into a deterministic OCI artifact, +// pushes it to ref's repository, tags it with ref's tag, and returns the resolved +// manifest Descriptor (digest pin). +func (c *Client) Push(ctx context.Context, ref Reference, dir string) (Descriptor, error) { + if ref.Tag == "" { + return Descriptor{}, fmt.Errorf("push requires a tag (got %s)", ref) + } + tarData, name, schemaVersion, err := PackBundle(dir) + if err != nil { + return Descriptor{}, err + } + target, err := c.newTarget(ctx, ref) + if err != nil { + return Descriptor{}, err + } + + layerDesc, err := oras.PushBytes(ctx, target, LayerMediaType, tarData) + if err != nil { + return Descriptor{}, wrapAuth(err, ref, "push layer") + } + layerDesc.Annotations = map[string]string{ocispec.AnnotationTitle: name} + + manifestDesc, err := oras.PackManifest(ctx, target, oras.PackManifestVersion1_1, ArtifactType, oras.PackManifestOptions{ + Layers: []ocispec.Descriptor{layerDesc}, + ManifestAnnotations: map[string]string{ + ocispec.AnnotationCreated: createdAnnotation, + annotationTemplateName: name, + annotationSchemaVersion: fmt.Sprintf("%d", schemaVersion), + }, + }) + if err != nil { + return Descriptor{}, wrapAuth(err, ref, "pack manifest") + } + if err := target.Tag(ctx, manifestDesc, ref.Tag); err != nil { + return Descriptor{}, wrapAuth(err, ref, "tag manifest") + } + + return Descriptor{ + Ref: refWithDigest(ref, manifestDesc.Digest.String()).String(), + Repository: ref.Name(), + Tag: ref.Tag, + Digest: manifestDesc.Digest.String(), + SchemaVersion: schemaVersion, + Name: name, + Size: manifestDesc.Size, + }, nil +} + +// ResolveDigest resolves ref (by tag or digest) to its manifest Descriptor WITHOUT +// fetching the layers — the cheap tag→digest step used by `add`/`update`. +func (c *Client) ResolveDigest(ctx context.Context, ref Reference) (Descriptor, error) { + target, err := c.newTarget(ctx, ref) + if err != nil { + return Descriptor{}, err + } + desc, err := target.Resolve(ctx, ref.shortRef()) + if err != nil { + return Descriptor{}, wrapAuth(err, ref, "resolve") + } + return Descriptor{ + Ref: refWithDigest(ref, desc.Digest.String()).String(), + Repository: ref.Name(), + Tag: ref.Tag, + Digest: desc.Digest.String(), + Size: desc.Size, + }, nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..346ac4b --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,150 @@ +package registry + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "oras.land/oras-go/v2/content/memory" +) + +// memResolver returns a TargetResolver backed by a single shared in-memory oras +// store, so push/pull round-trips with no network (spec 19 test guidance). +func memResolver() TargetResolver { + store := memory.New() + return func(_ context.Context, _ Reference) (Target, error) { return store, nil } +} + +// writeBundle scaffolds a minimal-but-complete template bundle on disk and returns +// its directory. name becomes the bundle's top-level template directory. +func writeBundle(t *testing.T, name, version string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(filepath.Join(dir, "build"), 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "template.yaml": "schemaVersion: 1\ndescription: \"test\"\nparams:\n version:\n type: string\n default: \"" + version + "\"\nservice:\n image: alpine:[[ .params.version ]]\n", + "build/Dockerfile": "FROM alpine:[[ .params.version ]]\n", + "golden.yaml": "services:\n test: {}\n", + } + for rel, body := range files { + if err := os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestPushPullRoundTrip(t *testing.T) { + ctx := context.Background() + dir := writeBundle(t, "postgres", "16") + c := NewWithResolver(memResolver()) + + ref, err := ParseReference("oci://ghcr.io/acme/postgres:1.0.0") + if err != nil { + t.Fatal(err) + } + desc, err := c.Push(ctx, ref, dir) + if err != nil { + t.Fatalf("push: %v", err) + } + if desc.Digest == "" || desc.Name != "postgres" || desc.SchemaVersion != 1 { + t.Fatalf("unexpected descriptor: %+v", desc) + } + + // Pull by the resolved digest (the reproducible path) and unpack. + pinned := refWithDigest(ref, desc.Digest) + pulled, err := c.Pull(ctx, pinned) + if err != nil { + t.Fatalf("pull: %v", err) + } + if pulled.Descriptor.Digest != desc.Digest { + t.Errorf("pulled digest %s != pushed %s", pulled.Descriptor.Digest, desc.Digest) + } + + dest := t.TempDir() + gotName, err := UnpackBundle(pulled.Tar, dest) + if err != nil { + t.Fatalf("unpack: %v", err) + } + if gotName != "postgres" { + t.Errorf("unpacked name %q, want postgres", gotName) + } + // Round-trip fidelity: the unpacked template.yaml matches the source. + want, _ := os.ReadFile(filepath.Join(dir, "template.yaml")) + got, err := os.ReadFile(filepath.Join(dest, "postgres", "template.yaml")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(want, got) { + t.Errorf("template.yaml round-trip mismatch:\n want %q\n got %q", want, got) + } + for _, f := range []string{"build/Dockerfile", "golden.yaml"} { + if _, err := os.Stat(filepath.Join(dest, "postgres", f)); err != nil { + t.Errorf("missing %s after round-trip: %v", f, err) + } + } +} + +func TestPackBundleDeterministic(t *testing.T) { + dir := writeBundle(t, "redis", "7") + a, _, _, err := PackBundle(dir) + if err != nil { + t.Fatal(err) + } + b, _, _, err := PackBundle(dir) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatal("PackBundle is not deterministic: two packs of identical content differ") + } + + // Same content in a different directory (different mtimes/paths) packs identically. + dir2 := writeBundle(t, "redis", "7") + c, _, _, err := PackBundle(dir2) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, c) { + t.Fatal("PackBundle output depends on filesystem metadata — digest would not be content-addressed") + } +} + +func TestPullDigestMismatchRefused(t *testing.T) { + ctx := context.Background() + dir := writeBundle(t, "postgres", "16") + c := NewWithResolver(memResolver()) + ref, _ := ParseReference("oci://ghcr.io/acme/postgres:1.0.0") + if _, err := c.Push(ctx, ref, dir); err != nil { + t.Fatal(err) + } + + // Pin a bogus digest: the resolved manifest digest won't match → refused. + bad := refWithDigest(ref, "sha256:"+strings.Repeat("0", 64)) + if _, err := c.Pull(ctx, bad); err == nil { + t.Fatal("want a digest-mismatch error, got nil") + } +} + +func TestResolveDigest(t *testing.T) { + ctx := context.Background() + dir := writeBundle(t, "minio", "latest") + c := NewWithResolver(memResolver()) + ref, _ := ParseReference("oci://ghcr.io/acme/minio:2.0.0") + pushed, err := c.Push(ctx, ref, dir) + if err != nil { + t.Fatal(err) + } + got, err := c.ResolveDigest(ctx, ref) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got.Digest != pushed.Digest { + t.Errorf("resolve digest %s != pushed %s", got.Digest, pushed.Digest) + } +} diff --git a/internal/registry/target.go b/internal/registry/target.go new file mode 100644 index 0000000..c8fdc63 --- /dev/null +++ b/internal/registry/target.go @@ -0,0 +1,128 @@ +package registry + +import ( + "context" + "fmt" + "os" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + "oras.land/oras-go/v2/registry/remote/retry" +) + +// refWithDigest returns a copy of ref with its Digest set (Tag preserved). +func refWithDigest(ref Reference, dig string) Reference { + ref.Digest = dig + return ref +} + +// wrapAuth maps an oras authorization failure to a one-line remediation (the +// ARCHITECTURE §7.6 error contract) and otherwise annotates the failing step. +func wrapAuth(err error, ref Reference, step string) error { + if err == nil { + return nil + } + if isAuthMessage(err) { + return fmt.Errorf("not authorized for %s (%s): log in with `docker login %s` (or set GITHUB_TOKEN for ghcr.io) — devstack reads ~/.docker/config.json: %w", + ref.Name(), step, ref.Registry, err) + } + return fmt.Errorf("%s %s: %w", step, ref.Name(), err) +} + +// isAuthMessage catches auth failures that some registries surface without the +// typed errdef.ErrUnauthorized (e.g. a 403 on push to a repo you can read). +func isAuthMessage(err error) bool { + msg := err.Error() + for _, s := range []string{"401", "403", "unauthorized", "denied", "forbidden"} { + if containsFold(msg, s) { + return true + } + } + return false +} + +func containsFold(s, sub string) bool { + // tiny ASCII-fold contains to avoid pulling strings for two call sites + ls, lsub := toLowerASCII(s), toLowerASCII(sub) + return len(lsub) == 0 || indexOf(ls, lsub) >= 0 +} + +func toLowerASCII(s string) string { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 'a' - 'A' + } + } + return string(b) +} + +func indexOf(s, sub string) int { + n, m := len(s), len(sub) + for i := 0; i+m <= n; i++ { + if s[i:i+m] == sub { + return i + } + } + return -1 +} + +// defaultTargetResolver builds authenticated *remote.Repository targets, reading +// credentials from the docker/ORAS credential store (~/.docker/config.json + OS +// helpers). A GITHUB_TOKEN in the environment is used for ghcr.io when the docker +// store has no entry, so CI publishes without a `docker login` step. +func defaultTargetResolver() (TargetResolver, error) { + credStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{ + AllowPlaintextPut: false, + DetectDefaultNativeStore: true, + }) + if err != nil { + return nil, fmt.Errorf("open docker credential store: %w", err) + } + credFn := credentials.Credential(credStore) + + return func(_ context.Context, ref Reference) (Target, error) { + repo, err := remote.NewRepository(ref.Name()) + if err != nil { + return nil, fmt.Errorf("invalid repository %q: %w", ref.Name(), err) + } + // Allow plain HTTP for localhost registries (the `template push + // oci://localhost:5000/…` dev/testing path); everything else is HTTPS. + if isLocalhost(ref.Registry) { + repo.PlainHTTP = true + } + repo.Client = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: func(ctx context.Context, hostport string) (auth.Credential, error) { + if c, err := credFn(ctx, hostport); err == nil && c != (auth.Credential{}) { + return c, nil + } + if tok := ghcrToken(hostport); tok != "" { + return auth.Credential{Username: "oauth2", Password: tok}, nil + } + return auth.Credential{}, nil + }, + } + return repo, nil + }, nil +} + +// ghcrToken returns a GITHUB_TOKEN (or GHCR_TOKEN) for ghcr.io hosts, else "". +func ghcrToken(hostport string) string { + if hostport != "ghcr.io" { + return "" + } + for _, env := range []string{"GHCR_TOKEN", "GITHUB_TOKEN"} { + if v := os.Getenv(env); v != "" { + return v + } + } + return "" +} + +func isLocalhost(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" || + len(host) >= 10 && host[:10] == "localhost:" +} diff --git a/internal/registry/verify.go b/internal/registry/verify.go new file mode 100644 index 0000000..4b7af2b --- /dev/null +++ b/internal/registry/verify.go @@ -0,0 +1,160 @@ +package registry + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// Signature verification (spec 19 §"Supply-chain posture"). Digest verification is +// mandatory and always-on (see Pull); SIGNATURE verification is optional but +// first-class: with a VerifyPolicy configured, `add`/`update`/`verify` require a +// valid cosign signature over the manifest digest before the artifact is trusted. +// +// Consistent with internal/selfupdate (which deliberately shells the external +// `cosign` binary behind an interface to keep the release binary CGO-free rather +// than vendor the heavy sigstore-go tree), the real Verifier shells `cosign +// verify`. The seam is mock-able so tests never touch the network or need cosign. +// The pure-Go sigstore-go path (spec 19 §"no cosign binary required") is scoped +// out for this milestone and tracked in the PR — the seam makes it a drop-in swap. + +// VerifyPolicy pins the accepted signer identity for keyless cosign, or a public +// key for keyed cosign. A zero policy means "no signature policy" — the artifact +// is digest-pinned but unsigned (a loud one-line notice is printed on add). +type VerifyPolicy struct { + // Enabled turns signature verification on. When false, only the digest is + // verified (mandatory) and a "digest-pinned but unsigned" notice is shown. + Enabled bool + // IdentityRegexp pins the keyless signer identity (the Fulcio cert SAN, e.g. a + // GitHub Actions workflow ref). Required in keyless mode. + IdentityRegexp string + // OIDCIssuer pins the keyless OIDC issuer (e.g. GitHub Actions' token issuer). + // Required in keyless mode. + OIDCIssuer string + // KeyPath points at a cosign public key for KEYED (offline) verification. When + // set, keyless identity/issuer are ignored. + KeyPath string +} + +// IsZero reports whether no signature policy is configured. +func (p VerifyPolicy) IsZero() bool { return !p.Enabled && p.KeyPath == "" } + +// Keyless reports whether the policy verifies via keyless (Fulcio/Rekor) rather +// than a distributed public key. +func (p VerifyPolicy) Keyless() bool { return p.KeyPath == "" } + +// Verifier verifies a cosign signature over an OCI artifact addressed by its +// digest reference. Behind an interface so tests inject a fake and the real +// implementation shells cosign. +type Verifier interface { + // Available reports whether the verification backend (the cosign binary) is + // usable on this host. + Available() bool + // Verify returns nil when digestRef carries a valid cosign signature satisfying + // policy; any other outcome (unsigned, wrong identity, tampered) is an error. + Verify(ctx context.Context, digestRef string, policy VerifyPolicy) error +} + +// DefaultVerifier is the cosign-backed Verifier used when none is injected. +var DefaultVerifier Verifier = cosignVerifier{} + +// cosignVerifier shells `cosign verify`. +type cosignVerifier struct{} + +const cosignBin = "cosign" + +func (cosignVerifier) Available() bool { + _, err := exec.LookPath(cosignBin) + return err == nil +} + +func (cosignVerifier) Verify(ctx context.Context, digestRef string, policy VerifyPolicy) error { + args := []string{"verify"} + if policy.Keyless() { + if policy.IdentityRegexp == "" || policy.OIDCIssuer == "" { + return fmt.Errorf("keyless cosign policy requires both an identity regexp and an OIDC issuer") + } + args = append(args, + "--certificate-identity-regexp", policy.IdentityRegexp, + "--certificate-oidc-issuer", policy.OIDCIssuer, + ) + } else { + args = append(args, "--key", policy.KeyPath) + } + args = append(args, digestRef) + + // #nosec G204 -- args are fixed flags + a validated digest reference. + cmd := exec.CommandContext(ctx, cosignBin, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cosign signature verification failed for %s — refusing to trust the artifact (unsigned, tampered, or signed by an untrusted identity): %w: %s", + digestRef, err, strings.TrimSpace(string(out))) + } + return nil +} + +// Signer signs an OCI artifact addressed by its digest reference. Behind an +// interface for symmetry with Verifier; the real implementation shells `cosign +// sign` (keyless by default). Scoped for manual/CI use — see the package doc. +type Signer interface { + Available() bool + Sign(ctx context.Context, digestRef string, keyPath string) error +} + +// DefaultSigner is the cosign-backed Signer. +var DefaultSigner Signer = cosignSigner{} + +type cosignSigner struct{} + +func (cosignSigner) Available() bool { + _, err := exec.LookPath(cosignBin) + return err == nil +} + +func (cosignSigner) Sign(ctx context.Context, digestRef, keyPath string) error { + args := []string{"sign", "--yes"} + if keyPath != "" { + args = append(args, "--key", keyPath) + } + args = append(args, digestRef) + // #nosec G204 -- fixed flags + a validated digest reference + operator key path. + cmd := exec.CommandContext(ctx, cosignBin, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cosign sign failed for %s: %w: %s", digestRef, err, strings.TrimSpace(string(out))) + } + return nil +} + +// SignArtifact signs desc with s (or DefaultSigner). A missing cosign binary is a +// clear error with a remediation. +func SignArtifact(ctx context.Context, s Signer, desc Descriptor, keyPath string) error { + if s == nil { + s = DefaultSigner + } + if !s.Available() { + return fmt.Errorf("cosign not found: --sign requires the `cosign` binary (https://docs.sigstore.dev/system_config/installation/)") + } + return s.Sign(ctx, desc.Repository+"@"+desc.Digest, keyPath) +} + +// VerifySignature verifies the artifact at desc.Ref against policy using v (or the +// DefaultVerifier when nil). A no-op (nil) when the policy is zero — the caller is +// responsible for printing the unsigned notice. A missing cosign binary with an +// enabled policy is a hard error with a remediation (never a silent downgrade). +func VerifySignature(ctx context.Context, v Verifier, desc Descriptor, policy VerifyPolicy) error { + if policy.IsZero() { + return nil + } + if v == nil { + v = DefaultVerifier + } + if !v.Available() { + return fmt.Errorf("cosign not found: template signature verification is required by policy but the `cosign` binary is not installed.\n" + + " Install it (https://docs.sigstore.dev/system_config/installation/) and retry,\n" + + " or remove the verify policy to trust the digest pin alone (digest is still enforced).") + } + digestRef := desc.Repository + "@" + desc.Digest + return v.Verify(ctx, digestRef, policy) +} diff --git a/internal/registry/verify_test.go b/internal/registry/verify_test.go new file mode 100644 index 0000000..d595f0a --- /dev/null +++ b/internal/registry/verify_test.go @@ -0,0 +1,80 @@ +package registry + +import ( + "context" + "errors" + "testing" +) + +// fakeVerifier is a mock Verifier: it records what it was asked to verify and +// returns a configurable availability + result, so tests exercise the trust gate +// without shelling cosign or touching the network. +type fakeVerifier struct { + available bool + err error + called bool + gotRef string + gotPolicy VerifyPolicy +} + +func (f *fakeVerifier) Available() bool { return f.available } +func (f *fakeVerifier) Verify(_ context.Context, digestRef string, policy VerifyPolicy) error { + f.called = true + f.gotRef = digestRef + f.gotPolicy = policy + return f.err +} + +var keylessPolicy = VerifyPolicy{ + Enabled: true, + IdentityRegexp: `^https://github\.com/acme/.+$`, + OIDCIssuer: "https://token.actions.githubusercontent.com", +} + +func desc() Descriptor { + return Descriptor{Repository: "ghcr.io/acme/postgres", Digest: "sha256:abc"} +} + +func TestVerifyGoodSignatureAccepted(t *testing.T) { + v := &fakeVerifier{available: true} + if err := VerifySignature(context.Background(), v, desc(), keylessPolicy); err != nil { + t.Fatalf("good signature should be accepted: %v", err) + } + if !v.called { + t.Fatal("verifier was never invoked") + } + if v.gotRef != "ghcr.io/acme/postgres@sha256:abc" { + t.Errorf("verified %q, want the digest ref", v.gotRef) + } + if v.gotPolicy.IdentityRegexp != keylessPolicy.IdentityRegexp { + t.Errorf("policy identity not propagated: %+v", v.gotPolicy) + } +} + +func TestVerifyTamperedSignatureRejected(t *testing.T) { + v := &fakeVerifier{available: true, err: errors.New("signature mismatch")} + if err := VerifySignature(context.Background(), v, desc(), keylessPolicy); err == nil { + t.Fatal("a tampered/invalid signature must be rejected") + } +} + +func TestVerifyZeroPolicyIsNoop(t *testing.T) { + v := &fakeVerifier{available: false} // would fail if consulted + if err := VerifySignature(context.Background(), v, desc(), VerifyPolicy{}); err != nil { + t.Fatalf("a zero policy verifies nothing (digest-pinned only): %v", err) + } + if v.called { + t.Error("no verification should happen without a policy") + } +} + +func TestVerifyUnavailableCosignAborts(t *testing.T) { + v := &fakeVerifier{available: false} + err := VerifySignature(context.Background(), v, desc(), keylessPolicy) + if err == nil { + t.Fatal("an enabled policy with no cosign backend must abort") + } + if v.called { + t.Error("Verify must not be called when the backend is unavailable") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0854170..84a9e21 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -92,6 +92,9 @@ type Config struct { // 21): a `docker context` or DOCKER_HOST endpoint. nil = the local daemon. A // workspace.yaml `backend:` block, when present, overrides this per workspace. Backend *config.BackendConfig `yaml:"backend,omitempty"` + // Templates is the digest-pinned registry lockfile (spec 19): remote template + // sources registered by `template add`, resolved by name in the source chain. + Templates []RemoteTemplate `yaml:"templates,omitempty"` // Telemetry is the per-user/per-machine opt-in usage-telemetry consent // (spec 20). It lives here — never in workspace.yaml (must not be committed) // and never in state.db (it's user policy, not ledger state). Default OFF: a diff --git a/internal/store/templates.go b/internal/store/templates.go new file mode 100644 index 0000000..dd43fd6 --- /dev/null +++ b/internal/store/templates.go @@ -0,0 +1,89 @@ +package store + +import ( + "fmt" + "os" + "sort" + + "github.com/opencontainers/go-digest" + + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// RemoteTemplate is one registered remote template source in the store config +// (spec 19). It is the lockfile row: a local `name` resolvable in the template +// source chain, the `source` registry repository, the human `version` tag (kept +// for provenance / diffs only), and the `digest` that is ACTUALLY fetched and +// verified. `schemaVersion` is the template-bundle schema the pinned content +// declares — a bundle newer than this binary understands is refused on add. +// +// The task-scoped v2 slice keeps this lockfile in the store config +// (~/.devstack/config.yaml). The full spec-19 target is the committed +// `templates:` block in workspace.yaml; the shape here is deliberately identical +// so that migration is a move, not a redesign. +type RemoteTemplate struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Version string `yaml:"version"` + Digest string `yaml:"digest"` + SchemaVersion int `yaml:"schemaVersion,omitempty"` +} + +// TemplateCacheRoot is the digest-keyed template cache root: +// $XDG_CACHE_HOME/devstack/templates. Content is addressed by digest (never by +// tag) so a re-pushed tag lands under a NEW key and a pinned workspace never +// silently reaches poisoned content (spec 19 §"Cache poisoning if the key is the +// tag"). The cache lives on the Linux side under WSL2 (xdg refuses /mnt/*). +func TemplateCacheRoot() string { + return xdg.CacheHome() + string(os.PathSeparator) + "templates" +} + +// TemplateCacheDir returns the cache directory for a manifest digest: +// //. The unpacked bundle's single "/…" tree lives +// under it, so an FSSource rooted here lists the template by name. +func TemplateCacheDir(dig string) (string, error) { + d, err := digest.Parse(dig) + if err != nil { + return "", fmt.Errorf("invalid digest %q: %w", dig, err) + } + return TemplateCacheRoot() + string(os.PathSeparator) + d.Algorithm().String() + string(os.PathSeparator) + d.Encoded(), nil +} + +// Template returns the registered remote template with the given name. +func (c *Config) Template(name string) (RemoteTemplate, bool) { + for _, t := range c.Templates { + if t.Name == name { + return t, true + } + } + return RemoteTemplate{}, false +} + +// UpsertTemplate inserts or replaces a remote-template entry by name and returns +// whether an existing entry was replaced. Callers hold the flock (this mutates +// machine-global state) and Save afterwards. +func (c *Config) UpsertTemplate(rt RemoteTemplate) (replaced bool) { + for i, t := range c.Templates { + if t.Name == rt.Name { + c.Templates[i] = rt + return true + } + } + c.Templates = append(c.Templates, rt) + sort.Slice(c.Templates, func(i, j int) bool { return c.Templates[i].Name < c.Templates[j].Name }) + return false +} + +// RemoveTemplate drops the entry with the given name, reporting whether it existed. +func (c *Config) RemoveTemplate(name string) (removed bool) { + out := c.Templates[:0] + for _, t := range c.Templates { + if t.Name == name { + removed = true + continue + } + out = append(out, t) + } + c.Templates = out + return removed +}