Skip to content

Commit b3ee331

Browse files
committed
fix(cli): honor the atespace flag when applying resources
Use the explicit atespace for apply and reject conflicting manifest values.
1 parent c5c1ac5 commit b3ee331

3 files changed

Lines changed: 126 additions & 5 deletions

File tree

‎cmd/ax/main.go‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func main() {
4949
cmd string
5050
cleanArgs []string
5151
atespace = "default"
52+
atespaceFlag *string
5253
explicitServer = ""
5354
kubeContext = ""
5455
axNamespace = "ax-system"
@@ -60,10 +61,12 @@ func main() {
6061
if arg == "-a" || arg == "--atespace" {
6162
if i+1 < len(args) {
6263
atespace = args[i+1]
64+
atespaceFlag = &atespace
6365
i++
6466
}
6567
} else if strings.HasPrefix(arg, "--atespace=") {
6668
atespace = strings.TrimPrefix(arg, "--atespace=")
69+
atespaceFlag = &atespace
6770
} else if arg == "--server" {
6871
if i+1 < len(args) {
6972
explicitServer = args[i+1]
@@ -132,7 +135,7 @@ func main() {
132135

133136
switch cmd {
134137
case "apply":
135-
err = runApply(serverURL, cleanArgs)
138+
err = runApply(serverURL, atespaceFlag, cleanArgs)
136139
case "get":
137140
err = runGet(serverURL, atespace, cleanArgs)
138141
case "describe":
@@ -204,7 +207,8 @@ func getAXClient(serverURL string) (v1alpha1.AXClient, *grpc.ClientConn, error)
204207
return v1alpha1.NewAXClient(conn), conn, nil
205208
}
206209

207-
func runApply(serverURL string, args []string) error {
210+
// A nil atespace leaves manifest atespaces unchanged and lets the server default missing ones.
211+
func runApply(serverURL string, atespace *string, args []string) error {
208212
data, ok, err := manifestFromArgs(args)
209213
if err != nil {
210214
return err
@@ -244,7 +248,7 @@ func runApply(serverURL string, args []string) error {
244248
}
245249
}
246250

247-
kind, name, outcome, err := applyDocument(ctx, client, &doc)
251+
kind, name, outcome, err := applyDocument(ctx, client, &doc, atespace)
248252
if err != nil {
249253
return fmt.Errorf("applying document %d: %w", docIndex, err)
250254
}
@@ -255,7 +259,7 @@ func runApply(serverURL string, args []string) error {
255259
// applyDocument decodes one manifest by its kind and submits it with the matching
256260
// Update RPC. It reports the kind, the resource name, and whether the resource was
257261
// created, configured (spec changed), or unchanged, in the style of kubectl apply.
258-
func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node) (kind, name, outcome string, err error) {
262+
func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node, atespace *string) (kind, name, outcome string, err error) {
259263
var head struct {
260264
Kind string `yaml:"kind"`
261265
}
@@ -269,6 +273,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
269273
if err := doc.Decode(&task); err != nil {
270274
return "", "", "", err
271275
}
276+
if err := setApplyAtespace(task.Metadata, atespace); err != nil {
277+
return "", "", "", err
278+
}
272279
existing, err := client.GetTask(ctx, &v1alpha1.GetTaskRequest{Atespace: task.GetMetadata().GetAtespace(), Name: task.GetMetadata().GetName()})
273280
outcome, err := applyOutcome(err, existing.GetSpec(), task.GetSpec())
274281
if err != nil {
@@ -282,6 +289,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
282289
if err := doc.Decode(&ws); err != nil {
283290
return "", "", "", err
284291
}
292+
if err := setApplyAtespace(ws.Metadata, atespace); err != nil {
293+
return "", "", "", err
294+
}
285295
existing, err := client.GetWorkspace(ctx, &v1alpha1.GetWorkspaceRequest{Atespace: ws.GetMetadata().GetAtespace(), Name: ws.GetMetadata().GetName()})
286296
outcome, err := applyOutcome(err, existing.GetSpec(), ws.GetSpec())
287297
if err != nil {
@@ -295,6 +305,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
295305
if err := doc.Decode(&m); err != nil {
296306
return "", "", "", err
297307
}
308+
if err := setApplyAtespace(m.Metadata, atespace); err != nil {
309+
return "", "", "", err
310+
}
298311
existing, err := client.GetModel(ctx, &v1alpha1.GetModelRequest{Atespace: m.GetMetadata().GetAtespace(), Name: m.GetMetadata().GetName()})
299312
outcome, err := applyOutcome(err, existing.GetSpec(), m.GetSpec())
300313
if err != nil {
@@ -310,6 +323,18 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
310323
}
311324
}
312325

326+
// setApplyAtespace fills a missing atespace or rejects a mismatch with an explicit flag.
327+
func setApplyAtespace(meta *v1alpha1.ObjectMeta, atespace *string) error {
328+
if meta == nil || atespace == nil {
329+
return nil
330+
}
331+
if meta.Atespace != "" && meta.Atespace != *atespace {
332+
return fmt.Errorf("metadata.atespace %q does not match --atespace %q", meta.Atespace, *atespace)
333+
}
334+
meta.Atespace = *atespace
335+
return nil
336+
}
337+
313338
// applyOutcome classifies an apply from the result of looking up the existing
314339
// resource: "created" when it did not exist, "unchanged" when its spec already
315340
// matches, and "configured" otherwise. Lookup failures other than NotFound are

‎cmd/ax/main_test.go‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,114 @@ package main
1616

1717
import (
1818
"context"
19+
"errors"
20+
"fmt"
1921
"net"
2022
"os"
23+
"os/exec"
2124
"path/filepath"
2225
"strings"
2326
"testing"
27+
"time"
2428

2529
"github.com/google/ax/internal/server"
30+
"github.com/google/ax/internal/store"
2631
"github.com/google/ax/internal/store/memory"
2732
"github.com/google/ax/pkg/apis/v1alpha1"
2833
"google.golang.org/grpc/codes"
2934
"google.golang.org/grpc/status"
3035
"gopkg.in/yaml.v3"
3136
)
3237

38+
func TestApplyAtespace(t *testing.T) {
39+
// Exercise flag parsing and dispatch as well as the resource RPCs.
40+
binary := filepath.Join(t.TempDir(), "ax")
41+
if output, err := exec.Command("go", "build", "-o", binary, ".").CombinedOutput(); err != nil {
42+
t.Fatalf("building CLI: %v\n%s", err, output)
43+
}
44+
for _, kind := range []string{"Task", "Workspace", "Model"} {
45+
for _, tc := range []struct {
46+
name string
47+
manifestAtespace string
48+
flags []string
49+
wantAtespace string
50+
wantErr string
51+
}{
52+
{name: "default", wantAtespace: "default"},
53+
{name: "flag only", flags: []string{"-a", "team-a"}, wantAtespace: "team-a"},
54+
{name: "manifest only", manifestAtespace: "team-a", wantAtespace: "team-a"},
55+
{name: "matching", manifestAtespace: "team-a", flags: []string{"--atespace", "team-a"}, wantAtespace: "team-a"},
56+
{name: "conflicting", manifestAtespace: "team-a", flags: []string{"--atespace=team-b"}, wantErr: `metadata.atespace "team-a" does not match --atespace "team-b"`},
57+
{name: "explicit default", manifestAtespace: "team-a", flags: []string{"-a", "default"}, wantErr: `metadata.atespace "team-a" does not match --atespace "default"`},
58+
} {
59+
t.Run(kind+"/"+tc.name, func(t *testing.T) {
60+
s := memory.NewStore()
61+
listener, err := net.Listen("tcp", "127.0.0.1:0")
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
srv := server.NewServer(s).GRPCServer()
66+
t.Cleanup(srv.Stop)
67+
go func() { _ = srv.Serve(listener) }()
68+
69+
path := filepath.Join(t.TempDir(), "resource.yaml")
70+
manifest := fmt.Sprintf("apiVersion: ax.io/v1alpha1\nkind: %s\nmetadata:\n name: example\n", kind)
71+
if tc.manifestAtespace != "" {
72+
manifest += fmt.Sprintf(" atespace: %q\n", tc.manifestAtespace)
73+
}
74+
manifest += "spec: {}\n"
75+
if err := os.WriteFile(path, []byte(manifest), 0600); err != nil {
76+
t.Fatal(err)
77+
}
78+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
79+
defer cancel()
80+
args := append([]string{"--server", listener.Addr().String(), "apply", "-f", path}, tc.flags...)
81+
output, err := exec.CommandContext(ctx, binary, args...).CombinedOutput()
82+
if tc.wantErr != "" {
83+
var exitErr *exec.ExitError
84+
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 || !strings.Contains(string(output), tc.wantErr) {
85+
t.Fatalf("expected exit 1 with %q, got %v\n%s", tc.wantErr, err, output)
86+
}
87+
} else {
88+
wantOutput := strings.ToLower(kind) + ".ax.io/example created\n"
89+
if err != nil || string(output) != wantOutput {
90+
t.Fatalf("expected %q, got %v\n%s", wantOutput, err, output)
91+
}
92+
// Reapplying must look up the resource in the same atespace.
93+
output, err = exec.CommandContext(ctx, binary, args...).CombinedOutput()
94+
wantOutput = strings.ToLower(kind) + ".ax.io/example unchanged\n"
95+
if err != nil || string(output) != wantOutput {
96+
t.Fatalf("expected %q on reapply, got %v\n%s", wantOutput, err, output)
97+
}
98+
}
99+
100+
for _, atespace := range []string{"default", "team-a", "team-b"} {
101+
var meta *v1alpha1.ObjectMeta
102+
var getErr error
103+
switch kind {
104+
case "Task":
105+
resource, err := s.GetTask(ctx, atespace, "example")
106+
meta, getErr = resource.GetMetadata(), err
107+
case "Workspace":
108+
resource, err := s.GetWorkspace(ctx, atespace, "example")
109+
meta, getErr = resource.GetMetadata(), err
110+
case "Model":
111+
resource, err := s.GetModel(ctx, atespace, "example")
112+
meta, getErr = resource.GetMetadata(), err
113+
}
114+
if atespace == tc.wantAtespace {
115+
if getErr != nil || meta.GetAtespace() != atespace {
116+
t.Errorf("expected resource in %q, got metadata %v, error %v", atespace, meta, getErr)
117+
}
118+
} else if !errors.Is(getErr, store.ErrNotFound) {
119+
t.Errorf("expected no resource in %q, got metadata %v, error %v", atespace, meta, getErr)
120+
}
121+
}
122+
})
123+
}
124+
}
125+
}
126+
33127
func TestRunApplyEmptyDocuments(t *testing.T) {
34128
const first = "apiVersion: ax.io/v1alpha1\nkind: Workspace\nmetadata:\n name: first\nspec: {}\n"
35129
second := strings.Replace(first, "name: first", "name: second", 1)
@@ -77,7 +171,7 @@ func TestRunApplyEmptyDocuments(t *testing.T) {
77171
_ = output.Close()
78172
})
79173

80-
runErr := runApply(listener.Addr().String(), []string{"-f", path})
174+
runErr := runApply(listener.Addr().String(), nil, []string{"-f", path})
81175
if tc.wantErr == "" {
82176
if runErr != nil {
83177
t.Fatal(runErr)

‎docs/manifests.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All four kinds can live in one multi-document YAML file. See [`examples/task.yam
44

55
`metadata.name` and `metadata.atespace` become Substrate resource names, so they must be lowercase RFC 1123 labels: at most 63 lowercase alphanumeric characters or `-`, starting and ending with an alphanumeric character. `ax apply` rejects anything else up front rather than letting the task fail later with `ActorCreationFailed`.
66

7+
When `metadata.atespace` is omitted, `ax apply -a team-a -f manifest.yaml` uses `team-a`. Without `-a` or `--atespace`, the manifest's atespace is preserved, or defaults to `default` if omitted. If an explicit flag disagrees with `metadata.atespace`, that resource is rejected before it is submitted.
8+
79
## Task
810

911
```yaml

0 commit comments

Comments
 (0)