Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/ax/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ spec:
}

docNode := node.Content[0]
kind, name, outcome, err := applyDocument(context.Background(), client, docNode)
kind, name, outcome, err := applyDocument(context.Background(), client, docNode, nil)
if err != nil {
t.Fatalf("expected applyDocument to succeed, got: %v", err)
}
Expand Down Expand Up @@ -114,7 +114,7 @@ spec:
}

docNode := node.Content[0]
_, _, _, err := applyDocument(context.Background(), client, docNode)
_, _, _, err := applyDocument(context.Background(), client, docNode, nil)
if err == nil {
t.Fatalf("expected error applying to existing task, got nil")
}
Expand Down
35 changes: 30 additions & 5 deletions cmd/ax/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func main() {
cmd string
cleanArgs []string
atespace = "default"
atespaceFlag *string
explicitServer = ""
kubeContext = ""
axNamespace = "ax-system"
Expand All @@ -60,10 +61,12 @@ func main() {
if arg == "-a" || arg == "--atespace" {
if i+1 < len(args) {
atespace = args[i+1]
atespaceFlag = &atespace
i++
}
} else if strings.HasPrefix(arg, "--atespace=") {
atespace = strings.TrimPrefix(arg, "--atespace=")
atespaceFlag = &atespace
} else if arg == "--server" {
if i+1 < len(args) {
explicitServer = args[i+1]
Expand Down Expand Up @@ -132,7 +135,7 @@ func main() {

switch cmd {
case "apply":
err = runApply(serverURL, cleanArgs)
err = runApply(serverURL, atespaceFlag, cleanArgs)
case "get":
err = runGet(serverURL, atespace, cleanArgs)
case "describe":
Expand Down Expand Up @@ -204,7 +207,8 @@ func getAXClient(serverURL string) (v1alpha1.AXClient, *grpc.ClientConn, error)
return v1alpha1.NewAXClient(conn), conn, nil
}

func runApply(serverURL string, args []string) error {
// A nil atespace leaves manifest atespaces unchanged and lets the server default missing ones.
func runApply(serverURL string, atespace *string, args []string) error {
data, ok, err := manifestFromArgs(args)
if err != nil {
return err
Expand Down Expand Up @@ -244,7 +248,7 @@ func runApply(serverURL string, args []string) error {
}
}

kind, name, outcome, err := applyDocument(ctx, client, &doc)
kind, name, outcome, err := applyDocument(ctx, client, &doc, atespace)
if err != nil {
return fmt.Errorf("applying document %d: %w", docIndex, err)
}
Expand All @@ -253,9 +257,9 @@ func runApply(serverURL string, args []string) error {
}

// applyDocument decodes one manifest by its kind and submits it with the matching
// Update RPC. It reports the kind, the resource name, and whether the resource was
// RPC. It reports the kind, the resource name, and whether the resource was
// created, configured (spec changed), or unchanged, in the style of kubectl apply.
func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node) (kind, name, outcome string, err error) {
func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node, atespace *string) (kind, name, outcome string, err error) {
var head struct {
Kind string `yaml:"kind"`
}
Expand All @@ -269,6 +273,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
if err := doc.Decode(&task); err != nil {
return "", "", "", err
}
if err := setApplyAtespace(task.Metadata, atespace); err != nil {
return "", "", "", err
}
res, err := client.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &task})
if err != nil {
return "", "", "", err
Expand All @@ -280,6 +287,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
if err := doc.Decode(&ws); err != nil {
return "", "", "", err
}
if err := setApplyAtespace(ws.Metadata, atespace); err != nil {
return "", "", "", err
}
existing, err := client.GetWorkspace(ctx, &v1alpha1.GetWorkspaceRequest{Atespace: ws.GetMetadata().GetAtespace(), Name: ws.GetMetadata().GetName()})
outcome, err := applyOutcome(err, existing.GetSpec(), ws.GetSpec())
if err != nil {
Expand All @@ -293,6 +303,9 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
if err := doc.Decode(&m); err != nil {
return "", "", "", err
}
if err := setApplyAtespace(m.Metadata, atespace); err != nil {
return "", "", "", err
}
existing, err := client.GetModel(ctx, &v1alpha1.GetModelRequest{Atespace: m.GetMetadata().GetAtespace(), Name: m.GetMetadata().GetName()})
outcome, err := applyOutcome(err, existing.GetSpec(), m.GetSpec())
if err != nil {
Expand All @@ -308,6 +321,18 @@ func applyDocument(ctx context.Context, client v1alpha1.AXClient, doc *yaml.Node
}
}

// setApplyAtespace fills a missing atespace or rejects a mismatch with an explicit flag.
func setApplyAtespace(meta *v1alpha1.ObjectMeta, atespace *string) error {
if meta == nil || atespace == nil {
return nil
}
if meta.Atespace != "" && meta.Atespace != *atespace {
return fmt.Errorf("metadata.atespace %q does not match --atespace %q", meta.Atespace, *atespace)
}
meta.Atespace = *atespace
return nil
}

// applyOutcome classifies an apply from the result of looking up the existing
// resource: "created" when it did not exist, "unchanged" when its spec already
// matches, and "configured" otherwise. Lookup failures other than NotFound are
Expand Down
101 changes: 100 additions & 1 deletion cmd/ax/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand All @@ -33,6 +35,103 @@ import (
"gopkg.in/yaml.v3"
)

func TestApplyAtespace(t *testing.T) {
// Exercise flag parsing and dispatch as well as the resource RPCs.
binary := filepath.Join(t.TempDir(), "ax")
if output, err := exec.Command("go", "build", "-o", binary, ".").CombinedOutput(); err != nil {
t.Fatalf("building CLI: %v\n%s", err, output)
}
for _, kind := range []string{"Task", "Workspace", "Model"} {
for _, tc := range []struct {
name string
manifestAtespace string
flags []string
wantAtespace string
wantErr string
}{
{name: "default", wantAtespace: "default"},
{name: "flag only", flags: []string{"-a", "team-a"}, wantAtespace: "team-a"},
{name: "manifest only", manifestAtespace: "team-a", wantAtespace: "team-a"},
{name: "matching", manifestAtespace: "team-a", flags: []string{"--atespace", "team-a"}, wantAtespace: "team-a"},
{name: "conflicting", manifestAtespace: "team-a", flags: []string{"--atespace=team-b"}, wantErr: `metadata.atespace "team-a" does not match --atespace "team-b"`},
{name: "explicit default", manifestAtespace: "team-a", flags: []string{"-a", "default"}, wantErr: `metadata.atespace "team-a" does not match --atespace "default"`},
} {
t.Run(kind+"/"+tc.name, func(t *testing.T) {
s := memory.NewStore()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := server.NewServer(s).GRPCServer()
t.Cleanup(srv.Stop)
go func() { _ = srv.Serve(listener) }()

path := filepath.Join(t.TempDir(), "resource.yaml")
manifest := fmt.Sprintf("apiVersion: ax.io/v1alpha1\nkind: %s\nmetadata:\n name: example\n", kind)
if tc.manifestAtespace != "" {
manifest += fmt.Sprintf(" atespace: %q\n", tc.manifestAtespace)
}
manifest += "spec: {}\n"
if err := os.WriteFile(path, []byte(manifest), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args := append([]string{"--server", listener.Addr().String(), "apply", "-f", path}, tc.flags...)
output, err := exec.CommandContext(ctx, binary, args...).CombinedOutput()
if tc.wantErr != "" {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 || !strings.Contains(string(output), tc.wantErr) {
t.Fatalf("expected exit 1 with %q, got %v\n%s", tc.wantErr, err, output)
}
} else {
wantOutput := strings.ToLower(kind) + ".ax.io/example created\n"
if err != nil || string(output) != wantOutput {
t.Fatalf("expected %q, got %v\n%s", wantOutput, err, output)
}
// Tasks are immutable; other resources remain unchanged on reapply.
output, err = exec.CommandContext(ctx, binary, args...).CombinedOutput()
if kind == "Task" {
var exitErr *exec.ExitError
wantErr := fmt.Sprintf("code = FailedPrecondition desc = task %s/example already exists and is immutable", tc.wantAtespace)
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 || !strings.Contains(string(output), wantErr) {
t.Fatalf("expected exit 1 with %q on reapply, got %v\n%s", wantErr, err, output)
}
} else {
wantOutput = strings.ToLower(kind) + ".ax.io/example unchanged\n"
if err != nil || string(output) != wantOutput {
t.Fatalf("expected %q on reapply, got %v\n%s", wantOutput, err, output)
}
}
}

for _, atespace := range []string{"default", "team-a", "team-b"} {
var meta *v1alpha1.ObjectMeta
var getErr error
switch kind {
case "Task":
resource, err := s.GetTask(ctx, atespace, "example")
meta, getErr = resource.GetMetadata(), err
case "Workspace":
resource, err := s.GetWorkspace(ctx, atespace, "example")
meta, getErr = resource.GetMetadata(), err
case "Model":
resource, err := s.GetModel(ctx, atespace, "example")
meta, getErr = resource.GetMetadata(), err
}
if atespace == tc.wantAtespace {
if getErr != nil || meta.GetAtespace() != atespace {
t.Errorf("expected resource in %q, got metadata %v, error %v", atespace, meta, getErr)
}
} else if !errors.Is(getErr, store.ErrNotFound) {
t.Errorf("expected no resource in %q, got metadata %v, error %v", atespace, meta, getErr)
}
}
})
}
}
}

func TestRunApplyEmptyDocuments(t *testing.T) {
const first = "apiVersion: ax.io/v1alpha1\nkind: Workspace\nmetadata:\n name: first\nspec: {}\n"
second := strings.Replace(first, "name: first", "name: second", 1)
Expand Down Expand Up @@ -80,7 +179,7 @@ func TestRunApplyEmptyDocuments(t *testing.T) {
_ = output.Close()
})

runErr := runApply(listener.Addr().String(), []string{"-f", path})
runErr := runApply(listener.Addr().String(), nil, []string{"-f", path})
if tc.wantErr == "" {
if runErr != nil {
t.Fatal(runErr)
Expand Down
2 changes: 2 additions & 0 deletions docs/manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All four kinds can live in one multi-document YAML file. See [`examples/task.yam

`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`.

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.

## Task

```yaml
Expand Down
Loading