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
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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")
}
}
20 changes: 16 additions & 4 deletions internal/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions internal/cli/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading