diff --git a/pkg/kclshim/fixtures/example_synthesizer/eno/resource_list.k b/pkg/kclshim/fixtures/example_synthesizer/eno/resource_list.k index e2515939..5095fa41 100644 --- a/pkg/kclshim/fixtures/example_synthesizer/eno/resource_list.k +++ b/pkg/kclshim/fixtures/example_synthesizer/eno/resource_list.k @@ -1,20 +1,7 @@ # TODO: Move this file to a KCL module. -schema ResultResourceRef: - apiVersion: str - kind: str - name: str - namespace?: str - -schema Result: - message: str - resourceRef?: ResultResourceRef - severity?: str - tag?: [str]str - schema ResourceList: apiVersion: str = "config.kubernetes.io/v1" kind: str = "ResourceList" items: [any] functionConfig?: any - results?: [Result] \ No newline at end of file diff --git a/pkg/kclshim/fixtures/example_synthesizer/main.k b/pkg/kclshim/fixtures/example_synthesizer/main.k index cd56d6c8..8477d7c9 100644 --- a/pkg/kclshim/fixtures/example_synthesizer/main.k +++ b/pkg/kclshim/fixtures/example_synthesizer/main.k @@ -2,4 +2,4 @@ import eno import pkg input: eno.ResourceList = option("input") -output: eno.ResourceList = pkg.Synthesize(input) +output: [any] = pkg.Synthesize(input) diff --git a/pkg/kclshim/fixtures/example_synthesizer/pkg/synthesizer.k b/pkg/kclshim/fixtures/example_synthesizer/pkg/synthesizer.k index 88641460..b05b87bd 100644 --- a/pkg/kclshim/fixtures/example_synthesizer/pkg/synthesizer.k +++ b/pkg/kclshim/fixtures/example_synthesizer/pkg/synthesizer.k @@ -1,12 +1,10 @@ import eno -Synthesize: (eno.ResourceList) -> eno.ResourceList = lambda input: eno.ResourceList -> eno.ResourceList { +Synthesize: (eno.ResourceList) -> [any] = lambda input: eno.ResourceList -> [any] { image_base = [item.data.image_base for item in input.items if item.metadata.name == "some-config"][0] - output = eno.ResourceList { - items: [ - GetDeployment(image_base), - my_service_account, - ] - } -} \ No newline at end of file + output = [ + GetDeployment(image_base), + my_service_account, + ] +} diff --git a/pkg/kclshim/kcl.go b/pkg/kclshim/kcl.go index 99fc3338..d2544574 100644 --- a/pkg/kclshim/kcl.go +++ b/pkg/kclshim/kcl.go @@ -4,57 +4,33 @@ // // 1. Create a folder and write your KCLs. Take "./fixtures/example_synthesizer/main.k" as an example. // -// 2. Write a main.go and call "Synthesize()", defined below. +// 2. Write a main.go and call "Synthesize(workingDir, input)", defined below. package kclshim import ( "encoding/json" "fmt" - "io" - "os" - krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" kcl "kcl-lang.io/kcl-go" "kcl-lang.io/kcl-go/pkg/spec/gpyrpc" + "sigs.k8s.io/controller-runtime/pkg/client" ) -func printErr(err error) { - rl := krmv1.ResourceList{ - APIVersion: krmv1.SchemeGroupVersion.String(), - Kind: krmv1.ResourceListKind, - Items: []*unstructured.Unstructured{}, - Results: []*krmv1.Result{ - { - Message: err.Error(), - Severity: krmv1.ResultSeverityError, - }, - }, - } - bytes, err := json.Marshal(rl) - if err != nil { - rl.Results = append(rl.Results, &krmv1.Result{ - Message: fmt.Sprintf("error marshaling error response: %v", err), - Severity: krmv1.ResultSeverityError, - }) - } - fmt.Print(string(bytes)) -} - -func Synthesize(workingDir string) { - buffer, err := io.ReadAll(os.Stdin) +// Synthesize runs a KCL program in the given directory with JSON-serializable structured input. +// It returns the synthesized Kubernetes objects or an error. +func Synthesize(workingDir string, input any) ([]client.Object, error) { + inputJSON, err := json.Marshal(input) if err != nil { - printErr(fmt.Errorf("error reading from stdin: %w", err)) - return + return nil, fmt.Errorf("error marshaling input to JSON: %w", err) } - input := string(buffer) + inputStr := string(inputJSON) depResult, err := kcl.UpdateDependencies(&gpyrpc.UpdateDependencies_Args{ ManifestPath: workingDir, }) if err != nil { - printErr(fmt.Errorf("error updating dependencies: %w", err)) - return + return nil, fmt.Errorf("error updating dependencies: %w", err) } depOpt := kcl.NewOption() @@ -64,24 +40,31 @@ func Synthesize(workingDir string) { "main.k", kcl.WithWorkDir(workingDir), *depOpt, - kcl.WithOptions(fmt.Sprintf("input=%s", input)), + kcl.WithOptions(fmt.Sprintf("input=%s", inputStr)), ) if err != nil { - printErr(fmt.Errorf("error running KCL: %w", err)) - return + return nil, fmt.Errorf("error running KCL: %w", err) } result, err := results.First().ToMap() + if err != nil { + return nil, fmt.Errorf("error converting KCL result to map: %w", err) + } + output := result["output"] outputJSON, err := json.Marshal(output) if err != nil { - printErr(fmt.Errorf("error marshaling output to JSON: %w", err)) - return + return nil, fmt.Errorf("error marshaling output to JSON: %w", err) } - _, err = fmt.Println(string(outputJSON)) - if err != nil { - printErr(fmt.Errorf("error printing output: %w", err)) - return + var items []*unstructured.Unstructured + if err := json.Unmarshal(outputJSON, &items); err != nil { + return nil, fmt.Errorf("error unmarshaling output to object list: %w", err) + } + + var objects []client.Object + for _, item := range items { + objects = append(objects, item) } + return objects, nil } diff --git a/pkg/kclshim/kcl_test.go b/pkg/kclshim/kcl_test.go index 3ad5e1b4..7b155397 100644 --- a/pkg/kclshim/kcl_test.go +++ b/pkg/kclshim/kcl_test.go @@ -1,130 +1,113 @@ package kclshim import ( - "io" + "encoding/json" "os" "strings" "testing" ) func TestSynthesize(t *testing.T) { + // Load test input from fixture file + inputBytes, err := os.ReadFile("fixtures/example_input.json") + if err != nil { + t.Fatalf("Failed to read input file: %v", err) + } + var input map[string]interface{} + if err := json.Unmarshal(inputBytes, &input); err != nil { + t.Fatalf("Failed to unmarshal input JSON: %v", err) + } + tests := []struct { name string workingDir string + expectedErrs []string expectedOutput string }{ { name: "successful synthesis", workingDir: "fixtures/example_synthesizer", - expectedOutput: `{ - "apiVersion":"config.kubernetes.io/v1", - "items":[ - { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": "my-deployment", - "namespace": "default" - }, - "spec": { - "replicas": 3, - "selector": { - "matchLabels": { - "app": "my-app" - } - }, - "template": { - "metadata": { - "labels": { - "app": "my-app" - } - }, - "spec": { - "containers": [ - { - "image": "mcr.microsoft.com/a/b/my-image:latest", - "name": "my-container" - } - ] - } - } - } - }, - { - "apiVersion": "v1", - "kind": "ServiceAccount", - "metadata": { - "name": "my-service-account", - "namespace": "default" - } - } - ], - "kind": "ResourceList" - }`, + expectedOutput: `[ + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "my-deployment", + "namespace": "default" + }, + "spec": { + "replicas": 3, + "selector": { + "matchLabels": { + "app": "my-app" + } + }, + "template": { + "metadata": { + "labels": { + "app": "my-app" + } + }, + "spec": { + "containers": [ + { + "image": "mcr.microsoft.com/a/b/my-image:latest", + "name": "my-container" + } + ] + } + } + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": "my-service-account", + "namespace": "default" + } + } + ]`, }, { - name: "failed synthesis", - workingDir: "fixtures/bad_example_synthesizer", - expectedOutput: `{ - "apiVersion":"config.kubernetes.io/v1", - "kind":"ResourceList", - "items":[], - "results":[ - { - "message":"error updating dependencies: No such file or directory (os error 2)", - "severity":"error" - } - ] - }`, + name: "failed synthesis", + workingDir: "fixtures/bad_example_synthesizer", + expectedErrs: []string{"error updating dependencies", "No such file or directory"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalStdin := os.Stdin - originalStdout := os.Stdout - defer func() { - os.Stdin = originalStdin - os.Stdout = originalStdout - }() - - input, err := os.Open("fixtures/example_input.json") - if err != nil { - t.Fatalf("Failed to open input file: %v", err) - } - defer input.Close() + output, err := Synthesize(tt.workingDir, input) - stdin, w, err := os.Pipe() - if err != nil { - t.Fatalf("Failed to create stdin pipe: %v", err) + // Failure path + if len(tt.expectedErrs) > 0 { + if err == nil { + t.Fatal("Expected error, got nil") + } + for _, substr := range tt.expectedErrs { + if !strings.Contains(err.Error(), substr) { + t.Errorf("Expected error containing %q, got: %v", substr, err) + } + } + if output != nil { + t.Errorf("Expected nil output on error, got %d items", len(output)) + } + return } - os.Stdin = stdin - go func() { - defer w.Close() - io.Copy(w, input) - }() - - r, stdout, err := os.Pipe() + // Success path if err != nil { - t.Fatalf("Failed to create stdout pipe: %v", err) + t.Fatalf("Synthesize returned unexpected error: %v", err) } - os.Stdout = stdout - - Synthesize(tt.workingDir) - stdout.Close() - buf, err := io.ReadAll(r) + outputJSON, err := json.Marshal(output) if err != nil { - t.Fatalf("Failed to read output: %v", err) + t.Fatalf("Failed to marshal output to JSON: %v", err) } - output := string(buf) - - normalizedExpected := normalizeWhitespace(tt.expectedOutput) - normalizedOutput := normalizeWhitespace(output) - if normalizedOutput != normalizedExpected { - t.Errorf("Output mismatch\nExpected:\n%s\nGot:\n%s", normalizedExpected, normalizedOutput) + if normalizeWhitespace(string(outputJSON)) != normalizeWhitespace(tt.expectedOutput) { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", tt.expectedOutput, string(outputJSON)) } }) }