From b977127480de42fd03c59f2c2ee53a0e5d0afa41 Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Wed, 27 May 2026 15:53:03 +0200 Subject: [PATCH 1/4] Update Plugin API with optional New Resources Update of `crane-lib` to allow transform plugins to generate entirely new Kubernetes resources. The implementation ensures full backward compatibility without the need for a V2 API. **Core Architecture Updates** * **Extended API:** Both `PluginResponse` and `RunnerResponse` now include a `NewResources` field to hold standard Kubernetes unstructured objects. * **Graceful Degradation:** Relies on the JSON `omitempty` tag. Older plugins simply omit this field, which automatically resolves to an empty list without breaking the execution. * **Simplified Runner Logic:** The runner iterates through plugins and aggregates any `NewResources` provided. It avoids complex version validation, relying solely on whether the new resources array contains items. Related to https://github.com/migtools/crane/issues/415 Fixes https://github.com/migtools/crane-lib/issues/139 Signed-off-by: Marek Aufart --- transform/plugin.go | 7 +- transform/runner.go | 8 ++ transform/runner_test.go | 173 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/transform/plugin.go b/transform/plugin.go index 12afbe9..84647fe 100644 --- a/transform/plugin.go +++ b/transform/plugin.go @@ -27,9 +27,10 @@ type PluginRequest struct { } type PluginResponse struct { - Version string `json:"version,omitempty"` - IsWhiteOut bool `json:"isWhiteOut,omitempty"` - Patches jsonpatch.Patch `json:"patches,omitempty"` + Version string `json:"version,omitempty"` + IsWhiteOut bool `json:"isWhiteOut,omitempty"` + Patches jsonpatch.Patch `json:"patches,omitempty"` + NewResources []unstructured.Unstructured `json:"newResources,omitempty"` } type PluginMetadata struct { diff --git a/transform/runner.go b/transform/runner.go index 42a496d..f5be75f 100644 --- a/transform/runner.go +++ b/transform/runner.go @@ -28,6 +28,7 @@ type RunnerResponse struct { TransformFile []byte HaveWhiteOut bool IgnoredPatches []byte + NewResources []unstructured.Unstructured } type PluginOperation struct { @@ -63,6 +64,7 @@ func (r *Runner) Run(object unstructured.Unstructured, plugins []Plugin) (Runner haveWhiteOut := false havePatches := false patches := []PluginOperation{} + newResources := []unstructured.Unstructured{} errs := []error{} for _, plugin := range plugins { @@ -82,11 +84,17 @@ func (r *Runner) Run(object unstructured.Unstructured, plugins []Plugin) (Runner havePatches = true patches = append(patches, PluginOperationsFromPatch(plugin.Metadata().Name, resp.Patches)...) } + if len(resp.NewResources) > 0 { + newResources = append(newResources, resp.NewResources...) + r.Log.Debugf("Plugin %s generated %d new resource(s)", + plugin.Metadata().Name, len(resp.NewResources)) + } } response := RunnerResponse{ TransformFile: []byte(`[]`), HaveWhiteOut: haveWhiteOut, IgnoredPatches: []byte(`[]`), + NewResources: newResources, } // TODO: in the future we should consider a way to speed this up with go routines. diff --git a/transform/runner_test.go b/transform/runner_test.go index ec2d608..3c5f605 100644 --- a/transform/runner_test.go +++ b/transform/runner_test.go @@ -36,6 +36,7 @@ func TestRunnerRun(t *testing.T) { OptionalFlags map[string]string IsWhiteOut bool ShouldError bool + ExpectedNewResources int }{ { Name: "RunWithNoPlugins", @@ -326,6 +327,174 @@ func TestRunnerRun(t *testing.T) { }, PatchesString: `[{"op": "add", "path": "/spec/testing", "value": "testFlagValue"}]`, }, + { + Name: "RunWithPluginGeneratingSingleNewResource", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + newResource := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "shipwright.io/v1beta1", + "kind": "Build", + "metadata": map[string]interface{}{ + "name": "myapp-build", + }, + "spec": map[string]interface{}{}, + }, + } + return PluginResponse{ + NewResources: []unstructured.Unstructured{newResource}, + }, nil + }, + name: "buildconfig-converter", + }, + }, + ExpectedNewResources: 1, + }, + { + Name: "RunWithPluginGeneratingMultipleNewResources", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + resource1 := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "shipwright.io/v1beta1", + "kind": "Build", + "metadata": map[string]interface{}{ + "name": "build-1", + }, + }, + } + resource2 := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "tekton.dev/v1", + "kind": "Pipeline", + "metadata": map[string]interface{}{ + "name": "pipeline-1", + }, + }, + } + resource3 := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "config-1", + }, + }, + } + return PluginResponse{ + NewResources: []unstructured.Unstructured{resource1, resource2, resource3}, + }, nil + }, + name: "multi-resource-generator", + }, + }, + ExpectedNewResources: 3, + }, + { + Name: "RunWithWhiteoutAndNewResource", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + replacement := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "replacement-deployment", + }, + }, + } + return PluginResponse{ + IsWhiteOut: true, + NewResources: []unstructured.Unstructured{replacement}, + }, nil + }, + name: "replacement-plugin", + }, + }, + IsWhiteOut: true, + ExpectedNewResources: 1, + }, + { + Name: "RunWithOldPluginBackwardCompatibility", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + p, err := jsonpatch.DecodePatch([]byte(`[{"op": "add", "path": "/spec/replicas", "value": 3}]`)) + if err != nil { + return PluginResponse{}, err + } + return PluginResponse{ + Version: "v1", + Patches: p, + }, nil + }, + name: "old-plugin", + }, + }, + PatchesString: `[{"op": "add", "path": "/spec/replicas", "value": 3}]`, + }, + { + Name: "RunWithMultiplePluginsGeneratingResources", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + resource := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "service-1", + }, + }, + } + return PluginResponse{ + NewResources: []unstructured.Unstructured{resource}, + }, nil + }, + name: "plugin1", + }, + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + resource := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "configmap-1", + }, + }, + } + return PluginResponse{ + NewResources: []unstructured.Unstructured{resource}, + }, nil + }, + name: "plugin2", + }, + }, + ExpectedNewResources: 2, + }, + { + Name: "RunWithPluginEmptyNewResources", + Object: unstructured.Unstructured{}, + Plugins: []Plugin{ + fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + return PluginResponse{ + NewResources: []unstructured.Unstructured{}, + }, nil + }, + name: "empty-resources-plugin", + }, + }, + }, } for _, c := range cases { @@ -375,6 +544,10 @@ func TestRunnerRun(t *testing.T) { t.Errorf("incorrect plugin operations, actual: %v expected: %v", string(response.IgnoredPatches), c.IgnoredPatchesString) } } + // Verify NewResources count + if len(response.NewResources) != c.ExpectedNewResources { + t.Errorf("incorrect new resources count, actual: %v expected: %v", len(response.NewResources), c.ExpectedNewResources) + } }) } From 0807c63e1ae44d12f5047dfe27862e239bd66edd Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Wed, 27 May 2026 16:58:04 +0200 Subject: [PATCH 2/4] Ensure log is never nil in Runner Signed-off-by: Marek Aufart --- transform/runner.go | 9 +++++++++ transform/runner_test.go | 24 +++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/transform/runner.go b/transform/runner.go index f5be75f..f8915d8 100644 --- a/transform/runner.go +++ b/transform/runner.go @@ -21,6 +21,15 @@ type Runner struct { Log *logrus.Logger } +// NewRunner creates a new Runner with the required logger. +func NewRunner(logger *logrus.Logger, pluginPriorities map[string]int, optionalFlags map[string]string) *Runner { + return &Runner{ + Log: logger, + PluginPriorities: pluginPriorities, + OptionalFlags: optionalFlags, + } +} + // RunnerResponse will be responsble for // TransformFile is a marshaled jsonpatch.Patch // IgnoredPatches is a marshaled []PluginOperation diff --git a/transform/runner_test.go b/transform/runner_test.go index 3c5f605..ea1831e 100644 --- a/transform/runner_test.go +++ b/transform/runner_test.go @@ -499,11 +499,7 @@ func TestRunnerRun(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - runner := Runner{ - Log: logrus.New(), - PluginPriorities: c.PluginPriorities, - OptionalFlags: c.OptionalFlags, - } + runner := NewRunner(logrus.New(), c.PluginPriorities, c.OptionalFlags) response, err := runner.Run(c.Object, c.Plugins) if err != nil && !c.ShouldError { t.Error(err) @@ -552,3 +548,21 @@ func TestRunnerRun(t *testing.T) { } } + +func TestNewRunner(t *testing.T) { + logger := logrus.New() + priorities := map[string]int{"plugin1": 1} + flags := map[string]string{"flag1": "value1"} + + runner := NewRunner(logger, priorities, flags) + + if runner.Log != logger { + t.Error("Log was not set correctly") + } + if runner.PluginPriorities["plugin1"] != 1 { + t.Error("PluginPriorities was not set correctly") + } + if runner.OptionalFlags["flag1"] != "value1" { + t.Error("OptionalFlags was not set correctly") + } +} From cedf87c22d0a1c311afd7d3c42f996a5760629a4 Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Fri, 29 May 2026 15:31:31 +0200 Subject: [PATCH 3/4] Fallback to new Logger if got nil Signed-off-by: Marek Aufart --- transform/runner.go | 4 +++ transform/runner_test.go | 62 +++++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/transform/runner.go b/transform/runner.go index f8915d8..faeba2f 100644 --- a/transform/runner.go +++ b/transform/runner.go @@ -22,7 +22,11 @@ type Runner struct { } // NewRunner creates a new Runner with the required logger. +// If logger is nil, a new default logger is created. func NewRunner(logger *logrus.Logger, pluginPriorities map[string]int, optionalFlags map[string]string) *Runner { + if logger == nil { + logger = logrus.New() + } return &Runner{ Log: logger, PluginPriorities: pluginPriorities, diff --git a/transform/runner_test.go b/transform/runner_test.go index ea1831e..e3e336d 100644 --- a/transform/runner_test.go +++ b/transform/runner_test.go @@ -550,19 +550,55 @@ func TestRunnerRun(t *testing.T) { } func TestNewRunner(t *testing.T) { - logger := logrus.New() - priorities := map[string]int{"plugin1": 1} - flags := map[string]string{"flag1": "value1"} + t.Run("WithLogger", func(t *testing.T) { + logger := logrus.New() + priorities := map[string]int{"plugin1": 1} + flags := map[string]string{"flag1": "value1"} - runner := NewRunner(logger, priorities, flags) + runner := NewRunner(logger, priorities, flags) - if runner.Log != logger { - t.Error("Log was not set correctly") - } - if runner.PluginPriorities["plugin1"] != 1 { - t.Error("PluginPriorities was not set correctly") - } - if runner.OptionalFlags["flag1"] != "value1" { - t.Error("OptionalFlags was not set correctly") - } + if runner.Log != logger { + t.Error("Log was not set correctly") + } + if runner.PluginPriorities["plugin1"] != 1 { + t.Error("PluginPriorities was not set correctly") + } + if runner.OptionalFlags["flag1"] != "value1" { + t.Error("OptionalFlags was not set correctly") + } + }) + + t.Run("WithNilLogger", func(t *testing.T) { + runner := NewRunner(nil, nil, nil) + + if runner.Log == nil { + t.Error("Log should not be nil when nil logger is passed") + } + + // Verify runner can execute without panic + plugin := fakePlugin{ + Func: func(request PluginRequest) (PluginResponse, error) { + newResource := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + } + return PluginResponse{ + NewResources: []unstructured.Unstructured{newResource}, + }, nil + }, + name: "test-plugin", + } + response, err := runner.Run(unstructured.Unstructured{}, []Plugin{plugin}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(response.NewResources) != 1 { + t.Errorf("expected 1 new resource, got %d", len(response.NewResources)) + } + }) } From 8573ded11958d90eee1b91a23fe41133b95f6d2c Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Tue, 2 Jun 2026 10:37:57 +0200 Subject: [PATCH 4/4] Extend resources validation test Signed-off-by: Marek Aufart --- transform/runner_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/transform/runner_test.go b/transform/runner_test.go index e3e336d..2e8b8ef 100644 --- a/transform/runner_test.go +++ b/transform/runner_test.go @@ -26,6 +26,11 @@ func (fp fakePlugin) Metadata() PluginMetadata { } func TestRunnerRun(t *testing.T) { + type expectedResource struct { + APIVersion string + Kind string + Name string + } cases := []struct { Name string Plugins []Plugin @@ -37,6 +42,7 @@ func TestRunnerRun(t *testing.T) { IsWhiteOut bool ShouldError bool ExpectedNewResources int + ExpectedResources []expectedResource }{ { Name: "RunWithNoPlugins", @@ -351,6 +357,9 @@ func TestRunnerRun(t *testing.T) { }, }, ExpectedNewResources: 1, + ExpectedResources: []expectedResource{ + {APIVersion: "shipwright.io/v1beta1", Kind: "Build", Name: "myapp-build"}, + }, }, { Name: "RunWithPluginGeneratingMultipleNewResources", @@ -393,6 +402,11 @@ func TestRunnerRun(t *testing.T) { }, }, ExpectedNewResources: 3, + ExpectedResources: []expectedResource{ + {APIVersion: "shipwright.io/v1beta1", Kind: "Build", Name: "build-1"}, + {APIVersion: "tekton.dev/v1", Kind: "Pipeline", Name: "pipeline-1"}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "config-1"}, + }, }, { Name: "RunWithWhiteoutAndNewResource", @@ -480,6 +494,10 @@ func TestRunnerRun(t *testing.T) { }, }, ExpectedNewResources: 2, + ExpectedResources: []expectedResource{ + {APIVersion: "v1", Kind: "Service", Name: "service-1"}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "configmap-1"}, + }, }, { Name: "RunWithPluginEmptyNewResources", @@ -544,6 +562,27 @@ func TestRunnerRun(t *testing.T) { if len(response.NewResources) != c.ExpectedNewResources { t.Errorf("incorrect new resources count, actual: %v expected: %v", len(response.NewResources), c.ExpectedNewResources) } + // Verify specific resource fields (APIVersion, Kind, Name) + if len(c.ExpectedResources) > 0 { + if len(response.NewResources) != len(c.ExpectedResources) { + t.Errorf("expected %d resources, got %d", len(c.ExpectedResources), len(response.NewResources)) + } + for i, expected := range c.ExpectedResources { + if i >= len(response.NewResources) { + break + } + actual := response.NewResources[i] + if actual.GetAPIVersion() != expected.APIVersion { + t.Errorf("resource[%d]: expected APIVersion %q, got %q", i, expected.APIVersion, actual.GetAPIVersion()) + } + if actual.GetKind() != expected.Kind { + t.Errorf("resource[%d]: expected Kind %q, got %q", i, expected.Kind, actual.GetKind()) + } + if actual.GetName() != expected.Name { + t.Errorf("resource[%d]: expected Name %q, got %q", i, expected.Name, actual.GetName()) + } + } + } }) }