Skip to content

Commit e3fd5b9

Browse files
committed
cella: expose policy discovery
1 parent d073540 commit e3fd5b9

5 files changed

Lines changed: 265 additions & 2 deletions

File tree

internal/api/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ type APIError struct {
155155
func (e *APIError) Error() string {
156156
if e.Code == "policy_sidecar_required" {
157157
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" +
158-
"This is not a local command syntax problem. Re-run `latere auth login` with the latest CLI, then retry. If it still fails, ask your Latere admin/support to configure the CLI sidecar client or choose a policy that does not require a sidecar.\n" +
158+
"This is not a local command syntax problem. Re-run `latere auth login` with the latest CLI, then retry.\n" +
159+
"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" +
160+
"If no such policy is available, ask your Latere admin/support to configure the CLI sidecar client or assign a non-sidecar policy.\n" +
159161
"server code: policy_sidecar_required"
160162
}
161163
if e.Code != "" {

internal/api/client_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ func TestAPIErrorPolicySidecarRequiredIsActionable(t *testing.T) {
1717
"server has no complete sidecar configuration for this CLI token",
1818
"not a local command syntax problem",
1919
"latere auth login",
20+
"latere cella policy list",
21+
"latere cella create --policy <name>",
22+
"SIDECAR is `no`",
2023
"server code: policy_sidecar_required",
2124
} {
2225
if !strings.Contains(err, want) {

internal/commands/cella.go

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ type sandboxDTO struct {
4343
Workdir string `json:"workdir,omitempty"`
4444
}
4545

46+
type policyDTO struct {
47+
Name string `json:"name"`
48+
Label string `json:"label"`
49+
Description string `json:"description"`
50+
CapabilityProfile string `json:"capability_profile"`
51+
SidecarRequired bool `json:"sidecar_required"`
52+
IsDefault bool `json:"is_default"`
53+
Selectable bool `json:"selectable"`
54+
AssignmentSource string `json:"assignment_source"`
55+
NetworkEgressFQDNs []string `json:"network_egress_fqdns,omitempty"`
56+
CreatedAt time.Time `json:"created_at,omitzero"`
57+
UpdatedAt time.Time `json:"updated_at,omitzero"`
58+
}
59+
4660
// fallbackWorkdir is the path the MCP/CLI assume when a sandbox DTO
4761
// arrives without a workdir field (older sandboxd before the workdir
4862
// contract shipped).
@@ -94,13 +108,14 @@ func newCellaCmd() *cobra.Command {
94108
cmd := &cobra.Command{
95109
Use: "cella",
96110
Aliases: []string{"sandbox"},
97-
Short: "Manage cellas (create, list, rename, start, stop, delete, run).",
111+
Short: "Manage cellas (create, list, policy, start, stop, delete, run).",
98112
Long: `Manage Cella sandboxes — per-user compute environments at cella.latere.ai.
99113
100114
Each cella is a PVC-backed workspace plus a Pod for compute. Tier
101115
'ephemeral' auto-stops on idle and auto-deletes after a wall-clock
102116
window; tier 'persistent' stays until you delete it.`,
103117
Example: ` latere cella list
118+
latere cella policy list
104119
latere cella create --name dev --tier persistent --disk 10
105120
latere cella shell dev
106121
latere cella run dev -- python train.py
@@ -114,6 +129,7 @@ window; tier 'persistent' stays until you delete it.`,
114129
newCeStartCmd(),
115130
newCeStopCmd(),
116131
newCeDeleteCmd(),
132+
newCePolicyCmd(),
117133
newCeExecCmd(),
118134
newCeShellCmd(),
119135
newCeRunCmd(),
@@ -185,6 +201,7 @@ image and account defaults for disk, CPU, memory, idle timeout, and
185201
policy. Use --tier persistent for a workspace that survives until
186202
explicitly deleted.`,
187203
Example: ` latere cella create
204+
latere cella policy list
188205
latere cella create --name dev --tier persistent --disk 10
189206
latere cella create --name gpu-test --cpu 2 --memory 8Gi
190207
latere cella create --name app --env NODE_ENV=development --credential github
@@ -301,6 +318,95 @@ cellas returned by the backend, including warm-pool cellas.`,
301318
return cmd
302319
}
303320

321+
func newCePolicyCmd() *cobra.Command {
322+
var (
323+
apiURL string
324+
jsonF bool
325+
)
326+
cmd := &cobra.Command{
327+
Use: "policy",
328+
Aliases: []string{"policies"},
329+
Short: "List policy profiles available for new cellas.",
330+
Long: `List Cella policy profiles visible to the current token.
331+
332+
Policies control runtime capabilities such as network shape, workspace
333+
layout, and whether Cella's credential sidecar is required. The default
334+
policy is used when 'latere cella create' is run without --policy.
335+
336+
Use a selectable policy with:
337+
338+
latere cella create --policy <name>
339+
340+
If create fails because the selected policy requires the sidecar, list
341+
policies and choose a selectable policy where SIDECAR is "no", or ask
342+
an admin to configure the sidecar client for your token.`,
343+
Example: ` latere cella policy
344+
latere cella policy list
345+
latere cella policies --json
346+
latere cella create --policy restricted-network`,
347+
RunE: func(cmd *cobra.Command, args []string) error {
348+
return runPolicyList(cmd.Context(), apiURL, jsonF)
349+
},
350+
}
351+
f := cmd.Flags()
352+
f.StringVar(&apiURL, "api-url", "", "override Cella API base URL")
353+
f.BoolVar(&jsonF, "json", false, "JSON output")
354+
355+
list := &cobra.Command{
356+
Use: "list",
357+
Short: "List policy profiles available for new cellas.",
358+
Long: cmd.Long,
359+
Example: ` latere cella policy list
360+
latere cella policy list --json
361+
latere cella create --policy restricted-network`,
362+
RunE: func(cmd *cobra.Command, args []string) error {
363+
return runPolicyList(cmd.Context(), apiURL, jsonF)
364+
},
365+
}
366+
list.Flags().StringVar(&apiURL, "api-url", "", "override Cella API base URL")
367+
list.Flags().BoolVar(&jsonF, "json", false, "JSON output")
368+
cmd.AddCommand(list)
369+
return cmd
370+
}
371+
372+
func runPolicyList(ctx context.Context, apiURL string, jsonF bool) error {
373+
c, err := authedClient(apiURL)
374+
if err != nil {
375+
return err
376+
}
377+
var policies []policyDTO
378+
if err := c.GetJSON(ctx, "/v1/policies", &policies); err != nil {
379+
return err
380+
}
381+
if jsonF {
382+
return printJSON(policies)
383+
}
384+
if len(policies) == 0 {
385+
fmt.Fprintln(os.Stdout, "No policy profiles are visible to this token.")
386+
fmt.Fprintln(os.Stdout, "Ask your Latere admin to assign a selectable policy, then retry `latere cella create --policy <name>`.")
387+
return nil
388+
}
389+
printPolicies(policies)
390+
return nil
391+
}
392+
393+
func printPolicies(policies []policyDTO) {
394+
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
395+
_, _ = fmt.Fprintln(tw, "NAME\tDEFAULT\tSELECTABLE\tSIDECAR\tCAPABILITY\tSOURCE\tDESCRIPTION")
396+
for _, p := range policies {
397+
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
398+
p.Name,
399+
yesNo(p.IsDefault),
400+
yesNo(p.Selectable),
401+
yesNo(p.SidecarRequired),
402+
defaultStr(p.CapabilityProfile, "-"),
403+
defaultStr(p.AssignmentSource, "-"),
404+
oneLine(defaultStr(p.Description, p.Label)),
405+
)
406+
}
407+
_ = tw.Flush()
408+
}
409+
304410
func newCeGetCmd() *cobra.Command {
305411
var apiURL string
306412
cmd := &cobra.Command{
@@ -1447,6 +1553,17 @@ func defaultStr(s, fallback string) string {
14471553
return s
14481554
}
14491555

1556+
func yesNo(v bool) string {
1557+
if v {
1558+
return "yes"
1559+
}
1560+
return "no"
1561+
}
1562+
1563+
func oneLine(s string) string {
1564+
return strings.Join(strings.Fields(s), " ")
1565+
}
1566+
14501567
func humanAge(t time.Time) string {
14511568
if t.IsZero() {
14521569
return "-"

internal/commands/policy_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
)
14+
15+
func TestPolicyListPrintsCreateGuidanceFields(t *testing.T) {
16+
writeTestToken(t)
17+
var authz string
18+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
authz = r.Header.Get("Authorization")
20+
if r.URL.Path != "/v1/policies" {
21+
http.NotFound(w, r)
22+
return
23+
}
24+
w.Header().Set("Content-Type", "application/json")
25+
_, _ = w.Write([]byte(`[
26+
{
27+
"name":"agent-default",
28+
"label":"Agent Default",
29+
"description":"Uses sidecar credentials",
30+
"capability_profile":"restricted",
31+
"sidecar_required":true,
32+
"is_default":true,
33+
"selectable":true,
34+
"assignment_source":"default"
35+
},
36+
{
37+
"name":"restricted-network",
38+
"label":"Restricted Network",
39+
"description":"No sidecar required",
40+
"capability_profile":"restricted-no-network",
41+
"sidecar_required":false,
42+
"is_default":false,
43+
"selectable":true,
44+
"assignment_source":"client"
45+
}
46+
]`))
47+
}))
48+
defer srv.Close()
49+
50+
out := capturePolicyStdout(t, func() {
51+
if err := runPolicyList(context.Background(), srv.URL, false); err != nil {
52+
t.Fatalf("runPolicyList: %v", err)
53+
}
54+
})
55+
56+
if authz != "Bearer test-token" {
57+
t.Fatalf("Authorization = %q, want bearer token", authz)
58+
}
59+
for _, want := range []string{
60+
"NAME",
61+
"DEFAULT",
62+
"SELECTABLE",
63+
"SIDECAR",
64+
"agent-default",
65+
"yes",
66+
"restricted-network",
67+
"restricted-no-network",
68+
"client",
69+
} {
70+
if !strings.Contains(out, want) {
71+
t.Fatalf("policy list output missing %q:\n%s", want, out)
72+
}
73+
}
74+
}
75+
76+
func TestPolicyListEmptyExplainsNextStep(t *testing.T) {
77+
writeTestToken(t)
78+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
_, _ = w.Write([]byte(`[]`))
80+
}))
81+
defer srv.Close()
82+
83+
out := capturePolicyStdout(t, func() {
84+
if err := runPolicyList(context.Background(), srv.URL, false); err != nil {
85+
t.Fatalf("runPolicyList: %v", err)
86+
}
87+
})
88+
89+
for _, want := range []string{
90+
"No policy profiles are visible",
91+
"latere cella create --policy <name>",
92+
} {
93+
if !strings.Contains(out, want) {
94+
t.Fatalf("empty policy output missing %q:\n%s", want, out)
95+
}
96+
}
97+
}
98+
99+
func writeTestToken(t *testing.T) {
100+
t.Helper()
101+
path := filepath.Join(t.TempDir(), "token.json")
102+
if err := os.WriteFile(path, []byte(`{"access_token":"test-token","token_type":"Bearer"}`), 0o600); err != nil {
103+
t.Fatal(err)
104+
}
105+
t.Setenv("LATERE_TOKEN_FILE", path)
106+
}
107+
108+
func capturePolicyStdout(t *testing.T, fn func()) string {
109+
t.Helper()
110+
old := os.Stdout
111+
r, w, err := os.Pipe()
112+
if err != nil {
113+
t.Fatal(err)
114+
}
115+
os.Stdout = w
116+
defer func() { os.Stdout = old }()
117+
118+
fn()
119+
120+
if err := w.Close(); err != nil {
121+
t.Fatal(err)
122+
}
123+
var buf bytes.Buffer
124+
if _, err := io.Copy(&buf, r); err != nil {
125+
t.Fatal(err)
126+
}
127+
if err := r.Close(); err != nil {
128+
t.Fatal(err)
129+
}
130+
return buf.String()
131+
}

internal/commands/root_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,21 @@ func TestHelpIncludesUserExamples(t *testing.T) {
7979
args: []string{"cella", "create", "--help"},
8080
want: []string{
8181
"Create a Cella workspace.",
82+
"latere cella policy list",
8283
"latere cella create --name dev --tier persistent --disk 10",
8384
"idle timeout in minutes; omit for account default, 0 disables",
8485
"named network policy",
8586
},
8687
},
88+
{
89+
name: "cella policy",
90+
args: []string{"cella", "policy", "--help"},
91+
want: []string{
92+
"List Cella policy profiles visible to the current token.",
93+
"latere cella create --policy <name>",
94+
"choose a selectable policy where SIDECAR is \"no\"",
95+
},
96+
},
8797
{
8898
name: "cella run",
8999
args: []string{"cella", "run", "--help"},

0 commit comments

Comments
 (0)