From 07f6010fde6e4fd3d43ccb21f46e3ec3213ade62 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 13:16:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20remote=20shared=20backend=20?= =?UTF-8?q?=E2=80=94=20run=20the=20shared=20stack=20on=20a=20remote=20Dock?= =?UTF-8?q?er=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