Skip to content

Commit 0b74f02

Browse files
committed
feat(cella): replace flag-based create with apply -f <manifest.yaml>
The Sandbox Manifest (sandbox spec 81) is now the only path for spinning up a Cella from the CLI. The old flag-soup `latere cella create --name --image --tier --disk --cpu --memory --auto-stop-minutes --auto-delete-hours --ttl --env --credential --policy` is gone in favour of a single command: latere cella apply -f sandbox.yaml The CLI does no schema work. It reads the file (or stdin), POSTs the raw bytes to /v1/sandboxes with Content-Type: application/yaml, and surfaces the server's response verbatim. The same body the dashboard's YAML tab and a curl -H 'Content-Type: application/yaml' send. Help text, root examples, the policy-sidecar error message, and the CLI's own docs/cella.md README now teach apply as the canonical path. Tests rewritten: the legacy --image default test deleted, four new tests pin the apply-only flag surface, a round-trip wire test, a stdin path, and the 64 KiB body cap. Towards sandbox spec 82 / 83 promises (the CLI side users see the docs and landing page point at).
1 parent 2e93d67 commit 0b74f02

10 files changed

Lines changed: 248 additions & 161 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ latere auth login --token <token>
5757
| **Lux** | Call language models on your identity, no key to allocate: discovery, SDK enablement, chat, usage. | [docs/lux.md](docs/lux.md) |
5858

5959
```sh
60-
latere cella create --name demo --tier ephemeral
60+
latere cella apply -f sandbox.yaml
6161
latere lux chat --model openai/gpt-4o-mini "Say hi"
6262
```
6363

docs/cella.md

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,30 @@
44

55
## Quickstart
66

7-
Create an ephemeral cella and run a command:
8-
9-
```sh
10-
latere cella create --name demo --tier ephemeral
7+
Describe a Cella in a YAML file and apply it:
8+
9+
```sh
10+
cat > sandbox.yaml <<'YAML'
11+
apiVersion: cella.latere.ai/v1 # Schema version.
12+
kind: Sandbox
13+
metadata:
14+
name: demo # Optional.
15+
spec:
16+
image: ghcr.io/latere-ai/sandbox-base:latest
17+
tier: ephemeral # Or "persistent" to keep it.
18+
lifecycle:
19+
autoStop: 15m
20+
YAML
21+
22+
latere cella apply -f sandbox.yaml
1123
latere cella exec demo -- sh -lc 'echo hello && pwd'
1224
latere cella shell demo
1325
```
1426

27+
The same YAML works in the dashboard's YAML tab and over the
28+
public API with `Content-Type: application/yaml`. Full field
29+
reference: <https://cella.latere.ai/docs/cella/manifest>.
30+
1531
Run a one-shot disposable command. The backend creates an ephemeral
1632
cella, runs the command, returns output and timing, then deletes the
1733
cella:
@@ -20,14 +36,6 @@ cella:
2036
latere cella run --ephemeral --rm -- sh -lc 'echo hello && pwd'
2137
```
2238

23-
Create a persistent workspace:
24-
25-
```sh
26-
latere cella create --name work --tier persistent --disk 10
27-
latere cella stop work
28-
latere cella start work
29-
```
30-
3139
Run a background job and follow logs:
3240

3341
```sh
@@ -38,7 +46,7 @@ latere cella logs demo "$CMD" --follow
3846
## Lifecycle
3947

4048
```sh
41-
latere cella create
49+
latere cella apply -f sandbox.yaml
4250
latere cella list
4351
latere cella get <name|id>
4452
latere cella rename <name|id> <new-name>
@@ -47,22 +55,6 @@ latere cella stop <name|id>
4755
latere cella delete <name|id>
4856
```
4957

50-
Create flags:
51-
52-
```sh
53-
latere cella create \
54-
--name work \
55-
--image ghcr.io/latere-ai/sandbox-base:main \
56-
--tier persistent \
57-
--disk 10 \
58-
--auto-stop-minutes 30 \
59-
--auto-delete-hours 24 \
60-
--ttl 12h \
61-
--env GOFLAGS=-count=1 \
62-
--credential source-control \
63-
--policy default
64-
```
65-
6658
Tier changes:
6759

6860
```sh

internal/api/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func (e *APIError) Error() string {
201201
if e.Code == "policy_sidecar_required" {
202202
return "cannot create cella: the selected policy requires Cella's credential sidecar, but the server has no complete sidecar configuration for this CLI token.\n" +
203203
"This is not a local command syntax problem. Re-run `latere auth login` with the latest CLI, then retry.\n" +
204-
"To choose another policy, run `latere cella policy list` and retry with `latere cella create --policy <name>` using a selectable policy where sidecar is `no`.\n" +
204+
"To choose another policy, run `latere cella policy list` and set `spec.policy` in your Manifest to a selectable policy where sidecar is `no`.\n" +
205205
"If no such policy is available, ask your Latere admin/support to configure the CLI sidecar client or assign a non-sidecar policy.\n" +
206206
"server code: policy_sidecar_required"
207207
}

internal/api/client_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestAPIErrorPolicySidecarRequiredIsActionable(t *testing.T) {
1818
"not a local command syntax problem",
1919
"latere auth login",
2020
"latere cella policy list",
21-
"latere cella create --policy <name>",
21+
"spec.policy",
2222
"sidecar is `no`",
2323
"server code: policy_sidecar_required",
2424
} {

internal/commands/cella.go

Lines changed: 77 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,13 @@ Each cella is a PVC-backed workspace plus a Pod for compute. Tier
115115
window; tier 'persistent' stays until you delete it.`,
116116
Example: ` latere cella list
117117
latere cella policy list
118-
latere cella create --name dev --tier persistent --disk 10
118+
latere cella apply -f sandbox.yaml
119119
latere cella shell dev
120120
latere cella run dev -- python train.py
121121
latere cella export dev src -o workspace.tar`,
122122
}
123123
cmd.AddCommand(
124-
newCeCreateCmd(),
124+
newCeApplyCmd(),
125125
newCeListCmd(),
126126
newCeGetCmd(),
127127
newCeRenameCmd(),
@@ -172,110 +172,95 @@ the remote command's exit code when the command finishes.`,
172172
return cmd
173173
}
174174

175-
// ---- create / list / get / rename / start / stop / delete ----
175+
// ---- apply / list / get / rename / start / stop / delete ----
176176

177-
func newCeCreateCmd() *cobra.Command {
177+
// newCeApplyCmd registers `latere cella apply -f <file>`. Reads the
178+
// SandboxManifest from disk and POSTs the raw bytes to
179+
// /v1/sandboxes with Content-Type: application/yaml. The server is
180+
// the authoritative validator, so the CLI does no schema work.
181+
// "-" reads the manifest from stdin.
182+
func newCeApplyCmd() *cobra.Command {
178183
var (
179-
image string
180-
name string
181-
tier string
182-
diskGB int
183-
cpu string
184-
memory string
185-
autoStop int
186-
autoDeleteHours int
187-
ttl string
188-
envFlag []string
189-
credentialFlag []string
190-
policy string
191-
apiURL string
184+
file string
185+
apiURL string
192186
)
193187
cmd := &cobra.Command{
194-
Use: "create",
195-
Short: "Create a cella.",
196-
Long: `Create a Cella workspace.
197-
198-
By default this creates an ephemeral cella using the standard base
199-
image and account defaults for disk, CPU, memory, idle timeout, and
200-
policy. Use --tier persistent for a workspace that survives until
201-
explicitly deleted.`,
202-
Example: ` latere cella create
203-
latere cella policy list
204-
latere cella create --name dev --tier persistent --disk 10
205-
latere cella create --name gpu-test --cpu 2 --memory 8Gi
206-
latere cella create --name app --env NODE_ENV=development --credential github
207-
latere cella create --policy default-sidecar`,
188+
Use: "apply",
189+
Short: "Create a cella from a Sandbox Manifest file.",
190+
Long: `Create a Cella from a declarative Sandbox Manifest.
191+
192+
The same YAML accepted by the dashboard's YAML tab and the
193+
public API. Defaults hit the warm pool, so a Manifest like the
194+
one below starts in around 300 ms:
195+
196+
apiVersion: cella.latere.ai/v1 # Schema version.
197+
kind: Sandbox
198+
metadata:
199+
name: dev # Optional. Server picks one if omitted.
200+
spec:
201+
image: ghcr.io/latere-ai/sandbox-base:latest
202+
tier: ephemeral # Or "persistent" to keep the workspace.
203+
lifecycle:
204+
autoStop: 15m # Stop the compute after this much idle.
205+
206+
Full field reference: https://cella.latere.ai/docs/cella/manifest`,
207+
Example: ` latere cella apply -f sandbox.yaml
208+
cat sandbox.yaml | latere cella apply -f -`,
208209
RunE: func(cmd *cobra.Command, args []string) error {
209-
c, err := authedClient(apiURL)
210+
if strings.TrimSpace(file) == "" {
211+
return fmt.Errorf("-f is required (path to a Sandbox Manifest, or - for stdin)")
212+
}
213+
body, err := readManifestBody(file)
210214
if err != nil {
211215
return err
212216
}
213-
env, err := parseKV(envFlag)
217+
c, err := authedClient(apiURL)
214218
if err != nil {
215219
return err
216220
}
217-
if cmd.Flags().Changed("auto-stop-minutes") && autoStop < 0 {
218-
return fmt.Errorf("--auto-stop-minutes must be 0 or greater")
219-
}
220-
body := map[string]any{
221-
"image": image,
222-
"name": name,
223-
}
224-
if tier != "" {
225-
body["tier"] = tier
226-
}
227-
if diskGB > 0 {
228-
body["disk_gb"] = diskGB
229-
}
230-
if cpu != "" {
231-
body["cpu"] = cpu
232-
}
233-
if memory != "" {
234-
body["memory"] = memory
235-
}
236-
if cmd.Flags().Changed("auto-stop-minutes") {
237-
body["auto_stop_minutes"] = autoStop
238-
}
239-
if autoDeleteHours > 0 {
240-
body["auto_delete_hours"] = autoDeleteHours
241-
}
242-
if ttl != "" {
243-
body["ttl"] = ttl
244-
}
245-
if len(env) > 0 {
246-
body["env"] = env
247-
}
248-
if len(credentialFlag) > 0 {
249-
body["credential_catalog"] = credentialFlag
250-
}
251-
if policy != "" {
252-
body["policy"] = policy
253-
}
254221
var sb sandboxDTO
255-
if err := c.PostJSON(cmd.Context(), "/v1/sandboxes", body, &sb); err != nil {
222+
if err := c.Do(cmd.Context(), http.MethodPost, "/v1/sandboxes",
223+
bytes.NewReader(body), "application/yaml", &sb); err != nil {
256224
return err
257225
}
258226
printSandbox(sb)
259227
return nil
260228
},
261229
}
262-
f := cmd.Flags()
263-
f.StringVar(&image, "image", "ghcr.io/latere-ai/sandbox-base:latest", "container image")
264-
f.StringVar(&name, "name", "", "human slug; server generates one if omitted")
265-
f.StringVar(&tier, "tier", "", "ephemeral|persistent (default ephemeral)")
266-
f.IntVar(&diskGB, "disk", 0, "PVC size in GB (default 1)")
267-
f.StringVar(&cpu, "cpu", "", "CPU limit as a Kubernetes quantity, e.g. 1.5 or 1500m (default plan/account default)")
268-
f.StringVar(&memory, "memory", "", "memory limit as a Kubernetes quantity, e.g. 4Gi or 2048Mi (default plan/account default)")
269-
f.IntVar(&autoStop, "auto-stop-minutes", 0, "idle timeout in minutes; omit for account default, 0 disables")
270-
f.IntVar(&autoDeleteHours, "auto-delete-hours", 0, "ephemeral wall-clock lifetime")
271-
f.StringVar(&ttl, "ttl", "", "Go duration TTL alternative to --auto-delete-hours")
272-
f.StringArrayVar(&envFlag, "env", nil, "non-secret KEY=VALUE; repeatable")
273-
f.StringArrayVar(&credentialFlag, "credential", nil, "trust-plane catalog key to attach; repeatable")
274-
f.StringVar(&policy, "policy", "", "named network policy")
275-
f.StringVar(&apiURL, "api-url", "", "override Cella API base URL")
230+
cmd.Flags().StringVarP(&file, "file", "f", "", "path to a Sandbox Manifest YAML file, or - for stdin")
231+
_ = cmd.MarkFlagRequired("file")
232+
cmd.Flags().StringVar(&apiURL, "api-url", "", "override Cella API base URL")
276233
return cmd
277234
}
278235

236+
// readManifestBody reads the manifest from path or, if path is "-",
237+
// from stdin. Capped at 64 KiB to match the server's body limit.
238+
func readManifestBody(path string) ([]byte, error) {
239+
const maxBytes = 64 << 10
240+
var r io.Reader
241+
if path == "-" {
242+
r = os.Stdin
243+
} else {
244+
f, err := os.Open(path)
245+
if err != nil {
246+
return nil, fmt.Errorf("open manifest %q: %w", path, err)
247+
}
248+
defer func() { _ = f.Close() }()
249+
r = f
250+
}
251+
body, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
252+
if err != nil {
253+
return nil, fmt.Errorf("read manifest: %w", err)
254+
}
255+
if len(body) > maxBytes {
256+
return nil, fmt.Errorf("manifest exceeds %d byte limit", maxBytes)
257+
}
258+
if len(bytes.TrimSpace(body)) == 0 {
259+
return nil, fmt.Errorf("manifest is empty")
260+
}
261+
return body, nil
262+
}
263+
279264
func newCeListCmd() *cobra.Command {
280265
var (
281266
apiURL string
@@ -328,19 +313,19 @@ func newCePolicyCmd() *cobra.Command {
328313
329314
Policies control runtime capabilities such as network shape, workspace
330315
layout, and whether Cella's credential sidecar is required. The default
331-
policy is used when 'latere cella create' is run without --policy.
316+
policy is used when a Manifest's spec.policy is left empty.
332317
333-
Use a selectable policy with:
318+
Use a selectable policy by setting it in your Manifest:
334319
335-
latere cella create --policy <name>
320+
spec:
321+
policy: restricted-network
336322
337323
If create fails because the selected policy requires the sidecar, list
338324
policies and choose a selectable policy where sidecar is "no", or ask
339325
an admin to configure the sidecar client for your token.`,
340326
Example: ` latere cella policy
341327
latere cella policy list
342-
latere cella policies --json
343-
latere cella create --policy restricted-network`,
328+
latere cella policies --json`,
344329
RunE: func(cmd *cobra.Command, args []string) error {
345330
return runPolicyList(cmd.Context(), apiURL, jsonF)
346331
},
@@ -354,8 +339,7 @@ an admin to configure the sidecar client for your token.`,
354339
Short: "List policy profiles available for new cellas.",
355340
Long: cmd.Long,
356341
Example: ` latere cella policy list
357-
latere cella policy list --json
358-
latere cella create --policy restricted-network`,
342+
latere cella policy list --json`,
359343
RunE: func(cmd *cobra.Command, args []string) error {
360344
return runPolicyList(cmd.Context(), apiURL, jsonF)
361345
},
@@ -380,7 +364,7 @@ func runPolicyList(ctx context.Context, apiURL string, jsonF bool) error {
380364
}
381365
if len(policies) == 0 {
382366
fmt.Fprintln(os.Stdout, "No policy profiles are visible to this token.")
383-
fmt.Fprintln(os.Stdout, "Ask your Latere admin to assign a selectable policy, then retry `latere cella create --policy <name>`.")
367+
fmt.Fprintln(os.Stdout, "Ask your Latere admin to assign a selectable policy, then re-run `latere cella apply` with `spec.policy` set in your Manifest.")
384368
return nil
385369
}
386370
printPolicies(policies)

0 commit comments

Comments
 (0)