From 6f869a36ab7e340f5956ca7805200fbc97e5e5f0 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 21:14:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20complete=20the=20command=20surface?= =?UTF-8?q?=20=E2=80=94=20shell,=20workspace=20list,=20up=20flags,=20tunne?= =?UTF-8?q?l=20up/down=20(spec=2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the spec-26 code deliverables (README already reconciled): - shell : real interactive exec via a NEW docker seam (docker.InteractiveRunner + Compose.Exec) that wires Stdin + a -it TTY, captures no stderr, and propagates the child exit code. Resolves project/service (single-default, else required), ValidArgsFunction completion, bash→sh default, clear non-TTY error. - workspace list + registry: additive schemaV3 `workspace` pointer table (keyed by ctx, CASCADE on docker_context) + RecordWorkspace/ListWorkspaces/ RemoveWorkspace CRUD; `up` upserts the row under the flock. `list` re-derives projects/shared refs from each workspace.yaml at list time, flags stale/unparseable roots, --prune drops vanished roots, --json. - up flags: --rebuild (new UpDeps.Rebuild → build --no-cache), --skip-clone (new UpDeps.SkipClone), --health-timeout (exposes existing HealthTimeout). - self update --force: new selfupdate.Options.Force re-installs over an up-to-date binary; the package-manager CanSelfReplace refusal still holds. - tunnel up/down: standalone commands managing the cloudflared container; default-DOWN; refuses services carrying non-local secret:// values (--allow-secrets override); {tunnel,state} JSON. - reserved post-1.0 stubs (dashboard/ide/telemetry, db + template v2 children); shell graduated out of stubs.go; logs re-tagged v2 (spec 16). Tests: registry CRUD + context isolation + CASCADE, interactive Exec arg construction, up rebuild threading, self --force semantics, tunnel secret refusal + container up/down, workspace list projection/prune/degrade. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/destroy.go | 2 +- internal/cli/root.go | 1 + internal/cli/self.go | 4 +- internal/cli/shell.go | 200 +++++++++++++++++++++++ internal/cli/shell_test.go | 151 ++++++++++++++++++ internal/cli/stubs.go | 21 ++- internal/cli/template.go | 7 + internal/cli/tunnel.go | 160 ++++++++++++++++++- internal/cli/tunnel_test.go | 57 ++++++- internal/cli/up.go | 29 +++- internal/cli/workspace_list.go | 228 +++++++++++++++++++++++++++ internal/cli/workspace_list_test.go | 174 ++++++++++++++++++++ internal/docker/compose.go | 45 ++++++ internal/docker/compose_test.go | 34 ++++ internal/orchestrate/rebuild_test.go | 67 ++++++++ internal/orchestrate/up.go | 10 +- internal/selfupdate/force_test.go | 28 ++++ internal/selfupdate/update.go | 22 ++- internal/state/migrations.go | 20 +++ internal/state/saga_test.go | 6 +- internal/state/workspace.go | 74 +++++++++ internal/state/workspace_test.go | 120 ++++++++++++++ internal/tunnel/tunnel.go | 66 ++++++++ internal/tunnel/tunnel_test.go | 43 +++++ 24 files changed, 1547 insertions(+), 22 deletions(-) create mode 100644 internal/cli/shell.go create mode 100644 internal/cli/shell_test.go create mode 100644 internal/cli/workspace_list.go create mode 100644 internal/cli/workspace_list_test.go create mode 100644 internal/orchestrate/rebuild_test.go create mode 100644 internal/selfupdate/force_test.go create mode 100644 internal/state/workspace.go create mode 100644 internal/state/workspace_test.go diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go index e3f120e..bf15813 100644 --- a/internal/cli/destroy.go +++ b/internal/cli/destroy.go @@ -24,7 +24,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command { Use: "workspace", Short: "Workspace-level lifecycle (teardown)", } - cmd.AddCommand(newWorkspaceDestroyCmd(g)) + cmd.AddCommand(newWorkspaceDestroyCmd(g), newWorkspaceListCmd(g)) return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index 5db69e4..f58a5c1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -79,6 +79,7 @@ func NewRootCmd(opts Options) *cobra.Command { root.AddCommand( newUpCmd(g), newDownCmd(g), + newShellCmd(g), newStatusCmd(g), newDnsCmd(g), newTrustCmd(g), diff --git a/internal/cli/self.go b/internal/cli/self.go index 21a1fd7..c9cf538 100644 --- a/internal/cli/self.go +++ b/internal/cli/self.go @@ -53,6 +53,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command { var ( check bool pin string + force bool ) cmd := &cobra.Command{ Use: "update", @@ -65,7 +66,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command { if check { return newSelfCheckCmd(g).RunE(cmd, nil) } - res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin}) + res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin, Force: force}) if err != nil { return err } @@ -96,5 +97,6 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command { } cmd.Flags().BoolVar(&check, "check", false, "only check for a newer version; do not install") cmd.Flags().StringVar(&pin, "version", "", "install a specific release tag (e.g. v0.2.0)") + cmd.Flags().BoolVar(&force, "force", false, "re-install even when already up to date (still refuses package-managed installs)") return cmd } diff --git a/internal/cli/shell.go b/internal/cli/shell.go new file mode 100644 index 0000000..172a8d7 --- /dev/null +++ b/internal/cli/shell.go @@ -0,0 +1,200 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" +) + +// newShellCmd wires `devstack shell [--project P] [-- cmd...]` (spec 26): +// an interactive exec into a running service container of the current (or +// --project) project stack. It shells `docker compose exec` with all three std +// streams inherited (a real -it TTY) via the InteractiveRunner and propagates the +// container's exit code verbatim. With no `-- cmd` it opens a login shell +// (bash→sh). A non-TTY invocation errors clearly rather than hanging. +func newShellCmd(g *GlobalOpts) *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "shell [service] [-- cmd...]", + Short: "Open an interactive shell (or run a command) in a service container", + Long: "shell execs into a running service container of this workspace. With one\n" + + "service it defaults to it; with many, name the service (completion helps).\n" + + "With no `-- cmd` it opens a login shell (bash, falling back to sh); pass\n" + + "`shell -- ` to run a one-off command instead. Requires an\n" + + "interactive terminal.", + // Args are the service name and, after `--`, the command. Cobra puts args + // after `--` in ArgsLenAtDash; we split them ourselves below. + ValidArgsFunction: shellServiceCompletion(&project), + RunE: func(cmd *cobra.Command, args []string) error { + // Split the positional service arg from the post-`--` command. + service, command := splitShellArgs(cmd, args) + + cwd, err := os.Getwd() + if err != nil { + return err + } + model, err := config.Load(cwd) + if err != nil { + return err + } + proj, svc, err := resolveShellTarget(model, project, service) + if err != nil { + return err + } + + // An interactive `compose exec -it` needs a real terminal on stdin; a + // pipe/CI would otherwise hang or fail opaquely. Fail clearly first. + if !isTerminal(os.Stdin) { + return fmt.Errorf("shell needs an interactive terminal (stdin is not a TTY); " + + "run it from a real terminal, or use a lifecycle hook / `docker compose exec -T` for scripted commands") + } + + outDir := filepath.Join(model.ProjectDir(proj), generate.GenDir) + composeFile := filepath.Join(outDir, generate.ComposeFile) + if _, err := os.Stat(composeFile); err != nil { + composeFile = "" // fall back to label-driven `compose -p exec` + } + run := command + if len(run) == 0 { + run = defaultShellCmd() + } + cp := docker.Compose{ + Project: "devstack-" + proj, + File: composeFile, + Dir: outDir, + Runner: docker.InteractiveRunner{}, + } + err = cp.Exec(cmd.Context(), svc, true, run...) + if err != nil { + // Propagate the container's exit code verbatim (spec 26): a non-zero + // shell exit is the shell's outcome, not a devstack failure. + var ee *exec.ExitError + if errors.As(err, &ee) { + os.Exit(ee.ExitCode()) + } + return err + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "project whose stack to exec into (default: the only project, else required)") + return cmd +} + +// splitShellArgs separates the positional service name (before `--`) from the +// command to run (after `--`). Cobra records the `--` position in ArgsLenAtDash. +func splitShellArgs(cmd *cobra.Command, args []string) (service string, command []string) { + dash := cmd.ArgsLenAtDash() + if dash < 0 { + // No `--`: the first arg (if any) is the service; anything else is ignored. + if len(args) > 0 { + return args[0], nil + } + return "", nil + } + if dash > 0 { + service = args[0] + } + command = args[dash:] + return service, command +} + +// resolveShellTarget resolves the project + service to exec into. project defaults +// to the workspace's only project (else it is required); service defaults to the +// project's only service (else it is required). Errors list the choices. +func resolveShellTarget(m *config.Model, project, service string) (proj, svc string, err error) { + proj = project + if proj == "" { + names := sortedProjectNames(m) + switch len(names) { + case 0: + return "", "", fmt.Errorf("this workspace has no projects") + case 1: + proj = names[0] + default: + return "", "", fmt.Errorf("workspace has multiple projects (%v); pass --project", names) + } + } + p, ok := m.Projects[proj] + if !ok { + return "", "", fmt.Errorf("project %q is not in this workspace", proj) + } + svc = service + if svc == "" { + names := sortedServiceNames(p.Services) + switch len(names) { + case 0: + return "", "", fmt.Errorf("project %q has no services", proj) + case 1: + svc = names[0] + default: + return "", "", fmt.Errorf("project %q has multiple services (%v); name one", proj, names) + } + return proj, svc, nil + } + if _, ok := p.Services[svc]; !ok { + return "", "", fmt.Errorf("service %q is not in project %q (have %v)", svc, proj, sortedServiceNames(p.Services)) + } + return proj, svc, nil +} + +// defaultShellCmd is the login-shell command when no `-- cmd` is given: prefer +// bash, fall back to sh, probed inside the container in a single exec +// (Q-SHELL-DEFAULT-CMD). +func defaultShellCmd() []string { + return []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} +} + +// shellServiceCompletion completes the service argument with the workspace's +// service names (of --project, or all projects), per spec 07's ValidArgsFunction. +func shellServiceCompletion(project *string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + cwd, err := os.Getwd() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + m, err := config.Load(cwd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + set := map[string]bool{} + for pname, p := range m.Projects { + if *project != "" && pname != *project { + continue + } + for sname := range p.Services { + set[sname] = true + } + } + out := make([]string, 0, len(set)) + for s := range set { + out = append(out, s) + } + sort.Strings(out) + return out, cobra.ShellCompDirectiveNoFileComp + } +} + +// isTerminal reports whether f is a character device (a TTY), using only stdlib. +func isTerminal(f *os.File) bool { + if f == nil { + return false + } + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/cli/shell_test.go b/internal/cli/shell_test.go new file mode 100644 index 0000000..b304b36 --- /dev/null +++ b/internal/cli/shell_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// writeWorkspace writes a workspace.yaml + one or more project devstack.yaml files +// under a fresh temp dir and returns the root. projects maps project name → body. +func writeWS(t *testing.T, workspaceYAML string, projects map[string]string) string { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", workspaceYAML) + for name, body := range projects { + write(filepath.Join(name, "devstack.yaml"), body) + } + return root +} + +func TestShellRegistered(t *testing.T) { + if !findCmd(t, "shell") { + t.Fatal("shell must be a real RunE command (graduated from stubs)") + } + // And it must NOT appear as a stub anymore. + root := NewRootCmd(Options{}) + for _, c := range root.Commands() { + if c.Name() == "shell" && c.RunE == nil { + t.Fatal("shell is still a stub group") + } + } +} + +func TestResolveShellTarget(t *testing.T) { + oneSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n" + twoSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n api: { template: node.vite }\n" + + t.Run("single project single service defaults", func(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n", + map[string]string{"app": oneSvc}) + m := mustLoad(t, root) + proj, svc, err := resolveShellTarget(m, "", "") + if err != nil || proj != "app" || svc != "web" { + t.Fatalf("resolve = (%q,%q,%v), want app/web", proj, svc, err) + } + }) + + t.Run("multi service requires explicit name", func(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n", + map[string]string{"app": twoSvc}) + m := mustLoad(t, root) + if _, _, err := resolveShellTarget(m, "", ""); err == nil { + t.Fatal("multi-service project must require an explicit service") + } + proj, svc, err := resolveShellTarget(m, "", "api") + if err != nil || proj != "app" || svc != "api" { + t.Fatalf("resolve with name = (%q,%q,%v)", proj, svc, err) + } + }) + + t.Run("unknown service errors", func(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n", + map[string]string{"app": oneSvc}) + m := mustLoad(t, root) + if _, _, err := resolveShellTarget(m, "", "nope"); err == nil { + t.Fatal("unknown service must error") + } + }) + + t.Run("multi project requires --project", func(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n - { name: svc, path: svc }\n", + map[string]string{ + "app": oneSvc, + "svc": "apiVersion: devstack/v1\nkind: Project\nname: svc\nservices:\n worker: { template: node.vite }\n", + }) + m := mustLoad(t, root) + if _, _, err := resolveShellTarget(m, "", ""); err == nil { + t.Fatal("multi-project workspace must require --project") + } + proj, svc, err := resolveShellTarget(m, "svc", "") + if err != nil || proj != "svc" || svc != "worker" { + t.Fatalf("resolve with --project = (%q,%q,%v)", proj, svc, err) + } + }) +} + +func TestDefaultShellCmd(t *testing.T) { + cmd := defaultShellCmd() + if len(cmd) < 3 || cmd[0] != "sh" || cmd[1] != "-c" { + t.Fatalf("defaultShellCmd = %v, want an sh -c probe", cmd) + } + if !strings.Contains(cmd[2], "bash") || !strings.Contains(cmd[2], "sh") { + t.Errorf("default shell probe should prefer bash, fall back to sh: %q", cmd[2]) + } +} + +func TestShellNonTTYErrorsClearly(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n", + map[string]string{"app": "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n"}) + t.Chdir(root) + + // Force a non-TTY stdin (a pipe is not a character device) so the check is + // deterministic regardless of how the tests are launched. + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = w.Close(); _ = r.Close() }() + old := os.Stdin + os.Stdin = r + defer func() { os.Stdin = old }() + + rootCmd := NewRootCmd(Options{}) + var out strings.Builder + rootCmd.SetArgs([]string{"shell", "web"}) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + err = rootCmd.Execute() + if err == nil { + t.Fatal("shell in a non-TTY must error, not hang") + } + if !strings.Contains(err.Error(), "TTY") && !strings.Contains(err.Error(), "terminal") { + t.Errorf("non-TTY error should mention the terminal requirement: %v", err) + } +} + +func mustLoad(t *testing.T, root string) *config.Model { + t.Helper() + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load %s: %v", root, err) + } + return m +} diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index a9eb66d..19f2cc0 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -26,11 +26,24 @@ func stub(use, short, milestone string, children ...*cobra.Command) *cobra.Comma func rootName(c *cobra.Command) string { return c.Root().Name() } -// addStubCommands wires the remaining command surface from spec 07. doctor, -// alias and version are real; everything else is a milestone-tagged placeholder. +// addStubCommands reserves the post-1.0 command surface from spec 07 as +// milestone-tagged placeholders so `--help`/completions stay consistent (exit 0, +// clear notice). `shell` has GRADUATED to a real command (spec 26); `logs` stays a +// stub, re-tagged to v2 (its full read-only-SDK design is owned by spec 16). func addStubCommands(root *cobra.Command, _ *GlobalOpts) { root.AddCommand( - stub("shell", "Open a shell in a service container", "M2"), - stub("logs", "Stream service logs", "M2"), + stub("logs", "Stream service logs", "v2 (spec 16)"), + stub("dashboard", "Live TUI cockpit", "v2 (spec 16)"), + stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"), + stub("telemetry", "Opt-in usage telemetry (default OFF)", "a later release (spec 20)"), + // db: parent hosting `gc` (v1, spec 13) + snapshot|restore|reset|list|pull (v2, spec 15). + stub("db", "Database snapshot/restore/reset lifecycle", "v2 (spec 15)", + stub("gc", "Reclaim orphaned provisioned databases/roles/buckets", "v1 (spec 13)"), + stub("snapshot", "Snapshot a project's database", "v2 (spec 15)"), + stub("restore", "Restore a project's database from a snapshot", "v2 (spec 15)"), + stub("reset", "Drop and re-provision a project's database", "v2 (spec 15)"), + stub("list", "List available database snapshots", "v2 (spec 15)"), + stub("pull", "Pull a database snapshot from a shared store", "v2 (spec 15)"), + ), ) } diff --git a/internal/cli/template.go b/internal/cli/template.go index def0a44..64656cf 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -27,6 +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)"), ) return cmd } diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 4603122..5fe40a3 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -2,9 +2,14 @@ package cli import ( "fmt" + "os" + "path/filepath" "github.com/spf13/cobra" + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/proxy" "github.com/open-source-cloud/devstack/internal/tunnel" ) @@ -16,10 +21,163 @@ func newTunnelCmd(g *GlobalOpts) *cobra.Command { Use: "tunnel", Short: "Optional public tunnel via cloudflared (account-gated, default down)", } - cmd.AddCommand(newTunnelLoginCmd(g), newTunnelCreateCmd(g), newTunnelRouteCmd(g)) + cmd.AddCommand( + newTunnelLoginCmd(g), + newTunnelCreateCmd(g), + newTunnelRouteCmd(g), + newTunnelUpCmd(g), + newTunnelDownCmd(g), + ) return cmd } +// newTunnelUpCmd wires `tunnel up [name] [--detach] [--allow-secrets]` (spec 05/26): +// bring the managed cloudflared container up against the shared stack, ingress +// rendered from the proxy []Route. Default-DOWN stays the default (this is the +// explicit opt-in). It REFUSES to expose a service whose env carries a non-local +// secret:// value unless --allow-secrets is given, printing the override hint. +func newTunnelUpCmd(g *GlobalOpts) *cobra.Command { + var ( + detach bool + allowSecrets bool + ) + cmd := &cobra.Command{ + Use: "up [name]", + Short: "Bring the managed cloudflared tunnel up (default is down)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + model, err := config.Load(cwd) + if err != nil { + return err + } + + // Spec 05 guard: refuse to tunnel any service carrying a non-local + // secret:// value (a public tunnel would expose a cloud secret's blast + // radius) unless the operator explicitly overrides. + if refs := nonLocalSecretRefs(model); len(refs) > 0 && !allowSecrets { + return fmt.Errorf("refusing to open a public tunnel: these services carry non-local secret:// values: %v\n"+ + "a public tunnel would widen their exposure; pass --allow-secrets to override once you have reviewed them", refs) + } + + routes := proxy.BuildRoutes(model) + hostnames := make([]string, 0, len(routes)) + for _, r := range routes { + hostnames = append(hostnames, r.Host) + } + + name := model.Workspace.Name + if len(args) == 1 { + name = args[0] + } + + // Render the ingress config (public reuses local routing — same []Route). + tunnelDir := filepath.Join(model.Root, generate.GenDir, "tunnel") + if err := os.MkdirAll(tunnelDir, 0o755); err != nil { + return err + } + configPath := filepath.Join(tunnelDir, "config.yml") + creds := "" + if home, err := os.UserHomeDir(); err == nil { + creds = filepath.Join(home, ".cloudflared") + } + ingress := tunnel.IngressConfig(name, filepath.Join("/home/nonroot/.cloudflared", name+".json"), caddyUpstream, hostnames) + if err := os.WriteFile(configPath, []byte(ingress), 0o644); err != nil { + return err + } + + if err := tunnel.New().Up(cmd.Context(), tunnel.UpOptions{ + Name: name, + ConfigPath: configPath, + CredsDir: creds, + Network: generate.SharedNetwork, + Detach: detach, + }); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"tunnel": name, "state": "up"}) + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "tunnel %q up (%d route(s))\n", name, len(hostnames)) + } + return nil + }, + } + cmd.Flags().BoolVar(&detach, "detach", true, "run the tunnel container detached") + cmd.Flags().BoolVar(&allowSecrets, "allow-secrets", false, "override the refusal to tunnel services carrying non-local secret:// values") + return cmd +} + +// newTunnelDownCmd wires `tunnel down` (spec 05/26): stop the managed tunnel +// container, leaving credentials and DNS routes intact (reversible). +func newTunnelDownCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "down", + Short: "Stop the managed cloudflared tunnel (credentials/routes preserved)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := tunnel.New().Down(cmd.Context()); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"tunnel": tunnel.ContainerName, "state": "down"}) + } + if !g.Quiet { + fmt.Fprintln(cmd.OutOrStdout(), "tunnel down") + } + return nil + }, + } +} + +// caddyUpstream is where cloudflared forwards public requests: the shared reverse +// proxy on the shared network, so public traffic reuses the exact local []Route +// (spec 05, no drift). The concrete proxy container wiring is spec-05 territory. +const caddyUpstream = "http://shared-caddy:80" + +// nonLocalSecretRefs collects every non-local secret:// value referenced by any +// service env across the workspace — the refs a public tunnel must refuse to +// expose (spec 05). Locality is classified from the workspace's declared +// providers (an offline provider like sops+age is local; aws/infisical are not). +func nonLocalSecretRefs(m *config.Model) []string { + var envValues []string + for _, p := range m.Projects { + for _, svc := range p.Services { + for _, v := range svc.Env.Raw { + envValues = append(envValues, v) + } + for _, v := range svc.Env.Prefixed { + envValues = append(envValues, v) + } + } + } + return tunnel.SecretBearing(envValues, localProviderClassifier(m)) +} + +// localProviderClassifier returns a predicate telling whether a provider NAME (as +// used in a secret:// ref) resolves to an OFFLINE/local backend. It reads the +// workspace's declared providers: kind `sops` is local (offline age decryption); +// cloud kinds (aws-sm/aws-ssm/infisical) are not. An unknown provider is treated +// as non-local (fail safe — err on refusing). +func localProviderClassifier(m *config.Model) func(string) bool { + kindByName := map[string]string{} + for _, pr := range m.Workspace.Secrets.Providers { + kindByName[pr.Name] = pr.Kind + } + localKinds := map[string]bool{"sops": true} + return func(provider string) bool { + kind, ok := kindByName[provider] + if !ok { + return false + } + return localKinds[kind] + } +} + func newTunnelLoginCmd(g *GlobalOpts) *cobra.Command { return &cobra.Command{ Use: "login", diff --git a/internal/cli/tunnel_test.go b/internal/cli/tunnel_test.go index c60aae8..b686726 100644 --- a/internal/cli/tunnel_test.go +++ b/internal/cli/tunnel_test.go @@ -1,13 +1,66 @@ package cli -import "testing" +import ( + "slices" + "strings" + "testing" +) func TestTunnelRegistered(t *testing.T) { root := NewRootCmd(Options{}) - for _, sub := range []string{"login", "create", "route"} { + for _, sub := range []string{"login", "create", "route", "up", "down"} { c, _, err := root.Find([]string{"tunnel", sub}) if err != nil || c.Name() != sub || c.RunE == nil { t.Errorf("tunnel %s not registered as a real command: %v", sub, err) } } } + +func TestNonLocalSecretRefs(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n"+ + "secrets:\n providers:\n - { name: aws, kind: aws-sm }\n - { name: vault, kind: sops }\n"+ + "projects:\n - { name: app, path: app }\n", + map[string]string{ + "app": "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n" + + " web:\n template: node.vite\n env:\n raw:\n" + + " CLOUD: secret://aws/db\n LOCAL: secret://vault/key\n PLAIN: hello\n", + }) + m := mustLoad(t, root) + refs := nonLocalSecretRefs(m) + // The aws (cloud) ref is non-local and must be reported; the sops one is local + // and must NOT; the plain value is not a secret. + if !slices.ContainsFunc(refs, func(r string) bool { return strings.Contains(r, "aws") }) { + t.Errorf("non-local refs %v should include the aws ref", refs) + } + if slices.ContainsFunc(refs, func(r string) bool { return strings.Contains(r, "vault") }) { + t.Errorf("non-local refs %v must NOT include the local sops ref", refs) + } +} + +func TestTunnelUpRefusesNonLocalSecret(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n"+ + "projects:\n - { name: app, path: app }\n", + map[string]string{ + // Provider "cloud" is undeclared → classified non-local (fail safe). + "app": "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n" + + " web:\n template: node.vite\n env:\n raw: { KEY: secret://cloud/db }\n", + }) + t.Chdir(root) + + rootCmd := NewRootCmd(Options{}) + var out strings.Builder + rootCmd.SetArgs([]string{"tunnel", "up"}) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + // The secret guard fires before any docker/cloudflared call, so this is safe + // without a daemon. + err := rootCmd.Execute() + if err == nil { + t.Fatal("tunnel up must refuse a service carrying a non-local secret://") + } + if !strings.Contains(err.Error(), "allow-secrets") { + t.Errorf("refusal should mention the --allow-secrets override: %v", err) + } +} diff --git a/internal/cli/up.go b/internal/cli/up.go index 1785998..0e22f80 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "time" "github.com/spf13/cobra" @@ -26,11 +27,14 @@ import ( // shared(health-gated) → compose-up → hooks, resumable and compensating. func newUpCmd(g *GlobalOpts) *cobra.Command { var ( - build bool - noHooks bool - noPreflight bool - noProvision bool - profiles []string + build bool + rebuild bool + skipClone bool + noHooks bool + noPreflight bool + noProvision bool + profiles []string + healthTimeout time.Duration ) cmd := &cobra.Command{ Use: "up [project...]", @@ -47,10 +51,13 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { defer closeFn() d.Projects = args d.Build = build + d.Rebuild = rebuild + d.SkipClone = skipClone d.NoHooks = noHooks d.NoPreflight = noPreflight d.NoProvision = noProvision d.Profiles = profiles + d.HealthTimeout = healthTimeout // Memory-budget warning (spec 12 §budget): opt-in — only when the // workspace declares memoryBudgetMB and the active slice exceeds it. Never @@ -78,6 +85,15 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { } records, runErr := saga.Run(cmd.Context(), phases) + // Register this workspace in the machine-wide registry (spec 26) so + // `workspace list` can enumerate it. Written under the flock on a + // successful up; best-effort — a registry write must never fail the up. + if runErr == nil { + _ = lock.WithLock(cmd.Context(), d.LockPath, func() error { + return d.DB.RecordWorkspace(d.Model.Workspace.Name, d.Model.Root) + }) + } + if g.JSON { if err := writeJSON(cmd, records); err != nil { return err @@ -90,6 +106,9 @@ func newUpCmd(g *GlobalOpts) *cobra.Command { }, } cmd.Flags().BoolVar(&build, "build", false, "build images before starting (compose build)") + cmd.Flags().BoolVar(&rebuild, "rebuild", false, "force a no-cache image rebuild before starting (compose build --no-cache)") + cmd.Flags().BoolVar(&skipClone, "skip-clone", false, "skip the clone/sync phase (repos are already on disk)") + cmd.Flags().DurationVar(&healthTimeout, "health-timeout", 0, "per-shared-service readiness deadline (0 → spec-10 default)") cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks") cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks") cmd.Flags().BoolVar(&noProvision, "no-provision", false, "skip per-project Postgres role/db provisioning") diff --git a/internal/cli/workspace_list.go b/internal/cli/workspace_list.go new file mode 100644 index 0000000..3f56606 --- /dev/null +++ b/internal/cli/workspace_list.go @@ -0,0 +1,228 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// wsSharedRef is one shared-service ref-count entry for a workspace list row. +type wsSharedRef struct { + Service string `json:"service"` + Refs int `json:"refs"` +} + +// wsListRow is one `workspace list` row (also the --json element shape). It is a +// live projection: the registry supplies name/root/timestamps, everything else is +// re-derived from the committed workspace.yaml at list time (Q-WS-REGISTRY). +type wsListRow struct { + Name string `json:"name"` + Root string `json:"root"` + Projects []string `json:"projects"` + Shared []wsSharedRef `json:"shared"` + LastUpAt string `json:"last_up_at,omitempty"` + Stale bool `json:"stale"` // root no longer on disk + Issue string `json:"issue,omitempty"` // e.g. an unparseable workspace.yaml +} + +// newWorkspaceListCmd wires `devstack workspace list [--json] [--prune]` (spec 26): +// enumerate every workspace recorded for the current Docker context, re-deriving +// each root's projects + shared-service refs from its workspace.yaml. A vanished +// root is flagged `stale` (and dropped by --prune); an unparseable workspace.yaml +// degrades to a flagged row rather than failing the whole list. +func newWorkspaceListCmd(g *GlobalOpts) *cobra.Command { + var prune bool + cmd := &cobra.Command{ + Use: "list", + Short: "List every registered workspace for this Docker context", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + db, closeFn, err := openLedger(cmd) + if err != nil { + return err + } + defer closeFn() + + registered, err := db.ListWorkspaces() + if err != nil { + return err + } + // A single ledger read of every ref row for this context; joined per + // workspace below. Lock-free (reads are snapshots). + allRefs, err := db.AllRefs() + if err != nil { + return err + } + + var rows []wsListRow + var vanished []string + for _, w := range registered { + row := wsListRow{Name: w.Name, Root: w.Root, LastUpAt: w.LastUpAt} + if _, statErr := os.Stat(w.Root); statErr != nil { + row.Stale = true + vanished = append(vanished, w.Root) + rows = append(rows, row) + continue + } + m, loadErr := config.LoadAt(w.Root) + if loadErr != nil { + // Degrade: the root exists but its workspace.yaml won't parse. Flag + // it, never fail the whole list (and never prune it — the root is + // still there). + row.Issue = "unreadable workspace.yaml" + rows = append(rows, row) + continue + } + row.Name = m.Workspace.Name + row.Projects = sortedProjectNames(m) + row.Shared = sharedRefsFor(m, allRefs) + rows = append(rows, row) + } + + // --prune is the ONLY path that removes a vanished root (a moved checkout + // keeps its history otherwise). Under the flock. + if prune && len(vanished) > 0 { + if err := lock.WithLock(cmd.Context(), lockPath(), func() error { + for _, root := range vanished { + if _, err := db.RemoveWorkspace(root); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + // Drop the pruned rows from the output. + kept := rows[:0] + for _, r := range rows { + if r.Stale { + continue + } + kept = append(kept, r) + } + rows = kept + } + + if g.JSON { + return writeJSON(cmd, rows) + } + return renderWorkspaceList(cmd, rows) + }, + } + cmd.Flags().BoolVar(&prune, "prune", false, "drop registry rows whose workspace root no longer exists") + return cmd +} + +// sharedRefsFor joins the ledger ref rows to a workspace's projects: for each +// shared service any of this workspace's projects reference, the ref count. Sorted +// by service for deterministic output. +func sharedRefsFor(m *config.Model, allRefs []state.Ref) []wsSharedRef { + projSet := map[string]bool{} + for name := range m.Projects { + projSet[name] = true + } + counts := map[string]int{} + for _, r := range allRefs { + if projSet[r.Project] { + counts[r.SharedService]++ + } + } + out := make([]wsSharedRef, 0, len(counts)) + for svc, n := range counts { + out = append(out, wsSharedRef{Service: svc, Refs: n}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Service < out[j].Service }) + return out +} + +// renderWorkspaceList prints the plain table: NAME · ROOT · PROJECTS · SHARED · LAST UP. +func renderWorkspaceList(cmd *cobra.Command, rows []wsListRow) error { + w := cmd.OutOrStdout() + if len(rows) == 0 { + fmt.Fprintln(w, "no workspaces recorded yet (run `devstack up` in a workspace)") + return nil + } + tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tROOT\tPROJECTS\tSHARED\tLAST UP") + for _, r := range rows { + root := r.Root + switch { + case r.Stale: + root += " [stale: gone]" + case r.Issue != "": + root += " [" + r.Issue + "]" + } + projects := "—" + if len(r.Projects) > 0 { + projects = joinComma(r.Projects) + } + shared := "—" + if len(r.Shared) > 0 { + parts := make([]string, 0, len(r.Shared)) + for _, s := range r.Shared { + parts = append(parts, fmt.Sprintf("%s (%d)", s.Service, s.Refs)) + } + shared = joinComma(parts) + } + lastUp := r.LastUpAt + if lastUp == "" { + lastUp = "—" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.Name, root, projects, shared, lastUp) + } + return tw.Flush() +} + +func joinComma(s []string) string { + out := "" + for i, v := range s { + if i > 0 { + out += ", " + } + out += v + } + return out +} + +// openLedger opens the machine-global ledger for the current Docker context +// WITHOUT requiring a workspace at the CWD (workspace list is machine-wide). The +// docker client only supplies the context key; if the daemon is unreachable the +// ledger still opens under the default context. +func openLedger(cmd *cobra.Command) (*state.DB, func(), error) { + ctx := cmd.Context() + ctxName := state.DefaultContext + var closeDocker func() + if c, err := docker.NewClient(ctx); err == nil { + ctxName = c.ContextName() + closeDocker = func() { _ = c.Close() } + } + db, err := state.Open(ctx, xdg.DataHome(), ctxName) + if err != nil { + if closeDocker != nil { + closeDocker() + } + return nil, nil, err + } + closeFn := func() { + db.Close() + if closeDocker != nil { + closeDocker() + } + } + return db, closeFn, nil +} + +// lockPath returns the machine-global advisory lock path (spec 08). +func lockPath() string { + return filepath.Join(xdg.RuntimeDir(), "devstack.lock") +} diff --git a/internal/cli/workspace_list_test.go b/internal/cli/workspace_list_test.go new file mode 100644 index 0000000..ec7c5d7 --- /dev/null +++ b/internal/cli/workspace_list_test.go @@ -0,0 +1,174 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/state" +) + +func TestWorkspaceListRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"workspace", "list"}) + if err != nil || c.Name() != "list" || c.RunE == nil { + t.Fatalf("workspace list not registered as a real command: %v", err) + } +} + +// seedWorkspace opens the ledger the same way the command does and records a +// workspace row under the flock, then closes it. +func seedWorkspace(t *testing.T, name, root string, refs []state.Ref) { + t.Helper() + c := &cobra.Command{} + c.SetContext(context.Background()) + db, closeFn, err := openLedger(c) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer closeFn() + if err := lock.WithLock(context.Background(), lockPath(), func() error { + if err := db.RecordWorkspace(name, root); err != nil { + return err + } + for _, r := range refs { + if err := db.AddRef(r.Project, r.Service, r.SharedService); err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } +} + +func TestWorkspaceListProjectionAndPrune(t *testing.T) { + // Isolate the ledger + lock under temp XDG dirs. + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + // A live workspace with one project that references a shared postgres. + liveRoot := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: acme\nshared:\n postgres: { template: postgres, params: { version: \"16\" } }\nprojects:\n - { name: api, path: api }\n", + map[string]string{ + "api": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n web:\n template: node.vite\n uses: [workspace.shared.postgres]\n", + }) + seedWorkspace(t, "acme", liveRoot, []state.Ref{{Project: "api", Service: "web", SharedService: "shared-postgres-16"}}) + + // A vanished workspace root (recorded, then removed from disk). + goneRoot := filepath.Join(t.TempDir(), "gone") + if err := os.MkdirAll(goneRoot, 0o755); err != nil { + t.Fatal(err) + } + seedWorkspace(t, "demo", goneRoot, nil) + if err := os.RemoveAll(goneRoot); err != nil { + t.Fatal(err) + } + + list := func(args ...string) []wsListRow { + t.Helper() + rootCmd := NewRootCmd(Options{}) + var out strings.Builder + rootCmd.SetArgs(append([]string{"workspace", "list", "--json"}, args...)) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("workspace list: %v\n%s", err, out.String()) + } + var rows []wsListRow + if err := json.Unmarshal([]byte(out.String()), &rows); err != nil { + t.Fatalf("unmarshal %q: %v", out.String(), err) + } + return rows + } + + rows := list() + var live, gone *wsListRow + for i := range rows { + switch rows[i].Root { + case liveRoot: + live = &rows[i] + case goneRoot: + gone = &rows[i] + } + } + if live == nil || gone == nil { + t.Fatalf("expected both workspaces listed, got %+v", rows) + } + // The live row re-derives projects + shared refs from workspace.yaml + ledger. + if len(live.Projects) != 1 || live.Projects[0] != "api" { + t.Errorf("live projects = %v, want [api]", live.Projects) + } + if len(live.Shared) != 1 || live.Shared[0].Service != "shared-postgres-16" || live.Shared[0].Refs != 1 { + t.Errorf("live shared = %+v, want shared-postgres-16 (1)", live.Shared) + } + if live.Stale { + t.Error("live workspace should not be stale") + } + // The vanished root is flagged stale (but NOT dropped without --prune). + if !gone.Stale { + t.Error("vanished workspace root should be flagged stale") + } + + // A plain (non-JSON) listing still succeeds and shows the stale marker. + rootCmd := NewRootCmd(Options{}) + var plain strings.Builder + rootCmd.SetArgs([]string{"workspace", "list"}) + rootCmd.SetOut(&plain) + rootCmd.SetErr(&plain) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("plain list: %v", err) + } + if !strings.Contains(plain.String(), "stale") { + t.Errorf("plain output should mark the stale row: %q", plain.String()) + } + + // --prune drops the vanished root and only that. + pruned := list("--prune") + for _, r := range pruned { + if r.Root == goneRoot { + t.Error("--prune should have removed the vanished root") + } + } + if len(pruned) != 1 || pruned[0].Root != liveRoot { + t.Fatalf("after prune = %+v, want just the live workspace", pruned) + } +} + +func TestWorkspaceListDegradesUnparseableRoot(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + // A root that exists but whose workspace.yaml is garbage. + badRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(badRoot, "workspace.yaml"), []byte("::: not yaml :::\n"), 0o644); err != nil { + t.Fatal(err) + } + seedWorkspace(t, "bad", badRoot, nil) + + rootCmd := NewRootCmd(Options{}) + var out strings.Builder + rootCmd.SetArgs([]string{"workspace", "list", "--json"}) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + // An unparseable workspace.yaml must degrade to a flagged row, not fail. + if err := rootCmd.Execute(); err != nil { + t.Fatalf("list must not fail on an unparseable root: %v", err) + } + var rows []wsListRow + if err := json.Unmarshal([]byte(out.String()), &rows); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(rows) != 1 || rows[0].Issue == "" { + t.Fatalf("unparseable root should be a flagged row: %+v", rows) + } + if rows[0].Stale { + t.Error("an existing-but-unparseable root is not stale (it is still on disk)") + } +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 572fd16..c9fd78e 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -38,6 +38,31 @@ func (ExecRunner) Run(ctx context.Context, env []string, dir, name string, args return nil } +// InteractiveRunner runs a command with ALL THREE std streams inherited and NO +// capture, for an interactive `compose exec` (a login shell into a container). +// Unlike ExecRunner it wires Stdin=os.Stdin (so the shell receives input) and +// does not tee stderr into a buffer (the child owns the terminal). The child's +// exit code is propagated verbatim via the returned *exec.ExitError, so callers +// can mirror it as the process exit code (spec 26 `shell`). +type InteractiveRunner struct{} + +func (InteractiveRunner) Run(ctx context.Context, env []string, dir, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // Return the raw error (an *exec.ExitError carries the child exit code) so the + // caller can propagate it — do NOT wrap it in CmdError (no captured stderr). + return cmd.Run() +} + +// Output is not meaningful for an interactive runner (the child owns stdout). +func (InteractiveRunner) Output(context.Context, []string, string, string, ...string) ([]byte, error) { + return nil, fmt.Errorf("InteractiveRunner does not support captured Output") +} + func (ExecRunner) Output(ctx context.Context, env []string, dir, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir @@ -128,6 +153,26 @@ func (c *Compose) Stop(ctx context.Context, services ...string) error { return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) } +// Exec runs `docker compose exec` for a service, letting compose resolve the +// container from the project + service (no manual SDK enumeration). When +// interactive it requests a real TTY (-it) and the caller MUST supply an +// InteractiveRunner (stdin-wired, non-capturing) so the shell doesn't hang; +// otherwise it passes -T (no TTY allocation) so a non-interactive caller never +// blocks on a missing terminal. cmd is the command + args to run in the container +// (empty → the image default). Returns the child's error verbatim (an +// *exec.ExitError carries its exit code) so `shell` can mirror it (spec 26). +func (c *Compose) Exec(ctx context.Context, service string, interactive bool, cmd ...string) error { + args := append(c.base(), "exec") + if interactive { + args = append(args, "-it") + } else { + args = append(args, "-T") + } + args = append(args, service) + args = append(args, cmd...) + return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...) +} + // Build rebuilds the named services (with --no-cache for the selective-rebuild // contexts the generate ledger flagged). With no services, builds all. func (c *Compose) Build(ctx context.Context, noCache bool, services ...string) error { diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go index 484228f..deb88c5 100644 --- a/internal/docker/compose_test.go +++ b/internal/docker/compose_test.go @@ -74,6 +74,40 @@ func TestComposeBuildNoCache(t *testing.T) { } } +func TestComposeExecInteractive(t *testing.T) { + c, r := newTestCompose() + // A login shell: interactive → -it, then the service, then the command. + if err := c.Exec(context.Background(), "web", true, "bash"); err != nil { + t.Fatal(err) + } + want := "docker compose -p devstack-api -f /ws/.devstack/docker-compose.yaml exec -it web bash" + if got := r.last(); got != want { + t.Errorf("interactive exec = %q, want %q", got, want) + } +} + +func TestComposeExecNonInteractive(t *testing.T) { + c, r := newTestCompose() + // Non-interactive → -T (no TTY) so a piped caller never hangs. + if err := c.Exec(context.Background(), "web", false, "psql", "-c", "select 1"); err != nil { + t.Fatal(err) + } + if got := r.last(); !strings.Contains(got, "exec -T web psql -c select 1") { + t.Errorf("non-interactive exec = %q", got) + } + if strings.Contains(r.last(), "-it") { + t.Errorf("non-interactive exec must not request a TTY: %q", r.last()) + } +} + +func TestInteractiveRunnerOutputUnsupported(t *testing.T) { + // The interactive runner captures nothing — Output must be a clear error, not + // silent empty bytes. + if _, err := (InteractiveRunner{}).Output(context.Background(), nil, "", "docker"); err == nil { + t.Error("InteractiveRunner.Output should return an error") + } +} + func TestCmdErrorMessage(t *testing.T) { e := &CmdError{Cmd: "docker compose up", Stderr: "network not found", Err: context.Canceled} if !strings.Contains(e.Error(), "network not found") || !strings.Contains(e.Error(), "docker compose up") { diff --git a/internal/orchestrate/rebuild_test.go b/internal/orchestrate/rebuild_test.go new file mode 100644 index 0000000..1ef3ee7 --- /dev/null +++ b/internal/orchestrate/rebuild_test.go @@ -0,0 +1,67 @@ +package orchestrate + +import ( + "context" + "slices" + "strings" + "testing" +) + +// projectBuild reports whether a `compose build` (optionally --no-cache) ran for +// the project stack. It matches on exact argv tokens (not substrings) so a temp +// path containing "build" (e.g. the test name) can't produce a false positive. +func projectBuild(fr *fakeRunner, project string) (built, noCache bool) { + for _, cmd := range fr.cmds { + joined := strings.Join(cmd, " ") + if !strings.Contains(joined, "-p "+project) { + continue + } + if slices.Contains(cmd, "build") { + built = true + if slices.Contains(cmd, "--no-cache") { + noCache = true + } + } + } + return built, noCache +} + +// TestRebuildForcesNoCache asserts the spec-26 --rebuild threading: UpDeps.Rebuild +// makes the compose-up phase run `compose build --no-cache`, whereas plain --build +// builds without --no-cache and no build flag builds not at all. +func TestRebuildForcesNoCache(t *testing.T) { + cases := []struct { + name string + build bool + rebuild bool + wantBuild bool + wantNoCache bool + }{ + {"neither", false, false, false, false}, + {"build only", true, false, true, false}, + {"rebuild forces no-cache", false, true, true, true}, + {"both", true, true, true, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d, fr, db := upFixture(t) + d.Build = c.build + d.Rebuild = c.rebuild + 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) + } + built, noCache := projectBuild(fr, "devstack-app") + if built != c.wantBuild { + t.Errorf("built = %v, want %v (cmds: %v)", built, c.wantBuild, fr.cmds) + } + if noCache != c.wantNoCache { + t.Errorf("noCache = %v, want %v (cmds: %v)", noCache, c.wantNoCache, fr.cmds) + } + }) + } +} diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index d324eab..1b3af9b 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -60,7 +60,9 @@ type UpDeps struct { // the pgx-backed default. Injected for tests (so provisioning runs daemon-free). PgConnect PgConnector - Build bool // compose up --build + Build bool // compose up --build (honors the generate ledger's selective-rebuild hash) + Rebuild bool // force `compose build --no-cache` before up (spec 26 --rebuild) + SkipClone bool // skip the clone/sync phase for repos already on disk (spec 26 --skip-clone) NoHooks bool // skip the hooks phase NoPreflight bool // skip the preflight phase (fast inner loops) NoProvision bool // skip the per-project Postgres provision phase @@ -458,8 +460,10 @@ func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string, ser }, Run: func(ctx context.Context) (any, error) { c := cp() - if d.Build { - if err := c.Build(ctx, false, services...); err != nil { + // --rebuild forces a no-cache build (ignoring the selective-rebuild hash); + // --build does a normal build. --rebuild implies a build even without --build. + if d.Build || d.Rebuild { + if err := c.Build(ctx, d.Rebuild, services...); err != nil { return nil, err } } diff --git a/internal/selfupdate/force_test.go b/internal/selfupdate/force_test.go new file mode 100644 index 0000000..96ce24b --- /dev/null +++ b/internal/selfupdate/force_test.go @@ -0,0 +1,28 @@ +package selfupdate + +import "testing" + +func TestUpToDateForceSemantics(t *testing.T) { + cases := []struct { + name string + current string + tag string + opts Options + want bool + }{ + {"same version → up to date", "v0.2.0", "v0.2.0", Options{}, true}, + {"older → not up to date", "v0.1.0", "v0.2.0", Options{}, false}, + {"ahead → up to date", "v0.3.0", "v0.2.0", Options{}, true}, + {"force over same → re-install", "v0.2.0", "v0.2.0", Options{Force: true}, false}, + {"force over ahead → re-install", "v0.3.0", "v0.2.0", Options{Force: true}, false}, + {"pinned version defeats short-circuit", "v0.2.0", "v0.2.0", Options{Version: "v0.2.0"}, false}, + {"dev build never up to date", "v0.1.0-5-gabc-dirty", "v0.2.0", Options{}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := upToDate(c.current, c.tag, c.opts); got != c.want { + t.Errorf("upToDate(%q,%q,%+v) = %v, want %v", c.current, c.tag, c.opts, got, c.want) + } + }) + } +} diff --git a/internal/selfupdate/update.go b/internal/selfupdate/update.go index 914eeef..0912701 100644 --- a/internal/selfupdate/update.go +++ b/internal/selfupdate/update.go @@ -20,6 +20,8 @@ import ( // Options tune an Update. type Options struct { Version string // pin a release tag (e.g. "v0.2.0"); "" = latest + Force bool // re-install even when already up-to-date (repair a corrupt binary); + // NEVER overrides the package-manager CanSelfReplace refusal (spec 26/14). } // Result reports what Update did (or why it refused). @@ -55,8 +57,9 @@ func Update(ctx context.Context, current string, opts Options) (*Result, error) res.To = tag // Nothing to do when already on (or ahead of) the latest, unless a specific - // version was pinned or this is an uncomparable dev build. - if opts.Version == "" && !IsDevBuild(current) && semver.IsValid(tag) && semver.Compare(tag, current) <= 0 { + // version was pinned, this is an uncomparable dev build, or --force asks to + // re-install over an up-to-date binary (repair a corrupt/partial install). + if upToDate(current, tag, opts) { res.UpToDate = true return res, nil } @@ -72,6 +75,21 @@ func Update(ctx context.Context, current string, opts Options) (*Result, error) return res, nil } +// upToDate decides whether Update should short-circuit as a no-op: the running +// binary is already on (or ahead of) the resolved tag. --force, a pinned +// --version, and an uncomparable dev build all defeat the short-circuit so the +// resolved release is (re-)installed. --force NEVER bypasses the earlier +// CanSelfReplace refusal for package-managed installs (that check runs first). +func upToDate(current, tag string, opts Options) bool { + if opts.Force || opts.Version != "" { + return false + } + if IsDevBuild(current) { + return false + } + return semver.IsValid(tag) && semver.Compare(tag, current) <= 0 +} + // assetName is the release archive for an os/arch (goreleaser strips the leading // v from the version field). func assetName(tag, goos, goarch string) string { diff --git a/internal/state/migrations.go b/internal/state/migrations.go index 4fb8e9d..fda7287 100644 --- a/internal/state/migrations.go +++ b/internal/state/migrations.go @@ -14,6 +14,7 @@ type migration struct { var migrations = []migration{ {version: 1, stmt: schemaV1}, {version: 2, stmt: schemaV2}, + {version: 3, stmt: schemaV3}, } // schemaV1 is the initial ledger (spec 08 §Tables). Every mutable row is scoped @@ -114,6 +115,25 @@ CREATE TABLE IF NOT EXISTS saga_phase ( ); ` +// schemaV3 (spec 26) adds the thin machine-wide workspace registry: a pointer +// table mapping each known workspace root to its name, written on `up` so +// `workspace list` can enumerate every workspace for the current Docker context. +// It is a POINTER only — projects/refs are re-derived from the committed +// workspace.yaml at list time, never denormalized here. Keyed by (ctx, root) so +// WSL2's Desktop-vs-dockerd rows never collide, and CASCADE-deleted with its +// docker_context like every other ledger table. +const schemaV3 = ` +CREATE TABLE IF NOT EXISTS workspace ( + ctx TEXT NOT NULL, + name TEXT NOT NULL, + root TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_up_at TEXT, + PRIMARY KEY (ctx, root), + FOREIGN KEY (ctx) REFERENCES docker_context(name) ON DELETE CASCADE +); +` + // migrate applies any pending migrations inside a transaction per step, backing // up the DB file before the first mutating step. Forward-only. func (db *DB) migrate() error { diff --git a/internal/state/saga_test.go b/internal/state/saga_test.go index c9dd82a..6c0c6b1 100644 --- a/internal/state/saga_test.go +++ b/internal/state/saga_test.go @@ -78,13 +78,13 @@ func TestSagaPhasesForAndClear(t *testing.T) { } } -func TestSchemaVersionIsTwo(t *testing.T) { +func TestSchemaVersionIsCurrent(t *testing.T) { db := openTestDB(t) v, err := db.SchemaVersion() if err != nil { t.Fatal(err) } - if v != 2 { - t.Errorf("schema version = %d, want 2 (saga_phase migration applied)", v) + if v != len(migrations) { + t.Errorf("schema version = %d, want %d (all migrations applied)", v, len(migrations)) } } diff --git a/internal/state/workspace.go b/internal/state/workspace.go new file mode 100644 index 0000000..cce0521 --- /dev/null +++ b/internal/state/workspace.go @@ -0,0 +1,74 @@ +package state + +import ( + "database/sql" + "fmt" +) + +// This file is the spec-26 workspace registry: a thin machine-wide pointer table +// mapping each known workspace root to its name + lifecycle timestamps. It is the +// ONLY ledger concept that knows where a workspace root lives on disk; everything +// else (projects, refs) is re-derived from the committed workspace.yaml at list +// time. Every row is scoped to the active Docker context (db.Ctx). +// +// RecordWorkspace mutates and MUST run while holding the machine-global flock. +// ListWorkspaces is a lock-free snapshot. + +// Workspace is one registry row: a workspace root and its declared name. +type Workspace struct { + Name string // workspace.yaml `name` at the time of the last `up` + Root string // absolute path to the workspace root (where workspace.yaml lives) + CreatedAt string + LastUpAt string // refreshed on each successful `up`; "" if never +} + +// RecordWorkspace upserts the (ctx, root) pointer row, refreshing the name and +// stamping last_up_at. Called on `up` under the flock (Q-WS-REGISTER-WHEN). The +// PK is (ctx, root): a moved checkout becomes a new row, the old one stays until +// `workspace list --prune` reaps it. Mutating — hold the lock. +func (db *DB) RecordWorkspace(name, root string) error { + _, err := db.Exec(` + INSERT INTO workspace (ctx, name, root, last_up_at) + VALUES (?,?,?,datetime('now')) + ON CONFLICT(ctx, root) + DO UPDATE SET name=excluded.name, last_up_at=datetime('now')`, + db.Ctx, name, root) + if err != nil { + return fmt.Errorf("record workspace %s (%s): %w", name, root, err) + } + return nil +} + +// ListWorkspaces returns every registered workspace for this context, ordered by +// name then root for deterministic output. Lock-free. +func (db *DB) ListWorkspaces() ([]Workspace, error) { + rows, err := db.Query(`SELECT name, root, created_at, last_up_at + FROM workspace WHERE ctx=? ORDER BY name, root`, db.Ctx) + if err != nil { + return nil, fmt.Errorf("list workspaces: %w", err) + } + defer rows.Close() + var out []Workspace + for rows.Next() { + var w Workspace + var lastUp sql.NullString + if err := rows.Scan(&w.Name, &w.Root, &w.CreatedAt, &lastUp); err != nil { + return nil, err + } + w.LastUpAt = lastUp.String + out = append(out, w) + } + return out, rows.Err() +} + +// RemoveWorkspace drops the registry row for a root (the `--prune` path: a root +// that vanished from disk). Returns whether a row was removed. Mutating — hold +// the lock. +func (db *DB) RemoveWorkspace(root string) (bool, error) { + res, err := db.Exec(`DELETE FROM workspace WHERE ctx=? AND root=?`, db.Ctx, root) + if err != nil { + return false, fmt.Errorf("remove workspace %s: %w", root, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} diff --git a/internal/state/workspace_test.go b/internal/state/workspace_test.go new file mode 100644 index 0000000..6cb14ea --- /dev/null +++ b/internal/state/workspace_test.go @@ -0,0 +1,120 @@ +package state + +import ( + "context" + "testing" +) + +func TestWorkspaceRegistryCRUD(t *testing.T) { + db := openTestDB(t) + + // Record two workspaces; RecordWorkspace is an upsert keyed by (ctx, root). + if err := db.RecordWorkspace("acme", "/src/acme"); err != nil { + t.Fatalf("record acme: %v", err) + } + if err := db.RecordWorkspace("demo", "/play/demo"); err != nil { + t.Fatalf("record demo: %v", err) + } + + ws, err := db.ListWorkspaces() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(ws) != 2 { + t.Fatalf("len = %d, want 2 (%v)", len(ws), ws) + } + // Ordered by name: acme before demo. + if ws[0].Name != "acme" || ws[0].Root != "/src/acme" { + t.Errorf("row 0 = %+v, want acme/src/acme", ws[0]) + } + if ws[0].LastUpAt == "" { + t.Error("last_up_at should be stamped on record") + } + + // Upsert the same root with a new name: still one row, name refreshed. + firstUp := ws[0].LastUpAt + if err := db.RecordWorkspace("acme-renamed", "/src/acme"); err != nil { + t.Fatalf("re-record acme: %v", err) + } + ws, _ = db.ListWorkspaces() + if len(ws) != 2 { + t.Fatalf("after upsert len = %d, want 2", len(ws)) + } + // The row for /src/acme now carries the new name (it may sort after demo now). + var found *Workspace + for i := range ws { + if ws[i].Root == "/src/acme" { + found = &ws[i] + } + } + if found == nil || found.Name != "acme-renamed" { + t.Fatalf("upsert did not refresh name: %+v", found) + } + _ = firstUp // last_up_at is refreshed too (datetime granularity may match) + + // Remove a vanished root. + removed, err := db.RemoveWorkspace("/play/demo") + if err != nil { + t.Fatalf("remove: %v", err) + } + if !removed { + t.Error("RemoveWorkspace should report a row was removed") + } + // Removing again is a no-op (idempotent). + removed, _ = db.RemoveWorkspace("/play/demo") + if removed { + t.Error("second remove should report no row removed") + } + + ws, _ = db.ListWorkspaces() + if len(ws) != 1 || ws[0].Root != "/src/acme" { + t.Fatalf("after remove = %v, want just /src/acme", ws) + } +} + +func TestWorkspaceRegistryCascadesWithContext(t *testing.T) { + db := openTestDB(t) // context "ctx" + if err := db.RecordWorkspace("acme", "/src/acme"); err != nil { + t.Fatal(err) + } + // Deleting the docker_context row must CASCADE-remove its workspace rows. + if _, err := db.Exec(`DELETE FROM docker_context WHERE name=?`, db.Ctx); err != nil { + t.Fatalf("delete context: %v", err) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM workspace`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("workspace rows after context delete = %d, want 0 (CASCADE)", n) + } +} + +func TestWorkspaceRegistryScopedByContext(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + dbA, err := Open(ctx, dir, "ctxA") + if err != nil { + t.Fatal(err) + } + defer dbA.Close() + if err := dbA.RecordWorkspace("a", "/root/a"); err != nil { + t.Fatal(err) + } + + // A second context over the SAME ledger file must not see ctxA's row (WSL2 + // Desktop-vs-dockerd isolation). + dbB, err := Open(ctx, dir, "ctxB") + if err != nil { + t.Fatal(err) + } + defer dbB.Close() + ws, err := dbB.ListWorkspaces() + if err != nil { + t.Fatal(err) + } + if len(ws) != 0 { + t.Fatalf("ctxB sees %d rows, want 0 (context isolation)", len(ws)) + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 638cf46..1ac154f 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -77,6 +77,72 @@ func (t *Tunnel) Run(ctx context.Context, configPath string) error { return t.exec(ctx, "tunnel", "--config", configPath, "run") } +// ContainerName is the managed cloudflared container `tunnel up` runs (spec 05). +// A single well-known name makes `tunnel down` a stateless `docker rm -f`. +const ContainerName = "devstack-tunnel" + +// DefaultImage is the cloudflared image the managed tunnel container runs. +const DefaultImage = "cloudflare/cloudflared:latest" + +// UpOptions parameterize bringing the managed tunnel container up. +type UpOptions struct { + Name string // tunnel name (from `tunnel create`) + ConfigPath string // host path to the rendered ingress config.yml + CredsDir string // host dir holding cert.pem + .json (default ~/.cloudflared) + Network string // shared Docker network the container joins (reaches the proxy) + Image string // override the cloudflared image + Detach bool // run detached (default true for a managed container) +} + +// Up runs the managed cloudflared container against the shared stack, mounting the +// rendered ingress config + the user's cloudflared credentials. It is the single +// code path a saga-wired tunnel would share (the secret:// refusal guard lives in +// the caller, spec 05). Reversible: `Down` removes the container, leaving +// credentials/routes intact. Requires the `docker` binary on PATH. +func (t *Tunnel) Up(ctx context.Context, opts UpOptions) error { + if _, err := t.runner().LookPath("docker"); err != nil { + return fmt.Errorf("docker not found on PATH — required to run the managed tunnel container") + } + image := opts.Image + if image == "" { + image = DefaultImage + } + args := []string{"run", "--name", ContainerName, "--rm=false"} + if opts.Detach { + args = append(args, "-d") + } + if opts.Network != "" { + args = append(args, "--network", opts.Network) + } + if opts.CredsDir != "" { + args = append(args, "-v", opts.CredsDir+":/home/nonroot/.cloudflared:ro") + } + if opts.ConfigPath != "" { + args = append(args, "-v", opts.ConfigPath+":/etc/cloudflared/config.yml:ro") + } + args = append(args, image, "tunnel", "--config", "/etc/cloudflared/config.yml", "run") + if opts.Name != "" { + args = append(args, opts.Name) + } + if err := t.runner().Run(ctx, "docker", args...); err != nil { + return fmt.Errorf("start managed tunnel container: %w", err) + } + return nil +} + +// Down stops and removes the managed tunnel container. Credentials and DNS routes +// are left intact (reversible — `Up` brings it back). A missing container is not +// an error (idempotent teardown). +func (t *Tunnel) Down(ctx context.Context) error { + if _, err := t.runner().LookPath("docker"); err != nil { + return fmt.Errorf("docker not found on PATH — required to stop the managed tunnel container") + } + // `rm -f` both stops and removes; ignore a "no such container" outcome so + // `tunnel down` is idempotent. + _ = t.runner().Run(ctx, "docker", "rm", "-f", ContainerName) + return nil +} + func (t *Tunnel) exec(ctx context.Context, args ...string) error { if !t.Available() { return fmt.Errorf("cloudflared not found on PATH — install it (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and run `devstack tunnel login`") diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go index 334cbe4..038aa4f 100644 --- a/internal/tunnel/tunnel_test.go +++ b/internal/tunnel/tunnel_test.go @@ -126,3 +126,46 @@ func TestSecretBearing(t *testing.T) { t.Errorf("no secrets → empty") } } + +func TestUpBuildsManagedContainer(t *testing.T) { + fr := have("docker") + tr := &Tunnel{Runner: fr} + err := tr.Up(context.Background(), UpOptions{ + Name: "shop", ConfigPath: "/ws/.devstack/tunnel/config.yml", + CredsDir: "/home/u/.cloudflared", Network: "devstack_shared", Detach: true, + }) + if err != nil { + t.Fatalf("up: %v", err) + } + call := fr.calls[len(fr.calls)-1] + for _, want := range []string{ + "docker run", "--name devstack-tunnel", "-d", + "--network devstack_shared", + "/home/u/.cloudflared:/home/nonroot/.cloudflared:ro", + "/ws/.devstack/tunnel/config.yml:/etc/cloudflared/config.yml:ro", + "tunnel --config /etc/cloudflared/config.yml run shop", + } { + if !strings.Contains(call, want) { + t.Errorf("up call missing %q:\n%s", want, call) + } + } +} + +func TestUpRequiresDocker(t *testing.T) { + tr := &Tunnel{Runner: have()} // no docker + if err := tr.Up(context.Background(), UpOptions{Name: "shop"}); err == nil { + t.Error("up without docker should error") + } +} + +func TestDownRemovesContainer(t *testing.T) { + fr := have("docker") + tr := &Tunnel{Runner: fr} + if err := tr.Down(context.Background()); err != nil { + t.Fatalf("down: %v", err) + } + call := fr.calls[len(fr.calls)-1] + if !strings.Contains(call, "docker rm -f devstack-tunnel") { + t.Errorf("down should `docker rm -f devstack-tunnel`: %s", call) + } +}