diff --git a/bin/createos b/bin/createos new file mode 100755 index 0000000..13d3bfe Binary files /dev/null and b/bin/createos differ diff --git a/cmd/sandbox/dc.go b/cmd/sandbox/dc.go new file mode 100644 index 0000000..d9bd9b2 --- /dev/null +++ b/cmd/sandbox/dc.go @@ -0,0 +1,54 @@ +package sandbox + +import ( + "github.com/urfave/cli/v2" +) + +// newDCCommand wires up `createos sandbox dc` — Docker Compose against a +// remote devbox sandbox. The mental model: one sandbox = one Docker +// host. We don't translate compose to fc-spawn primitives — we sync your +// project into the VM, run `docker compose` inside it, and forward the +// ports back. +// +// Lifecycle: +// +// dc up → ensure sandbox + sshd + dockerd + Mutagen sync + `docker compose up -d` +// + port-forward every published port +// dc down → destroy the sandbox (use --keep to stop compose only) +// dc ps → list services + ports + health +// dc logs → tail compose logs +// dc exec → docker compose exec into a service +// +// State per project lives in `.createos/dc.lock` next to the compose +// file (sandbox id, ssh key path, port map, Mutagen session id). +func newDCCommand() *cli.Command { + return &cli.Command{ + Name: "dc", + Aliases: []string{"compose"}, + Usage: "Run docker-compose against a remote sandbox (dev loop)", + Description: `Treats a fc-spawn sandbox as a remote Docker host. Reads your +docker-compose.yml, syncs the project directory in, runs +'docker compose up' inside the VM, and forwards published ports back to +your laptop. Edit locally, Mutagen mirrors changes, containers pick them +up natively. 'sb dc pause'/'resume' freeze the whole stack to R2. + +Bind mounts (./src:/app) work — they reference the synced project copy +inside the VM. For stateful services (postgres, redis, mysql) use named +docker volumes instead so the data stays in the VM and out of your +laptop's sync loop. + +Subcommands: + up Bring the stack up + down Destroy the sandbox (or just 'docker compose down' with --keep) + ps List services and forwarded ports + logs Tail compose logs + exec Run a command inside a service container`, + Subcommands: []*cli.Command{ + newDCUpCommand(), + newDCDownCommand(), + newDCPsCommand(), + newDCLogsCommand(), + newDCExecCommand(), + }, + } +} diff --git a/cmd/sandbox/dc_down.go b/cmd/sandbox/dc_down.go new file mode 100644 index 0000000..102bc70 --- /dev/null +++ b/cmd/sandbox/dc_down.go @@ -0,0 +1,185 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/dclock" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newDCDownCommand wires `createos sandbox dc down`. +// +// Default: stop compose (best-effort) + destroy the sandbox + remove +// the lockfile. The destroy makes the sandbox unrecoverable, so we +// confirm on a TTY unless --yes is passed. +// +// --keep skips the destroy: we run `docker compose down` inside the VM +// and leave the sandbox running. Useful when you want to teardown the +// stack but reuse the same VM (saves the next 'up' from waiting on a +// fresh sandbox boot + image pull). +// +// --volumes passes `-v` to `docker compose down` so named volumes are +// also removed. Without it, postgres / redis / etc. data persists in +// the VM and is picked up by the next 'up'. +func newDCDownCommand() *cli.Command { + return &cli.Command{ + Name: "down", + Usage: "Tear down the compose stack (and the sandbox unless --keep)", + Description: `By default destroys the sandbox entirely — the fastest path back to +zero cost. Pass --keep to only stop compose and leave the sandbox +running. + +Examples: + createos sb dc down + createos sb dc down --keep + createos sb dc down -v # also remove named docker volumes + createos sb dc down --yes # skip the confirmation prompt`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)", + Value: "docker-compose.yml", + }, + &cli.BoolFlag{ + Name: "keep", + Usage: "Stop compose only; keep the sandbox running", + }, + &cli.BoolFlag{ + Name: "volumes", + Aliases: []string{"v"}, + Usage: "Pass -v to 'docker compose down' (remove named volumes)", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip the confirmation prompt (required in non-interactive mode for destroy)", + }, + }, + Action: runDCDown, + } +} + +func runDCDown(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + projectDir, lock, err := loadDCProject(c.String("file")) + if err != nil { + return err + } + + // Confirm on a TTY when about to destroy. --keep doesn't need this: + // stopping compose is recoverable. + if !c.Bool("keep") { + if !c.Bool("yes") { + if !terminal.IsInteractive() { + return fmt.Errorf("non-interactive mode: pass --yes to confirm destroying sandbox %s", lock.SandboxID) + } + ok, perr := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Destroy sandbox %s? This deletes its disk and snapshot.", lock.SandboxID)). + WithDefaultValue(false). + Show() + if perr != nil { + return fmt.Errorf("could not read confirmation: %w", perr) + } + if !ok { + fmt.Println("Cancelled. Nothing was destroyed.") + return nil + } + } + } + + // 1. Best-effort `docker compose down` so named volumes survive a + // 'down --keep' cycle cleanly. Skipped if the sandbox is no + // longer running — there's nothing to gracefully stop. + if err := composeDown(c.Context, client, lock, c.Bool("volumes")); err != nil { + pterm.Warning.Println("compose down failed (continuing): " + err.Error()) + } else { + pterm.Success.Println("Compose stack stopped.") + } + + // 1b. Terminate any active mutagen sync session. Best-effort: if + // mutagen isn't installed (e.g. someone deleted ~/.createos), + // just drop the lockfile entry and move on. + if lock.Sync != nil && lock.Sync.SessionName != "" { + if err := terminateDCSync(c.Context, lock); err != nil { + pterm.Warning.Println("terminate sync session failed (continuing): " + err.Error()) + } else { + pterm.Success.Println("Sync session terminated.") + } + lock.Sync = nil + } + + // 2. --keep stops here. + if c.Bool("keep") { + // Clear ports from the lockfile — they're no longer bound, and + // a stale port list would confuse 'dc ps'. Sandbox id stays. + lock.Ports = nil + if err := lock.Save(projectDir); err != nil { + return fmt.Errorf("update lockfile: %w", err) + } + pterm.Info.Println("Sandbox " + lock.SandboxID + " left running.") + return nil + } + + // 3. Destroy the sandbox. + if err := client.DestroySandbox(c.Context, lock.SandboxID); err != nil { + // Treat "not found" as already-destroyed — clean up the lockfile + // either way so the user isn't stuck. + pterm.Warning.Println("destroy failed (will remove lockfile anyway): " + err.Error()) + } else { + pterm.Success.Println("Sandbox " + lock.SandboxID + " destroyed.") + } + + // 4. Remove the lockfile so the next 'up' creates a fresh sandbox. + if err := dclock.Remove(projectDir); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove lockfile: %w", err) + } + return nil +} + +// composeDown runs `docker compose down [-v]` inside the sandbox via +// the exec stream so the user sees the per-container stop messages +// in real time. Soft-fails when the sandbox isn't running so 'dc down' +// still completes its destroy step. +func composeDown(ctx context.Context, client *api.SandboxClient, lock *dclock.Lock, removeVolumes bool) error { + args := []string{ + "compose", + "-p", lock.ProjectName, + "-f", lock.ComposeFile, + "down", + } + if removeVolumes { + args = append(args, "-v") + } + exit, err := client.ExecSandboxStream(ctx, lock.SandboxID, api.SandboxExecReq{ + Cmd: "docker", + Args: args, + }, func(ev api.SandboxExecStreamEvent) { + switch { + case ev.Stdout != "": + _, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck + case ev.Stderr != "": + _, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck + case ev.Error != "": + pterm.Error.Println(ev.Error) + } + }) + if err != nil { + return err + } + if exit != 0 { + return fmt.Errorf("docker compose down exited %d", exit) + } + return nil +} diff --git a/cmd/sandbox/dc_exec.go b/cmd/sandbox/dc_exec.go new file mode 100644 index 0000000..8bc88a8 --- /dev/null +++ b/cmd/sandbox/dc_exec.go @@ -0,0 +1,173 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + "golang.org/x/term" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +// newDCExecCommand wires `createos sandbox dc exec`. +// +// Wraps the user's command in `docker compose exec -T ` +// and runs it through the existing exec stream API. -T is always set — +// the exec stream isn't a real PTY, and 'docker compose exec' refuses +// to attach to one over a non-TTY pipe. +// +// For a real interactive shell (vim, htop, psql prompt) use +// `createos sandbox shell ` to land in the VM, then +// `docker compose exec ` inside. +func newDCExecCommand() *cli.Command { + return &cli.Command{ + Name: "exec", + Usage: "Run a command inside one of the compose service containers", + ArgsUsage: " -- [args…]", + Description: `Wraps the inner command in 'docker compose exec -T ...' +inside the sandbox so it runs in the right container. Streams stdout +and stderr live. Exit code is preserved. + +For an interactive PTY (psql prompt, vim, htop) use: + createos sb shell + # then inside the VM: + docker compose -p exec bash + +Examples: + createos sb dc exec db -- psql -U dev -d app -c 'SELECT 42' + createos sb dc exec web -- ls /etc/nginx + echo 'SELECT now()' | createos sb dc exec db -- psql -U dev -d app`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)", + Value: "docker-compose.yml", + }, + &cli.StringFlag{ + Name: "user", + Aliases: []string{"u"}, + Usage: "Run as this uid:gid inside the container", + }, + &cli.StringFlag{ + Name: "workdir", + Aliases: []string{"w"}, + Usage: "Working directory inside the container", + }, + &cli.StringSliceFlag{ + Name: "env", + Aliases: []string{"e"}, + Usage: "Set an environment variable (repeatable): KEY=VALUE", + }, + }, + Action: runDCExec, + } +} + +func runDCExec(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + _, lock, err := loadDCProject(c.String("file")) + if err != nil { + return err + } + + // Positional args: [--] [args...] + // Mirror cmd/sandbox/exec.go's tolerant handling — accept either + // the explicit '--' separator or just trailing tokens. + service, innerCmd, innerArgs, err := splitDCExecArgs(c) + if err != nil { + return err + } + + // Build: docker compose -p -f exec -T [-u U] [-w W] [-e K=V…] + composeArgs := []string{ + "compose", + "-p", lock.ProjectName, + "-f", lock.ComposeFile, + "exec", + "-T", + } + if v := strings.TrimSpace(c.String("user")); v != "" { + composeArgs = append(composeArgs, "-u", v) + } + if v := strings.TrimSpace(c.String("workdir")); v != "" { + composeArgs = append(composeArgs, "-w", v) + } + for _, env := range c.StringSlice("env") { + env = strings.TrimSpace(env) + if env == "" { + continue + } + composeArgs = append(composeArgs, "-e", env) + } + composeArgs = append(composeArgs, service, innerCmd) + composeArgs = append(composeArgs, innerArgs...) + + // Forward piped stdin (one-shot — the API takes the whole buffer as a + // string). When stdin is a real TTY we leave it empty so the inner + // command's stdin stays bound to /dev/null inside the container. + req := api.SandboxExecReq{ + Cmd: "docker", + Args: composeArgs, + } + if !term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 -- fd fits in int + buf, rerr := io.ReadAll(os.Stdin) + if rerr != nil { + return fmt.Errorf("read stdin: %w", rerr) + } + req.Stdin = string(buf) + } + + exit, err := client.ExecSandboxStream(c.Context, lock.SandboxID, req, func(ev api.SandboxExecStreamEvent) { + switch { + case ev.Stdout != "": + _, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck + case ev.Stderr != "": + _, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck + case ev.Error != "": + pterm.Error.Println(ev.Error) + } + }) + if err != nil { + return err + } + if exit != 0 { + os.Exit(exit) + } + return nil +} + +// splitDCExecArgs reads the positional arguments and pulls out +// (service, cmd, args). Accepts both: +// +// createos sb dc exec web -- ls -la +// createos sb dc exec web ls -la +// +// Returns a friendly error when service or cmd is missing. +func splitDCExecArgs(c *cli.Context) (service, cmd string, args []string, err error) { + all := c.Args().Slice() + if len(all) == 0 { + return "", "", nil, fmt.Errorf("missing and command\n\n Example:\n createos sb dc exec web -- ls /etc/nginx") + } + service = strings.TrimSpace(all[0]) + rest := all[1:] + // Drop an optional '--' between service and cmd. + if len(rest) > 0 && rest[0] == "--" { + rest = rest[1:] + } + if len(rest) == 0 { + return "", "", nil, fmt.Errorf("missing command to run inside %s\n\n Example:\n createos sb dc exec %s -- ls /etc/nginx", service, service) + } + cmd = rest[0] + if len(rest) > 1 { + args = rest[1:] + } + return service, cmd, args, nil +} diff --git a/cmd/sandbox/dc_logs.go b/cmd/sandbox/dc_logs.go new file mode 100644 index 0000000..b4b8bed --- /dev/null +++ b/cmd/sandbox/dc_logs.go @@ -0,0 +1,154 @@ +package sandbox + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/dclock" +) + +// newDCLogsCommand wires `createos sandbox dc logs`. +// +// Streams `docker compose logs -f` from inside the sandbox over the +// existing /v1/sandboxes/:id/exec?stream=true API. NDJSON frames land +// in the callback and we copy stdout/stderr through as they arrive. +// Ctrl+C cancels the underlying HTTP context which terminates the +// remote process. +func newDCLogsCommand() *cli.Command { + return &cli.Command{ + Name: "logs", + Usage: "Tail logs from one or more compose services", + ArgsUsage: "[service...]", + Description: `Streams logs from compose services running inside the sandbox. +Omitting service names tails every service. Ctrl+C to stop. + +Examples: + createos sb dc logs + createos sb dc logs web + createos sb dc logs --tail 200 web db + createos sb dc logs -f docker-compose.dev.yml worker`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)", + Value: "docker-compose.yml", + }, + &cli.BoolFlag{ + Name: "follow", + Usage: "Follow log output (default true; pass --follow=false for a one-shot dump)", + Value: true, + }, + &cli.IntFlag{ + Name: "tail", + Usage: "Number of lines to show from the end of the logs per service", + Value: 100, + }, + &cli.BoolFlag{ + Name: "timestamps", + Aliases: []string{"t"}, + Usage: "Show timestamps", + }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable color in compose output", + }, + }, + Action: runDCLogs, + } +} + +func runDCLogs(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + projectDir, lock, err := loadDCProject(c.String("file")) + if err != nil { + return err + } + _ = projectDir // not yet used here; will be by 'up' + + args := []string{ + "compose", + "-f", lock.ComposeFile, + "-p", lock.ProjectName, + "logs", + } + if c.Bool("follow") { + args = append(args, "--follow") + } + if c.Int("tail") > 0 { + args = append(args, "--tail", strconv.Itoa(c.Int("tail"))) + } + if c.Bool("timestamps") { + args = append(args, "--timestamps") + } + if c.Bool("no-color") { + args = append(args, "--no-color") + } + // Trailing positionals = service names to scope the tail to. + args = append(args, c.Args().Slice()...) + + // Note: compose's `-f ` resolves relative paths in + // the file (./src, ./pgdata, ...) against the compose file's dir, + // not the process cwd — so we don't need to chdir into RemoteWorkdir + // here. RemoteWorkdir is kept in the lockfile for `dc exec`'s use + // (which often does want a real working directory). + req := api.SandboxExecReq{ + Cmd: "docker", + Args: args, + } + + exit, err := client.ExecSandboxStream(c.Context, lock.SandboxID, req, func(ev api.SandboxExecStreamEvent) { + switch { + case ev.Stdout != "": + _, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck + case ev.Stderr != "": + _, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck + case ev.Error != "": + pterm.Error.Println(ev.Error) + } + }) + if err != nil { + return err + } + if exit > 0 { + os.Exit(exit) + } + return nil +} + +// loadDCProject resolves the compose file path relative to cwd, then +// loads the per-project lockfile from /.createos/dc.lock. +// projectDir is the directory CONTAINING the compose file. +// +// Errors translate ErrNotFound into a friendly hint so users see +// "run 'createos sb dc up' first" instead of a raw filesystem error. +func loadDCProject(composeFlag string) (projectDir string, lock *dclock.Lock, err error) { + if composeFlag == "" { + composeFlag = "docker-compose.yml" + } + abs, err := filepath.Abs(composeFlag) + if err != nil { + return "", nil, fmt.Errorf("resolve %s: %w", composeFlag, err) + } + projectDir = filepath.Dir(abs) + lock, err = dclock.Load(projectDir) + if errors.Is(err, dclock.ErrNotFound) { + return "", nil, fmt.Errorf("no compose project here — run 'createos sb dc up' first (looked in %s)", filepath.Join(projectDir, dclock.DirName, dclock.FileName)) + } + if err != nil { + return "", nil, err + } + return projectDir, lock, nil +} diff --git a/cmd/sandbox/dc_ps.go b/cmd/sandbox/dc_ps.go new file mode 100644 index 0000000..f699b6a --- /dev/null +++ b/cmd/sandbox/dc_ps.go @@ -0,0 +1,195 @@ +package sandbox + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/dclock" +) + +// newDCPsCommand wires `createos sandbox dc ps`. +// +// Reads .createos/dc.lock for the sandbox id + locally-bound port map, +// then re-queries `docker compose ps --format json` inside the VM so +// the displayed status (and health) is fresh on every call. +// +// --json dumps the raw compose ps array for scripts. +func newDCPsCommand() *cli.Command { + return &cli.Command{ + Name: "ps", + Usage: "List services running in the sandbox + their local URLs", + Description: `Shows compose services running inside the sandbox plus the +'http://127.0.0.1:PORT' on your laptop that maps to each published +port (when 'dc up' is holding tunnels open in another terminal). + +Examples: + createos sb dc ps + createos sb dc ps --json | jq .`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)", + Value: "docker-compose.yml", + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Emit raw 'docker compose ps' JSON (one object per service)", + }, + }, + Action: runDCPs, + } +} + +// dcPsRow is the subset of `docker compose ps --format json` we render. +// Field names match docker's output verbatim — case matters. +type dcPsRow struct { + Name string `json:"Name"` + Service string `json:"Service"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + Health string `json:"Health,omitempty"` +} + +func runDCPs(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + _, lock, err := loadDCProject(c.String("file")) + if err != nil { + return err + } + raw, err := composePsRaw(c.Context, client, lock) + if err != nil { + return err + } + if c.Bool("json") { + fmt.Println(raw) + return nil + } + rows, err := parseDCPsRows(raw) + if err != nil { + return fmt.Errorf("parse compose ps output: %w (raw: %s)", err, truncate(raw, 200)) + } + renderDCPs(rows, lock.Ports) + return nil +} + +// composePsRaw executes `docker compose ps --format json` in the VM +// and returns its raw stdout — either an NDJSON stream or a single +// JSON array, depending on the plugin version. parseDCPsRows handles +// both shapes. +func composePsRaw(ctx context.Context, client *api.SandboxClient, lock *dclock.Lock) (string, error) { + resp, err := client.ExecSandbox(ctx, lock.SandboxID, api.SandboxExecReq{ + Cmd: "docker", + Args: []string{ + "compose", + "-p", lock.ProjectName, + "-f", lock.ComposeFile, + "ps", + "--format", "json", + "--all", // include stopped containers so the user sees crashes + }, + }) + if err != nil { + return "", err + } + if resp.Result.ExitCode != 0 { + return "", fmt.Errorf("compose ps exit %d: %s", resp.Result.ExitCode, resp.Result.Stderr) + } + return resp.Result.Stdout, nil +} + +func parseDCPsRows(raw string) ([]dcPsRow, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil + } + if strings.HasPrefix(trimmed, "[") { + var rows []dcPsRow + if err := json.Unmarshal([]byte(trimmed), &rows); err != nil { + return nil, err + } + return rows, nil + } + // NDJSON: one JSON object per line. + var rows []dcPsRow + for _, line := range strings.Split(trimmed, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var r dcPsRow + if err := json.Unmarshal([]byte(line), &r); err != nil { + return nil, err + } + rows = append(rows, r) + } + return rows, nil +} + +// renderDCPs prints the compose-ps result as a pterm table. The PORTS +// column synthesises a "http://127.0.0.1:N" URL from the lockfile's +// port map (those are the ports `dc up` opened tunnels for; they're +// only LIVE when a `dc up` is holding foreground in another terminal). +func renderDCPs(rows []dcPsRow, ports []dclock.Port) { + if len(rows) == 0 { + pterm.Info.Println("No services found for this project.") + return + } + portsByService := map[string][]dclock.Port{} + for _, p := range ports { + portsByService[p.Service] = append(portsByService[p.Service], p) + } + + header := []string{"SERVICE", "STATE", "STATUS", "PORTS"} + table := [][]string{header} + for _, r := range rows { + state := r.State + if r.Health != "" { + state = state + " (" + r.Health + ")" + } + table = append(table, []string{ + r.Service, + state, + truncate(r.Status, 40), + formatPorts(portsByService[r.Service]), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + if len(ports) > 0 { + pterm.Println() + pterm.Info.Println("Tunnels are only live while a 'sb dc up' is holding foreground in another terminal.") + } +} + +func formatPorts(ps []dclock.Port) string { + if len(ps) == 0 { + return "" + } + out := make([]string, 0, len(ps)) + for _, p := range ps { + out = append(out, fmt.Sprintf("http://127.0.0.1:%d → :%d", p.LocalPort, p.ContainerPort)) + } + return strings.Join(out, ", ") +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +// (time import kept for potential future use — uptime calculation off +// the State field when compose exposes a StartedAt timestamp.) +var _ = time.Now diff --git a/cmd/sandbox/dc_sync.go b/cmd/sandbox/dc_sync.go new file mode 100644 index 0000000..a178992 --- /dev/null +++ b/cmd/sandbox/dc_sync.go @@ -0,0 +1,373 @@ +package sandbox + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/dclock" + "github.com/NodeOps-app/createos-cli/internal/sshkey" +) + +// dcSyncSession represents one live Mutagen sync session held by a +// `dc up` foreground process. Close() tears down the bridge but +// LEAVES the mutagen session alive — mutagen pauses naturally when the +// SSH transport drops, and the next `dc up` resumes it. +// +// To FULLY destroy the session (terminate in mutagen, drop the +// lockfile sync entry), call terminateDCSync(). +type dcSyncSession struct { + name string + bridge *tunnelBridge + wrapperDir string + wrapperEnv []string + mutagenBin string + tempKeyDir string // tempdir holding the decrypted key (if any) +} + +func (s *dcSyncSession) Close() { + if s == nil { + return + } + if s.bridge != nil { + s.bridge.close() + } + if s.wrapperDir != "" { + _ = os.RemoveAll(s.wrapperDir) //nolint:errcheck + } + if s.tempKeyDir != "" { + _ = os.RemoveAll(s.tempKeyDir) //nolint:errcheck + } +} + +// ensureDCSyncSession sets up the SSH key + sshd + tunnel bridge + +// Mutagen sync session for a project. Idempotent: re-runs detect the +// lockfile's sync entry and resume an existing mutagen session instead +// of recreating it. +// +// On success the bridge is held open inside the returned session — the +// caller must keep the process alive and Close() on shutdown. +func ensureDCSyncSession( + c *cli.Context, + client *api.SandboxClient, + sandboxID, projectName, projectDir, remoteWorkdir string, + lock *dclock.Lock, +) (*dcSyncSession, error) { + // 1. SSH key — explicit, then ~/.ssh defaults, else generate ours. + keyPath := strings.TrimSpace(c.String("identity")) + if keyPath == "" && lock.Sync != nil { + keyPath = lock.Sync.PrivKeyPath // re-use last session's key if available + } + pair, err := sshkey.ResolveOrGenerate(keyPath) + if err != nil { + return nil, fmt.Errorf("ssh key: %w", err) + } + if pair.Managed { + pterm.Info.Println("Using managed SSH key ~/.createos/" + sshkey.ManagedKeyName + " (auto-generated)") + } + + // 2. Decrypt key if passphrase-protected (mutagen can't prompt). + unlocked, cleanup, err := unlockSSHKeyIfNeeded(pair.PrivPath) + if err != nil { + return nil, err + } + tempKeyDir := "" + if unlocked != pair.PrivPath { + tempKeyDir = filepath.Dir(unlocked) + } + defer func() { + // We only call cleanup if we DON'T return the session + // successfully; otherwise the session takes ownership. + _ = cleanup + }() + + pubBytes, err := os.ReadFile(pair.PubPath) // #nosec G304 -- user's own pubkey + if err != nil { + return nil, fmt.Errorf("read pub key %s: %w", pair.PubPath, err) + } + + // 3. Install pubkey in sandbox if needed, start sshd, mkdir target. + prepScript := fmt.Sprintf(` +set -e +if ! [ -x /usr/sbin/sshd ]; then + echo "this image does not ship sshd — sync requires devbox:1" >&2 + exit 100 +fi +mkdir -p /root/.ssh /run/sshd +chmod 700 /root/.ssh +mkdir -p %s +if ! awk 'NR>1{print $2}' /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -qi ':0016$'; then + /usr/sbin/sshd +fi +`, shellQuote(remoteWorkdir)) + if akErr := ensureAuthorizedKey(c, client, sandboxID, "root", projectName, pubBytes, true /* assumeYes */); akErr != nil { + return nil, fmt.Errorf("install ssh pubkey: %w", akErr) + } + if pre, execErr := client.ExecSandbox(c.Context, sandboxID, api.SandboxExecReq{ + Cmd: "sh", + Args: []string{"-c", prepScript}, + }); execErr != nil { + return nil, fmt.Errorf("prep sshd: %w", execErr) + } else if pre.Result.ExitCode == 100 { + return nil, fmt.Errorf("sandbox image doesn't have sshd — use rootfs devbox:1 for sync") + } else if pre.Result.ExitCode != 0 { + return nil, fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(pre.Result.Stderr)) + } + + // 4. Pin a deterministic local port per sandbox so the mutagen + // session URL stays valid across `dc up` re-runs. + localPort := 0 + if lock.Sync != nil && lock.Sync.LocalSSHPort > 0 { + localPort = lock.Sync.LocalSSHPort + } else { + localPort = derivedLocalPort(sandboxID) + } + bridge, err := startTunnelBridgeOn(c.Context, c, sandboxID, 22, localPort) + if err != nil { + // Port collision — fall back to ephemeral so we don't block + // the user, and update the lockfile. + pterm.Warning.Printfln("Pinned local port %d in use; falling back to a fresh port (mutagen session will be recreated)", localPort) + bridge, err = startTunnelBridgeOn(c.Context, c, sandboxID, 22, 0) + if err != nil { + return nil, fmt.Errorf("open ssh tunnel: %w", err) + } + // Force-recreate: the saved session points at the old port. + if lock.Sync != nil { + lock.Sync = nil + } + } + if wtErr := waitForTCP(c.Context, bridge.localAddr, 5*time.Second); wtErr != nil { + bridge.close() + return nil, fmt.Errorf("sshd not listening: %w", wtErr) + } + + // 5. Set up the mutagen wrapper + locate the binary. + mutagenBin, err := ensureMutagen() + if err != nil { + bridge.close() + return nil, err + } + wrapperDir, wrapperEnv, err := makeSSHWrapper(unlocked) + if err != nil { + bridge.close() + return nil, fmt.Errorf("ssh wrapper: %w", err) + } + // Force mutagen daemon restart so it picks up our PATH-shadowed ssh/scp. + _ = runMutagen(c.Context, mutagenBin, wrapperEnv, "daemon", "stop") //nolint:errcheck + + // 6. Session name — deterministic per (project, sandbox) so re-runs + // can find and resume. + sessionName := mutagenSessionName(projectName, sandboxID) + port, perr := splitPort(bridge.localAddr) + if perr != nil { + bridge.close() + return nil, fmt.Errorf("parse bridge addr %q: %w", bridge.localAddr, perr) + } + remoteSpec := fmt.Sprintf("root@127.0.0.1:%s:%s", port, remoteWorkdir) + localPath, _ := filepath.Abs(projectDir) //nolint:errcheck + + // 7. Resume existing or create fresh. + if sessionExists(c.Context, mutagenBin, wrapperEnv, sessionName) { + pterm.Info.Println("Resuming existing sync session " + sessionName) + if err := runMutagen(c.Context, mutagenBin, wrapperEnv, "sync", "resume", sessionName); err != nil { + // Resume can fail if the session is half-broken; recreate. + pterm.Warning.Println("resume failed; recreating session") + _ = runMutagen(c.Context, mutagenBin, wrapperEnv, "sync", "terminate", sessionName) //nolint:errcheck + if err := mutagenCreate(c.Context, mutagenBin, wrapperEnv, sessionName, localPath, remoteSpec); err != nil { + bridge.close() + _ = os.RemoveAll(wrapperDir) //nolint:errcheck + return nil, err + } + } + } else { + pterm.Info.Printfln("Starting sync %s ⇄ sandbox:%s", localPath, remoteWorkdir) + if err := mutagenCreate(c.Context, mutagenBin, wrapperEnv, sessionName, localPath, remoteSpec); err != nil { + bridge.close() + _ = os.RemoveAll(wrapperDir) //nolint:errcheck + return nil, err + } + } + + // 8. Persist session info into the lockfile (caller saves the file). + lock.Sync = &dclock.Sync{ + SessionName: sessionName, + LocalSSHPort: localPortFromAddr(bridge.localAddr), + PrivKeyPath: pair.PrivPath, + } + + // 9. Wait for initial scan to settle so the next `docker compose up` + // sees the user's files. + if err := waitForSyncReady(c.Context, mutagenBin, wrapperEnv, sessionName, 60*time.Second); err != nil { + pterm.Warning.Println("initial sync didn't reach steady state in time: " + err.Error()) + // Non-fatal: compose may still work if compose file is present. + } else { + pterm.Success.Println("Sync ready") + } + + return &dcSyncSession{ + name: sessionName, + bridge: bridge, + wrapperDir: wrapperDir, + wrapperEnv: wrapperEnv, + mutagenBin: mutagenBin, + tempKeyDir: tempKeyDir, + }, nil +} + +// terminateDCSync stops + removes the sync session permanently. Used +// by `dc down` before destroying the sandbox. +func terminateDCSync(ctx context.Context, lock *dclock.Lock) error { + if lock.Sync == nil || lock.Sync.SessionName == "" { + return nil + } + bin, err := ensureMutagen() + if err != nil { + // Without the binary we can't talk to the daemon; best-effort. + return nil //nolint:nilerr + } + // Use minimal env — the daemon should already know about the session. + if err := runMutagen(ctx, bin, os.Environ(), "sync", "terminate", lock.Sync.SessionName); err != nil { + return fmt.Errorf("mutagen terminate %s: %w", lock.Sync.SessionName, err) + } + return nil +} + +// mutagenCreate wraps `mutagen sync create --name= --ignore-vcs + +// our default ignores`. +func mutagenCreate(ctx context.Context, bin string, env []string, name, local, remoteSpec string) error { + args := []string{ + "sync", "create", + "--name=" + name, + "--ignore-vcs", + "--ignore=.createos/", // never sync our own lockfile + "--ignore=node_modules/", + "--ignore=__pycache__/", + "--ignore=.venv/", + "--ignore=target/", + "--ignore=dist/", + "--ignore=build/", + // Force readable perms on the VM side. Without this mutagen + // preserves source perms (often 0700 from `mkdir`) and most + // in-container daemons run as a non-root uid that can't read + // 0700-owned-by-root files. 0644 / 0755 is what `docker run -v` + // users implicitly expect. + "--default-file-mode-beta=0644", + "--default-directory-mode-beta=0755", + local, + remoteSpec, + } + if err := runMutagen(ctx, bin, env, args...); err != nil { + return fmt.Errorf("mutagen sync create: %w", err) + } + return nil +} + +// sessionExists asks the mutagen daemon whether a named session is +// known. Robust to daemon-down (returns false), so a fresh boot path +// "just creates" rather than failing. +func sessionExists(ctx context.Context, bin string, env []string, name string) bool { + cmd := exec.CommandContext(ctx, bin, "sync", "list", name) // #nosec G204 -- bin is managed, name is internal + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), name) +} + +// waitForSyncReady polls `mutagen sync list ` until the status +// shows "Watching for changes" (steady state) or the deadline fires. +func waitForSyncReady(ctx context.Context, bin string, env []string, name string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return ctx.Err() + } + cmd := exec.CommandContext(ctx, bin, "sync", "list", name) // #nosec G204 -- bin managed; name internal + cmd.Env = env + out, err := cmd.CombinedOutput() + if err == nil { + s := string(out) + if strings.Contains(s, "Watching for changes") { + return nil + } + if strings.Contains(s, "halted") || strings.Contains(s, "failed") { + return fmt.Errorf("session halted: %s", s) + } + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("timed out waiting for sync ready") +} + +// derivedLocalPort returns a deterministic ephemeral port in +// [20000, 65000) keyed on the sandbox id, so re-runs against the same +// sandbox land on the same listener and the mutagen session URL stays +// valid. +func derivedLocalPort(sandboxID string) int { + h := sha256.Sum256([]byte("createos-dc-sync:" + sandboxID)) + v := binary.BigEndian.Uint32(h[:4]) + const lo = 20000 + const hi = 65000 + return int(lo + v%(hi-lo)) +} + +// localPortFromAddr extracts the port from "host:port", returning 0 on +// parse failure (caller should treat as "not pinned, recreate next time"). +func localPortFromAddr(addr string) int { + port, err := splitPort(addr) + if err != nil { + return 0 + } + var p int + if _, err := fmt.Sscanf(port, "%d", &p); err != nil { + return 0 + } + return p +} + +// splitPort returns just the port from "host:port". Used in two places +// where the host half is unwanted (lint flags unparam if we return it). +func splitPort(addr string) (string, error) { + i := strings.LastIndex(addr, ":") + if i < 0 { + return "", errors.New("no port in addr") + } + return addr[i+1:], nil +} + +// mutagenSessionName builds a deterministic session name for a +// (project, sandbox) pair. Stable across re-runs so resume works. +// +// Mutagen names are constrained to [-_a-z0-9]+ and capped around 50 +// chars; we strip aggressively. +func mutagenSessionName(project, sandboxID string) string { + safe := func(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + case r == '_': + b.WriteRune('-') + } + } + return b.String() + } + name := "cdc-" + safe(project) + "-" + safe(sandboxID) + if len(name) > 50 { + name = name[:50] + } + return name +} diff --git a/cmd/sandbox/dc_up.go b/cmd/sandbox/dc_up.go new file mode 100644 index 0000000..b4fbe49 --- /dev/null +++ b/cmd/sandbox/dc_up.go @@ -0,0 +1,603 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/dclock" +) + +// Default rootfs for `dc up` — devbox ships docker, buildx, compose +// plugin, sshd, plus the start-docker helper that brings dockerd up on +// first exec. +const dcRootfs = "devbox:1" + +// Remote tree layout. +// +// /workspace ← Mutagen sync root +// └── / ← per-project subdir +// ├── docker-compose.yml ← pushed (V1) or synced +// └── ... user's source tree ... ← synced by Mutagen (V2) +const remoteWorkspaceRoot = "/workspace" + +func newDCUpCommand() *cli.Command { + return &cli.Command{ + Name: "up", + Usage: "Bring the compose stack up on a remote sandbox", + Description: `Creates (or reuses) a devbox sandbox, pushes the compose file into +it, starts dockerd, and runs 'docker compose up -d' inside the VM. +Saves state to .createos/dc.lock so subsequent dc commands find the +same sandbox. + +V1 limits — pushed once, not synced: + - Only the compose file is shipped to the VM. Push extra files + (Dockerfiles, .env, source for bind mounts) with 'createos sb push', + or set up live two-way sync with 'createos sb sync'. A '--sync' + flag for one-shot Mutagen bootstrap is on the roadmap. + +Examples: + createos sb dc up + createos sb dc up -f docker-compose.dev.yml + createos sb dc up --shape s-4vcpu-4gb --disk-mib 40960 + createos sb dc up --name my-proj`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)", + Value: "docker-compose.yml", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Project / sandbox name (default: cwd basename)", + }, + &cli.StringFlag{ + Name: "shape", + Usage: "VM shape (default: s-2vcpu-2gb)", + Value: "s-2vcpu-2gb", + }, + &cli.IntFlag{ + Name: "disk-mib", + Usage: "Disk size MiB (default: 20480 = 20 GiB)", + Value: 20480, + }, + &cli.BoolFlag{ + Name: "ingress", + Usage: "Enable public HTTPS URLs for the sandbox", + }, + &cli.IntFlag{ + Name: "docker-timeout", + Usage: "Seconds to wait for dockerd to come up after start-docker", + Value: 60, + }, + &cli.BoolFlag{ + Name: "detach", + Aliases: []string{"d"}, + Usage: "Return immediately after services are up — don't hold tunnels open", + }, + &cli.StringFlag{ + Name: "bind", + Usage: "Bind address for forwarded local ports (default: 127.0.0.1)", + Value: "127.0.0.1", + }, + &cli.BoolFlag{ + Name: "no-sync", + Usage: "Skip Mutagen sync (compose file is pushed once via the files API; bind mounts to ./src won't see laptop edits)", + }, + &cli.StringFlag{ + Name: "identity", + Aliases: []string{"i"}, + Usage: "SSH private key for sync (default: ~/.ssh/id_ed25519 then auto-managed ~/.createos/" + "dc_ed25519" + ")", + }, + }, + Action: runDCUp, + } +} + +func runDCUp(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + // ── 1. Resolve project paths + project name ──────────────────────── + composeAbs, absErr := filepath.Abs(c.String("file")) + if absErr != nil { + return fmt.Errorf("resolve --file: %w", absErr) + } + if _, statErr := os.Stat(composeAbs); statErr != nil { + return fmt.Errorf("compose file not readable: %w", statErr) + } + projectDir := filepath.Dir(composeAbs) + if rerr := refuseSensitiveProjectDir(projectDir); rerr != nil { + return rerr + } + projectName := c.String("name") + if projectName == "" { + projectName = sanitizeProjectName(filepath.Base(projectDir)) + } + remoteWorkdir := remoteWorkspaceRoot + "/" + projectName + remoteComposePath := remoteWorkdir + "/" + filepath.Base(composeAbs) + + pterm.Info.Println(fmt.Sprintf("Project: %s compose: %s", projectName, composeAbs)) + + // ── 2. Resolve sandbox: reuse from lockfile or create ────────────── + sb, lock, sbErr := resolveOrCreateDCSandbox(c, client, projectDir, projectName) + if sbErr != nil { + return sbErr + } + pterm.Info.Println(fmt.Sprintf("Sandbox: %s (%s)", sb.ID, sb.Status)) + + // ── 3a. Start dockerd (needed by docker compose up; also lets us + // cleanly run start-docker before mutagen needs it). ───────── + if dErr := ensureDockerRunning(c.Context, client, sb.ID, c.Int("docker-timeout")); dErr != nil { + return fmt.Errorf("start docker: %w", dErr) + } + + // ── 3b. Sync laptop ↔ VM via Mutagen, OR fall back to one-shot + // compose-file push when --no-sync / --detach. Sync requires + // the dc up to stay foreground for the SSH bridge, so detach + // is incompatible. ─────────────────────────────────────────── + wantSync := !c.Bool("no-sync") && !c.Bool("detach") + var syncSession *dcSyncSession + if wantSync { + s, syncErr := ensureDCSyncSession(c, client, sb.ID, projectName, projectDir, remoteWorkdir, lock) + if syncErr != nil { + return fmt.Errorf("set up sync: %w", syncErr) + } + syncSession = s + // Save lockfile early so a crash mid-up doesn't lose the + // session pointer (terminate would otherwise leak it). + if saveErr := lock.Save(projectDir); saveErr != nil { + syncSession.Close() + return fmt.Errorf("save lockfile: %w", saveErr) + } + } else { + if pushErr := pushComposeFile(c.Context, client, sb.ID, composeAbs, remoteComposePath); pushErr != nil { + return fmt.Errorf("push compose file: %w", pushErr) + } + pterm.Success.Println("Compose file pushed to " + remoteComposePath) + } + // Make sure we always close the bridge on exit, even on later errors. + defer func() { + if syncSession != nil { + syncSession.Close() + } + }() + + // ── 5. docker compose up -d (streamed) ───────────────────────────── + pterm.Info.Println("Running 'docker compose up -d' …") + if upErr := composeUp(c.Context, client, sb.ID, projectName, remoteComposePath); upErr != nil { + return fmt.Errorf("docker compose up: %w", upErr) + } + + // ── 6. Parse published ports for the lockfile + summary ──────────── + ports, psErr := composePsPorts(c.Context, client, sb.ID, projectName, remoteComposePath) + if psErr != nil { + // Non-fatal — the stack is up; we just can't show ports. + pterm.Warning.Println("Could not parse compose port map: " + psErr.Error()) + } + + // ── 7. Save lockfile ─────────────────────────────────────────────── + lock.SandboxID = sb.ID + lock.ProjectName = projectName + lock.ComposeFile = remoteComposePath + lock.RemoteWorkdir = remoteWorkdir + lock.Ports = ports + if saveErr := lock.Save(projectDir); saveErr != nil { + return fmt.Errorf("save lockfile: %w", saveErr) + } + + // ── 8. Print summary ─────────────────────────────────────────────── + printDCUpSummary(sb, ports, c.Bool("ingress")) + + // ── 9. Foreground hold: port-forwards + (if active) keep mutagen + // SSH bridge alive. Detach skips both — returns immediately. + if c.Bool("detach") { + return nil + } + bind := c.String("bind") + if bind == "" { + bind = "127.0.0.1" + } + return holdTunnels(c, sb.ID, ports, bind, syncSession != nil) +} + +// holdTunnels keeps the dc up process foreground so: +// +// - Each published port's accept loop forwarding localhost:PORT → +// control → VM stays alive. (These die with the process.) +// - If sync is active, the SSH bridge for the mutagen daemon stays +// alive too. (Already opened before this call.) +// +// Blocks on SIGINT/SIGTERM. The compose stack inside the VM keeps +// running — closing tunnels + sync is purely local cleanup. The +// session resumes on the next `dc up`. +func holdTunnels(c *cli.Context, sandboxID string, ports []dclock.Port, bind string, syncActive bool) error { + ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) + if ctrlURL == "" { + ctrlURL = api.DefaultSandboxBaseURL + } + authHeader, token, err := sandboxAuth(c) + if err != nil { + return err + } + + pterm.Println() + pterm.Info.Println("Forwarding ports (Ctrl+C to stop):") + var ( + listeners []net.Listener + lc net.ListenConfig + ) + for _, p := range ports { + addr := net.JoinHostPort(bind, strconv.Itoa(p.LocalPort)) + l, err := lc.Listen(c.Context, "tcp", addr) + if err != nil { + // Don't fail the whole 'up' — one port collision is + // recoverable. Tell the user and keep going so the rest + // of the stack stays reachable. + pterm.Warning.Printfln(" %s ← %s (skipped: %v)", addr, p.Service, err) + continue + } + listeners = append(listeners, l) + pterm.Success.Printfln(" %s ← %s (container :%d)", addr, p.Service, p.ContainerPort) + go acceptLoop(c.Context, l, ctrlURL, authHeader, token, sandboxID, p.LocalPort) + } + if len(listeners) == 0 && !syncActive { + pterm.Warning.Println("Nothing to hold open — exiting.") + return nil + } + if syncActive { + pterm.Info.Println("Watching for file changes (Mutagen) …") + } + + // Hold until signal. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + <-sigCh + pterm.Println() + if syncActive { + pterm.Info.Println("Closing tunnels + pausing sync …") + } else { + pterm.Info.Println("Closing tunnels …") + } + var wg sync.WaitGroup + for _, l := range listeners { + wg.Add(1) + go func(l net.Listener) { + defer wg.Done() + _ = l.Close() //nolint:errcheck + }(l) + } + wg.Wait() + pterm.Info.Println("Stack is still running. 'sb dc up' to resume sync; 'sb dc down' to destroy.") + return nil +} + +// acceptLoop serves a single listener until Close. Per-connection +// bridging happens on its own goroutine via bridgeOne so a stuck conn +// can't head-of-line every other client. +func acceptLoop(ctx context.Context, l net.Listener, ctrlURL, authHeader, token, id string, remote int) { + for { + conn, err := l.Accept() + if err != nil { + return + } + go bridgeOne(ctx, ctrlURL, authHeader, token, id, remote, conn) + } +} + +// resolveOrCreateDCSandbox looks for an existing live sandbox via the +// lockfile; if absent/dead, creates a fresh devbox sandbox. Returns +// the live SandboxView plus the Lock object (with prior raw fields +// preserved if reloaded). +func resolveOrCreateDCSandbox(c *cli.Context, client *api.SandboxClient, projectDir, projectName string) (*api.SandboxView, *dclock.Lock, error) { + existing, lerr := dclock.Load(projectDir) + if lerr != nil && !errors.Is(lerr, dclock.ErrNotFound) { + return nil, nil, lerr + } + if existing != nil && existing.SandboxID != "" { + sb, gerr := client.GetSandbox(c.Context, existing.SandboxID) + if gerr == nil && isReusableStatus(sb.Status) { + pterm.Info.Println("Reusing sandbox from .createos/dc.lock") + if sb.Status != "running" { + // paused / pausing / etc — resume it. + resumed, rerr := client.ResumeSandbox(c.Context, sb.ID) + if rerr != nil { + return nil, nil, fmt.Errorf("resume sandbox %s: %w", sb.ID, rerr) + } + ready, werr := waitForStatus(c.Context, client, resumed.ID, "running") + if werr != nil { + return nil, nil, fmt.Errorf("wait for resume: %w", werr) + } + return ready, existing, nil + } + return sb, existing, nil + } + // Stale lockfile — sandbox gone / failed. Fall through to create. + pterm.Warning.Println("Lockfile sandbox " + existing.SandboxID + " is not usable — creating a fresh one") + } + + pterm.Info.Println("Creating sandbox …") + req := api.SandboxCreateReq{ + Name: projectName, + Shape: c.String("shape"), + Rootfs: dcRootfs, + DiskMib: int64(c.Int("disk-mib")), + IngressEnabled: c.Bool("ingress"), + } + created, err := client.CreateSandbox(c.Context, req) + if err != nil { + return nil, nil, fmt.Errorf("create sandbox: %w", err) + } + ready, err := waitForStatus(c.Context, client, created.ID, "running") + if err != nil { + return nil, nil, fmt.Errorf("wait for sandbox to be running: %w", err) + } + lock := existing + if lock == nil { + lock = &dclock.Lock{} + } + return ready, lock, nil +} + +// isReusableStatus reports whether a sandbox's status is recoverable +// for `dc up`. running = use directly. paused / pausing / resuming = +// poke and resume. Anything else (destroyed, failed, error) = give up. +func isReusableStatus(s string) bool { + switch s { + case "running", "paused", "pausing", "resuming": + return true + default: + return false + } +} + +// refuseSensitiveProjectDir blocks `dc up` from project dirs that +// shouldn't be treated as a project: HOME and "/" most importantly. +// Mirrors the safety check in cmd/sandbox/sync.go. +func refuseSensitiveProjectDir(dir string) error { + if dir == "/" { + return fmt.Errorf("refusing to run dc from /") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + clean := filepath.Clean(dir) + if clean == filepath.Clean(home) { + return fmt.Errorf("refusing to run dc directly from $HOME — cd into a project subdirectory first") + } + } + return nil +} + +// sanitizeProjectName makes a directory basename safe for use as both +// a docker compose project label and an fc-spawn sandbox name (DNS +// label: lowercase a-z 0-9 hyphen, max 63). +func sanitizeProjectName(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 63 { + out = out[:63] + } + if out == "" { + out = "project" + } + return out +} + +// pushComposeFile streams the local compose file into +// /workspace// via the agent's file API. Parent dirs +// are auto-created by the agent (per CLAUDE.md /v1/sandboxes/:id/files). +func pushComposeFile(ctx context.Context, client *api.SandboxClient, id, localPath, remotePath string) error { + f, err := os.Open(localPath) // #nosec G304 -- caller-provided compose path + if err != nil { + return err + } + defer func() { _ = f.Close() }() //nolint:errcheck + st, err := f.Stat() + if err != nil { + return err + } + return client.UploadFile(ctx, id, remotePath, f, st.Size()) +} + +// ensureDockerRunning execs `start-docker` (idempotent on devbox:1) +// then polls `docker version` until exit 0 or the timeout fires. +func ensureDockerRunning(ctx context.Context, client *api.SandboxClient, id string, timeoutSec int) error { + pterm.Info.Println("Starting dockerd …") + _, err := client.ExecSandbox(ctx, id, api.SandboxExecReq{ + Cmd: "start-docker", + Args: nil, + }) + if err != nil { + return err + } + deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return ctx.Err() + } + resp, perr := client.ExecSandbox(ctx, id, api.SandboxExecReq{ + Cmd: "docker", + Args: []string{"version", "--format", "{{.Server.Version}}"}, + }) + if perr == nil && resp.Result.ExitCode == 0 && strings.TrimSpace(resp.Result.Stdout) != "" { + pterm.Success.Println("dockerd ready (server " + strings.TrimSpace(resp.Result.Stdout) + ")") + return nil + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("dockerd did not come up within %ds", timeoutSec) +} + +// composeUp streams `docker compose up -d` so the user sees pulls / +// builds / container creation as they happen. Errors out if the inner +// command's exit code is non-zero. +func composeUp(ctx context.Context, client *api.SandboxClient, id, projectName, composeFile string) error { + req := api.SandboxExecReq{ + Cmd: "docker", + Args: []string{ + "compose", "-p", projectName, "-f", composeFile, "up", "-d", + }, + } + exit, err := client.ExecSandboxStream(ctx, id, req, func(ev api.SandboxExecStreamEvent) { + switch { + case ev.Stdout != "": + _, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck + case ev.Stderr != "": + _, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck + case ev.Error != "": + pterm.Error.Println(ev.Error) + } + }) + if err != nil { + return err + } + if exit != 0 { + return fmt.Errorf("docker compose up -d exited %d", exit) + } + return nil +} + +// composePsRow models the subset of `docker compose ps --format json` +// we read. Each JSON object on its own line maps to one row. +type composePsRow struct { + Name string `json:"Name"` + Service string `json:"Service"` + Publishers []struct { + URL string `json:"URL"` + TargetPort int `json:"TargetPort"` + PublishedPort int `json:"PublishedPort"` + Protocol string `json:"Protocol"` + } `json:"Publishers"` +} + +// composePsPorts runs `docker compose ps --format json` (buffered) and +// flattens its Publishers blocks into dclock.Port records. Only ports +// with a non-zero PublishedPort are recorded — unpublished container +// ports are not user-reachable. +func composePsPorts(ctx context.Context, client *api.SandboxClient, id, projectName, composeFile string) ([]dclock.Port, error) { + resp, err := client.ExecSandbox(ctx, id, api.SandboxExecReq{ + Cmd: "docker", + Args: []string{ + "compose", "-p", projectName, "-f", composeFile, "ps", "--format", "json", + }, + }) + if err != nil { + return nil, err + } + if resp.Result.ExitCode != 0 { + return nil, fmt.Errorf("compose ps exit %d: %s", resp.Result.ExitCode, resp.Result.Stderr) + } + out := []dclock.Port{} + // Docker compose emits one JSON object per line OR a single JSON + // array depending on plugin version; handle both. + trimmed := strings.TrimSpace(resp.Result.Stdout) + if trimmed == "" { + return out, nil + } + if strings.HasPrefix(trimmed, "[") { + var rows []composePsRow + if err := json.Unmarshal([]byte(trimmed), &rows); err != nil { + return nil, fmt.Errorf("parse array: %w", err) + } + for _, r := range rows { + out = append(out, rowToPorts(r)...) + } + return out, nil + } + dec := json.NewDecoder(bytes.NewReader([]byte(trimmed))) + for dec.More() { + var r composePsRow + if err := dec.Decode(&r); err != nil { + return nil, fmt.Errorf("parse ndjson row: %w", err) + } + out = append(out, rowToPorts(r)...) + } + return out, nil +} + +func rowToPorts(r composePsRow) []dclock.Port { + // docker compose ps emits one Publisher row per address family + // (0.0.0.0 + [::]) for the same port mapping. Dedupe so the table + // + lockfile show each user-visible port once. + seen := map[string]struct{}{} + var out []dclock.Port + for _, p := range r.Publishers { + if p.PublishedPort == 0 { + continue + } + key := fmt.Sprintf("%d/%s/%d", p.PublishedPort, p.Protocol, p.TargetPort) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, dclock.Port{ + Service: r.Service, + ContainerPort: p.TargetPort, + LocalPort: p.PublishedPort, + Protocol: p.Protocol, + }) + } + return out +} + +// printDCUpSummary renders the table users see after `dc up`. Keeps +// pterm shape consistent with the rest of the CLI. +func printDCUpSummary(sb *api.SandboxView, ports []dclock.Port, ingress bool) { + pterm.Println() + pterm.Success.Println("Stack is up.") + pterm.Println("Sandbox: " + pterm.Cyan(sb.ID)) + if len(ports) == 0 { + pterm.Println("No published ports detected.") + return + } + rows := [][]string{{"SERVICE", "CONTAINER", "PUBLISHED"}} + for _, p := range ports { + rows = append(rows, []string{ + p.Service, + fmt.Sprintf("%d/%s", p.ContainerPort, defaultProto(p.Protocol)), + fmt.Sprintf("%d", p.LocalPort), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(rows).Render() //nolint:errcheck + pterm.Println() + pterm.Info.Println("Forward a port to localhost:") + pterm.Println(fmt.Sprintf(" createos sb tunnel %s :<%d>", sb.ID, ports[0].LocalPort)) + if ingress && sb.IngressURLTemplate != "" { + pterm.Info.Println("Or reach published ports directly via ingress:") + pterm.Println(" " + sb.IngressURLTemplate + " (substitute with the published port)") + } +} + +func defaultProto(p string) string { + if p == "" { + return "tcp" + } + return p +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index b4704fd..ba40421 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -27,6 +27,7 @@ func NewSandboxCommand() *cli.Command { newPullCommand(), newShellCommand(), newSyncCommand(), + newDCCommand(), newTunnelCommand(), newDiskCommand(), newNetworkCommand(), diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go index 8323d2a..f657070 100644 --- a/cmd/sandbox/shell.go +++ b/cmd/sandbox/shell.go @@ -455,6 +455,14 @@ func (b *tunnelBridge) close() { } func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, remotePort int) (*tunnelBridge, error) { + return startTunnelBridgeOn(parent, c, sandboxID, remotePort, 0) +} + +// startTunnelBridgeOn is like startTunnelBridge but binds the local +// listener to a specific port. localPort=0 means "pick any free port" +// (matches startTunnelBridge). Used by `dc up --sync` so the mutagen +// session URL stays stable across `dc up` invocations. +func startTunnelBridgeOn(parent context.Context, c *cli.Context, sandboxID string, remotePort, localPort int) (*tunnelBridge, error) { ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) if ctrlURL == "" { ctrlURL = api.DefaultSandboxBaseURL @@ -464,7 +472,8 @@ func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, return nil, err } var lc net.ListenConfig - l, err := lc.Listen(parent, "tcp", "127.0.0.1:0") + bindAddr := fmt.Sprintf("127.0.0.1:%d", localPort) + l, err := lc.Listen(parent, "tcp", bindAddr) if err != nil { return nil, fmt.Errorf("local listen: %w", err) } diff --git a/internal/dclock/lock.go b/internal/dclock/lock.go new file mode 100644 index 0000000..07606b4 --- /dev/null +++ b/internal/dclock/lock.go @@ -0,0 +1,164 @@ +// Package dclock manages the per-project lockfile that ties a local +// docker-compose project to a remote fc-spawn sandbox. +// +// The file lives at `.createos/dc.lock` next to the compose file. It's +// the single source of truth that links `dc up` to subsequent `dc ps`, +// `dc logs`, `dc exec`, and `dc down` invocations — without it, every +// command would have to re-create the sandbox or prompt the user. +// +// File format is JSON for human grep-ability and forward compatibility: +// unknown fields are preserved on read+write so older builds don't drop +// state written by newer ones. +package dclock + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// DirName is the per-project state directory (sibling of compose file). +const DirName = ".createos" + +// FileName is the per-project lockfile within DirName. +const FileName = "dc.lock" + +// Port maps one compose service's published port to the local TCP port +// the createos-cli tunnel is bound to on the user's laptop. +type Port struct { + Service string `json:"service"` + ContainerPort int `json:"container_port"` + LocalPort int `json:"local_port"` + Protocol string `json:"protocol,omitempty"` // "tcp" / "udp" — empty = tcp +} + +// Sync records the Mutagen sync session bound to this project. +// +// SessionName is mutagen's internal handle (used for `mutagen sync +// terminate/resume/list `). LocalSSHPort is the port our control- +// plane SSH bridge listens on for mutagen to dial through; we pin it +// across `dc up` invocations so the existing session's URL stays +// valid and the next `dc up` can resume instead of recreating. +// PrivKeyPath is the SSH private key bound to the session — must match +// the pubkey installed in the sandbox. +type Sync struct { + SessionName string `json:"session_name"` + LocalSSHPort int `json:"local_ssh_port"` + PrivKeyPath string `json:"priv_key_path"` +} + +// Lock is the persisted per-project state. +// +// SandboxID is the fc-spawn sandbox running this stack. +// ProjectName drives the docker compose `-p` flag (defaults to the +// compose-file directory's basename). +// ComposeFile is the path passed to `docker compose -f` INSIDE the VM +// (always under RemoteWorkdir). +// RemoteWorkdir is the absolute path inside the VM that Mutagen mirrors +// the local project directory to (typically /workspace/). +type Lock struct { + SandboxID string `json:"sandbox_id"` + ProjectName string `json:"project_name"` + ComposeFile string `json:"compose_file"` + RemoteWorkdir string `json:"remote_workdir"` + Ports []Port `json:"ports,omitempty"` + Sync *Sync `json:"sync,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // raw preserves any fields written by a newer build so a Load+Save + // round-trip never drops state we don't know about. + raw map[string]json.RawMessage +} + +// ErrNotFound is returned by Load when no lockfile exists at the given +// project root. Callers typically translate it to a friendly +// "run 'createos sb dc up' first" message. +var ErrNotFound = errors.New("no dc.lock in this project — run 'createos sb dc up' first") + +// Path returns the absolute lockfile path for a project rooted at +// projectDir. projectDir is the directory CONTAINING the compose file, +// not the compose file itself. +func Path(projectDir string) string { + return filepath.Join(projectDir, DirName, FileName) +} + +// Load reads the lockfile under projectDir. Returns ErrNotFound if the +// file doesn't exist (typed so callers can branch on it). +func Load(projectDir string) (*Lock, error) { + p := Path(projectDir) + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("read %s: %w", p, err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", p, err) + } + var l Lock + if err := json.Unmarshal(data, &l); err != nil { + return nil, fmt.Errorf("parse %s: %w", p, err) + } + l.raw = raw + return &l, nil +} + +// Save writes the lockfile under projectDir, creating .createos/ if +// needed. UpdatedAt is stamped automatically; CreatedAt is preserved if +// already set, otherwise stamped now. +func (l *Lock) Save(projectDir string) error { + now := time.Now().UTC() + if l.CreatedAt.IsZero() { + l.CreatedAt = now + } + l.UpdatedAt = now + + dir := filepath.Join(projectDir, DirName) + if err := os.MkdirAll(dir, 0o700); err != nil { // #nosec G301 -- per-project state; no other user should read + return fmt.Errorf("mkdir %s: %w", dir, err) + } + // Merge known fields back into raw so unknown keys round-trip. + known, err := json.Marshal(l) + if err != nil { + return err + } + var knownMap map[string]json.RawMessage + if umErr := json.Unmarshal(known, &knownMap); umErr != nil { + return umErr + } + if l.raw == nil { + l.raw = knownMap + } else { + for k, v := range knownMap { + l.raw[k] = v + } + } + data, err := json.MarshalIndent(l.raw, "", " ") + if err != nil { + return err + } + p := Path(projectDir) + tmp := p + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, p); err != nil { + return fmt.Errorf("rename %s: %w", tmp, err) + } + return nil +} + +// Remove deletes the lockfile. Used by `dc down` after a successful +// destroy. Safe to call when no file exists. +func Remove(projectDir string) error { + if err := os.Remove(Path(projectDir)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/internal/sshkey/sshkey.go b/internal/sshkey/sshkey.go new file mode 100644 index 0000000..f0091fa --- /dev/null +++ b/internal/sshkey/sshkey.go @@ -0,0 +1,145 @@ +// Package sshkey resolves (or auto-generates) the ed25519 keypair used +// by `createos sb dc up` to talk to the in-sandbox sshd via Mutagen. +// +// Resolution order: +// +// 1. --identity flag on the command (caller passes it explicitly) +// 2. ~/.ssh/id_ed25519 +// 3. ~/.ssh/id_rsa +// 4. ~/.ssh/id_ecdsa +// 5. ~/.createos/dc_ed25519 — our managed key. Auto-generated on +// first run if none of the above exist. +// +// The managed key is the "just works" default: zero prompts, no +// ssh-keygen step. Users who care can still pass --identity. +package sshkey + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// ManagedKeyName is the filename of the createos-managed ed25519 key +// under ~/.createos/. The public counterpart is ManagedKeyName + ".pub". +const ManagedKeyName = "dc_ed25519" + +// Pair holds the on-disk paths for a resolved keypair. Both paths are +// absolute and the files definitely exist. +type Pair struct { + PrivPath string + PubPath string + // Managed reports whether this pair is the auto-generated managed + // key (as opposed to a user-provided key under ~/.ssh). Callers + // can use this to decide whether a 0o600 mode check is necessary. + Managed bool +} + +// ResolveOrGenerate finds an existing keypair using the precedence +// listed in the package doc, or generates the managed key if none +// exist. `explicit` is the value of --identity (private key path); +// "" means "auto-pick". Errors are returned for unreadable user- +// specified keys but NOT for missing default keys (we just fall +// through). +func ResolveOrGenerate(explicit string) (*Pair, error) { + if explicit != "" { + return readPair(explicit) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} { + priv := filepath.Join(home, ".ssh", name) + if _, err := os.Stat(priv); err == nil { + return readPair(priv) + } + } + } + return generateManagedKey() +} + +// readPair validates that a user-provided private key + its .pub +// counterpart exist and returns a Pair. Mode-permission errors on the +// private key are surfaced as a friendly hint. +func readPair(privPath string) (*Pair, error) { + st, err := os.Stat(privPath) + if err != nil { + return nil, fmt.Errorf("SSH key %s: %w", privPath, err) + } + if st.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("SSH key %s is world/group readable (mode %o); run 'chmod 600 %s'", privPath, st.Mode().Perm(), privPath) + } + pubPath := privPath + ".pub" + if _, err := os.Stat(pubPath); err != nil { + return nil, fmt.Errorf("public key %s not found alongside %s", pubPath, privPath) + } + return &Pair{PrivPath: privPath, PubPath: pubPath, Managed: false}, nil +} + +// generateManagedKey creates the managed keypair under ~/.createos/. +// Idempotent — if the files already exist, it returns them without +// re-generating (so the public key the sandbox already trusts stays +// valid across runs). +// +// Private key is OpenSSH-format PEM (no passphrase, mode 0600). +// Public key is the standard "ssh-ed25519 AAAA... createos-cli" line. +func generateManagedKey() (*Pair, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve $HOME: %w", err) + } + dir := filepath.Join(home, ".createos") + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + return nil, fmt.Errorf("create %s: %w", dir, mkErr) + } + priv := filepath.Join(dir, ManagedKeyName) + pub := priv + ".pub" + + // Idempotent: reuse if both halves exist. + if _, perr := os.Stat(priv); perr == nil { + if _, perr := os.Stat(pub); perr == nil { + return &Pair{PrivPath: priv, PubPath: pub, Managed: true}, nil + } + } + + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + block, mErr := ssh.MarshalPrivateKey(privKey, "createos-cli managed key") + if mErr != nil { + return nil, fmt.Errorf("marshal private key: %w", mErr) + } + if wErr := os.WriteFile(priv, pem.EncodeToMemory(block), 0o600); wErr != nil { // #nosec G306 -- 0600 is intentional + return nil, fmt.Errorf("write %s: %w", priv, wErr) + } + sshPub, npErr := ssh.NewPublicKey(pubKey) + if npErr != nil { + // Clean up the private half so future runs don't inherit a + // broken pair. + _ = os.Remove(priv) //nolint:errcheck + return nil, fmt.Errorf("encode public key: %w", npErr) + } + authLine := append([]byte{}, ssh.MarshalAuthorizedKey(sshPub)...) + // MarshalAuthorizedKey adds a trailing \n but no comment; tack on + // our identifier so users see WHICH key this is in their sandbox's + // authorized_keys. + if len(authLine) > 0 && authLine[len(authLine)-1] == '\n' { + authLine = authLine[:len(authLine)-1] + } + authLine = append(authLine, ' ', 'c', 'r', 'e', 'a', 't', 'e', 'o', 's', '-', 'c', 'l', 'i', '\n') + if wErr := os.WriteFile(pub, authLine, 0o644); wErr != nil { // #nosec G306 -- pubkey is meant to be world-readable + _ = os.Remove(priv) //nolint:errcheck + return nil, fmt.Errorf("write %s: %w", pub, wErr) + } + return &Pair{PrivPath: priv, PubPath: pub, Managed: true}, nil +} + +// ErrManagedKeyMissing is returned by ReadManaged when neither half of +// the managed key exists. Callers can branch on it to fall back to +// ResolveOrGenerate. +var ErrManagedKeyMissing = errors.New("managed key not present")