Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions internal/cli/backend.go
Original file line number Diff line number Diff line change
@@ -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{}
}
29 changes: 28 additions & 1 deletion internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down
57 changes: 57 additions & 0 deletions internal/cli/doctor_backend_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 7 additions & 3 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
Expand All @@ -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() {
Expand Down
91 changes: 91 additions & 0 deletions internal/config/backend_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
25 changes: 24 additions & 1 deletion internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)?$`)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading