Skip to content

Commit 88b756b

Browse files
feat: docker compose support
1 parent 8357b48 commit 88b756b

12 files changed

Lines changed: 2057 additions & 1 deletion

File tree

bin/createos

18.5 MB
Binary file not shown.

cmd/sandbox/dc.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package sandbox
2+
3+
import (
4+
"github.com/urfave/cli/v2"
5+
)
6+
7+
// newDCCommand wires up `createos sandbox dc` — Docker Compose against a
8+
// remote devbox sandbox. The mental model: one sandbox = one Docker
9+
// host. We don't translate compose to fc-spawn primitives — we sync your
10+
// project into the VM, run `docker compose` inside it, and forward the
11+
// ports back.
12+
//
13+
// Lifecycle:
14+
//
15+
// dc up → ensure sandbox + sshd + dockerd + Mutagen sync + `docker compose up -d`
16+
// + port-forward every published port
17+
// dc down → destroy the sandbox (use --keep to stop compose only)
18+
// dc ps → list services + ports + health
19+
// dc logs → tail compose logs
20+
// dc exec → docker compose exec into a service
21+
//
22+
// State per project lives in `.createos/dc.lock` next to the compose
23+
// file (sandbox id, ssh key path, port map, Mutagen session id).
24+
func newDCCommand() *cli.Command {
25+
return &cli.Command{
26+
Name: "dc",
27+
Aliases: []string{"compose"},
28+
Usage: "Run docker-compose against a remote sandbox (dev loop)",
29+
Description: `Treats a fc-spawn sandbox as a remote Docker host. Reads your
30+
docker-compose.yml, syncs the project directory in, runs
31+
'docker compose up' inside the VM, and forwards published ports back to
32+
your laptop. Edit locally, Mutagen mirrors changes, containers pick them
33+
up natively. 'sb dc pause'/'resume' freeze the whole stack to R2.
34+
35+
Bind mounts (./src:/app) work — they reference the synced project copy
36+
inside the VM. For stateful services (postgres, redis, mysql) use named
37+
docker volumes instead so the data stays in the VM and out of your
38+
laptop's sync loop.
39+
40+
Subcommands:
41+
up Bring the stack up
42+
down Destroy the sandbox (or just 'docker compose down' with --keep)
43+
ps List services and forwarded ports
44+
logs Tail compose logs
45+
exec Run a command inside a service container`,
46+
Subcommands: []*cli.Command{
47+
newDCUpCommand(),
48+
newDCDownCommand(),
49+
newDCPsCommand(),
50+
newDCLogsCommand(),
51+
newDCExecCommand(),
52+
},
53+
}
54+
}

cmd/sandbox/dc_down.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package sandbox
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
10+
"github.com/pterm/pterm"
11+
"github.com/urfave/cli/v2"
12+
13+
"github.com/NodeOps-app/createos-cli/internal/api"
14+
"github.com/NodeOps-app/createos-cli/internal/dclock"
15+
"github.com/NodeOps-app/createos-cli/internal/terminal"
16+
)
17+
18+
// newDCDownCommand wires `createos sandbox dc down`.
19+
//
20+
// Default: stop compose (best-effort) + destroy the sandbox + remove
21+
// the lockfile. The destroy makes the sandbox unrecoverable, so we
22+
// confirm on a TTY unless --yes is passed.
23+
//
24+
// --keep skips the destroy: we run `docker compose down` inside the VM
25+
// and leave the sandbox running. Useful when you want to teardown the
26+
// stack but reuse the same VM (saves the next 'up' from waiting on a
27+
// fresh sandbox boot + image pull).
28+
//
29+
// --volumes passes `-v` to `docker compose down` so named volumes are
30+
// also removed. Without it, postgres / redis / etc. data persists in
31+
// the VM and is picked up by the next 'up'.
32+
func newDCDownCommand() *cli.Command {
33+
return &cli.Command{
34+
Name: "down",
35+
Usage: "Tear down the compose stack (and the sandbox unless --keep)",
36+
Description: `By default destroys the sandbox entirely — the fastest path back to
37+
zero cost. Pass --keep to only stop compose and leave the sandbox
38+
running.
39+
40+
Examples:
41+
createos sb dc down
42+
createos sb dc down --keep
43+
createos sb dc down -v # also remove named docker volumes
44+
createos sb dc down --yes # skip the confirmation prompt`,
45+
Flags: []cli.Flag{
46+
&cli.StringFlag{
47+
Name: "file",
48+
Aliases: []string{"f"},
49+
Usage: "Path to docker-compose.yml (default: ./docker-compose.yml)",
50+
Value: "docker-compose.yml",
51+
},
52+
&cli.BoolFlag{
53+
Name: "keep",
54+
Usage: "Stop compose only; keep the sandbox running",
55+
},
56+
&cli.BoolFlag{
57+
Name: "volumes",
58+
Aliases: []string{"v"},
59+
Usage: "Pass -v to 'docker compose down' (remove named volumes)",
60+
},
61+
&cli.BoolFlag{
62+
Name: "yes",
63+
Aliases: []string{"y"},
64+
Usage: "Skip the confirmation prompt (required in non-interactive mode for destroy)",
65+
},
66+
},
67+
Action: runDCDown,
68+
}
69+
}
70+
71+
func runDCDown(c *cli.Context) error {
72+
client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient)
73+
if !ok {
74+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
75+
}
76+
projectDir, lock, err := loadDCProject(c.String("file"))
77+
if err != nil {
78+
return err
79+
}
80+
81+
// Confirm on a TTY when about to destroy. --keep doesn't need this:
82+
// stopping compose is recoverable.
83+
if !c.Bool("keep") {
84+
if !c.Bool("yes") {
85+
if !terminal.IsInteractive() {
86+
return fmt.Errorf("non-interactive mode: pass --yes to confirm destroying sandbox %s", lock.SandboxID)
87+
}
88+
ok, perr := pterm.DefaultInteractiveConfirm.
89+
WithDefaultText(fmt.Sprintf("Destroy sandbox %s? This deletes its disk and snapshot.", lock.SandboxID)).
90+
WithDefaultValue(false).
91+
Show()
92+
if perr != nil {
93+
return fmt.Errorf("could not read confirmation: %w", perr)
94+
}
95+
if !ok {
96+
fmt.Println("Cancelled. Nothing was destroyed.")
97+
return nil
98+
}
99+
}
100+
}
101+
102+
// 1. Best-effort `docker compose down` so named volumes survive a
103+
// 'down --keep' cycle cleanly. Skipped if the sandbox is no
104+
// longer running — there's nothing to gracefully stop.
105+
if err := composeDown(c.Context, client, lock, c.Bool("volumes")); err != nil {
106+
pterm.Warning.Println("compose down failed (continuing): " + err.Error())
107+
} else {
108+
pterm.Success.Println("Compose stack stopped.")
109+
}
110+
111+
// 1b. Terminate any active mutagen sync session. Best-effort: if
112+
// mutagen isn't installed (e.g. someone deleted ~/.createos),
113+
// just drop the lockfile entry and move on.
114+
if lock.Sync != nil && lock.Sync.SessionName != "" {
115+
if err := terminateDCSync(c.Context, lock); err != nil {
116+
pterm.Warning.Println("terminate sync session failed (continuing): " + err.Error())
117+
} else {
118+
pterm.Success.Println("Sync session terminated.")
119+
}
120+
lock.Sync = nil
121+
}
122+
123+
// 2. --keep stops here.
124+
if c.Bool("keep") {
125+
// Clear ports from the lockfile — they're no longer bound, and
126+
// a stale port list would confuse 'dc ps'. Sandbox id stays.
127+
lock.Ports = nil
128+
if err := lock.Save(projectDir); err != nil {
129+
return fmt.Errorf("update lockfile: %w", err)
130+
}
131+
pterm.Info.Println("Sandbox " + lock.SandboxID + " left running.")
132+
return nil
133+
}
134+
135+
// 3. Destroy the sandbox.
136+
if err := client.DestroySandbox(c.Context, lock.SandboxID); err != nil {
137+
// Treat "not found" as already-destroyed — clean up the lockfile
138+
// either way so the user isn't stuck.
139+
pterm.Warning.Println("destroy failed (will remove lockfile anyway): " + err.Error())
140+
} else {
141+
pterm.Success.Println("Sandbox " + lock.SandboxID + " destroyed.")
142+
}
143+
144+
// 4. Remove the lockfile so the next 'up' creates a fresh sandbox.
145+
if err := dclock.Remove(projectDir); err != nil && !errors.Is(err, os.ErrNotExist) {
146+
return fmt.Errorf("remove lockfile: %w", err)
147+
}
148+
return nil
149+
}
150+
151+
// composeDown runs `docker compose down [-v]` inside the sandbox via
152+
// the exec stream so the user sees the per-container stop messages
153+
// in real time. Soft-fails when the sandbox isn't running so 'dc down'
154+
// still completes its destroy step.
155+
func composeDown(ctx context.Context, client *api.SandboxClient, lock *dclock.Lock, removeVolumes bool) error {
156+
args := []string{
157+
"compose",
158+
"-p", lock.ProjectName,
159+
"-f", lock.ComposeFile,
160+
"down",
161+
}
162+
if removeVolumes {
163+
args = append(args, "-v")
164+
}
165+
exit, err := client.ExecSandboxStream(ctx, lock.SandboxID, api.SandboxExecReq{
166+
Cmd: "docker",
167+
Args: args,
168+
}, func(ev api.SandboxExecStreamEvent) {
169+
switch {
170+
case ev.Stdout != "":
171+
_, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck
172+
case ev.Stderr != "":
173+
_, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck
174+
case ev.Error != "":
175+
pterm.Error.Println(ev.Error)
176+
}
177+
})
178+
if err != nil {
179+
return err
180+
}
181+
if exit != 0 {
182+
return fmt.Errorf("docker compose down exited %d", exit)
183+
}
184+
return nil
185+
}

0 commit comments

Comments
 (0)