From 62e734fc1eaf8b6ab1c77b3f75b995bd3cf6c64c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 10 Jul 2026 18:36:25 +0300 Subject: [PATCH 01/10] feat: add --lookup-resources flag for offline lookup in chart render/lint Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_render.go | 8 +++ pkg/action/chart_render.go | 36 +++++++++++++ pkg/chart/chart_render.go | 6 +++ pkg/helm/pkg/engine/engine.go | 6 +++ pkg/lookup/lookup.go | 46 ++++++++++++++++ pkg/lookup/lookup_ai_test.go | 98 +++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 pkg/lookup/lookup.go create mode 100644 pkg/lookup/lookup_ai_test.go diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 837ce4e6..a677ca74 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -157,6 +157,14 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Paths to manifest files with resources for the lookup template function to resolve against in non-remote mode", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, + Group: mainFlagGroup, + Type: cli.FlagTypeFile, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.LocalKubeVersion, "kube-version", common.DefaultLocalKubeVersion, "Kubernetes version stub for non-remote mode", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 8e1c5f0c..c326fa0a 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -18,6 +18,7 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/registry" + "github.com/werf/nelm/pkg/helm/pkg/releaseutil" "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" @@ -91,6 +92,10 @@ type ChartRenderOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for template rendering when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string + // LocalLookupResourcesPaths are paths to YAML/JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // Not allowed together with Remote. + LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int @@ -158,6 +163,15 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu lo.Must0(os.Setenv("WERF_SECRET_KEY", opts.SecretKey)) } + if opts.Remote && len(opts.LocalLookupResourcesPaths) > 0 { + return nil, fmt.Errorf("local lookup resources are not allowed together with remote mode") + } + + localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) + if err != nil { + return nil, fmt.Errorf("parse local lookup resources: %w", err) + } + if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -269,6 +283,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, + LocalLookupResources: localLookupResources, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, @@ -448,6 +463,27 @@ func applyChartRenderOptionsDefaults(opts ChartRenderOptions, currentDir, homeDi return opts, nil } +func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { + var resources []*unstructured.Unstructured + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file %q: %w", path, err) + } + + for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { + obj := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(manifest), &obj.Object); err != nil { + return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) + } + + resources = append(resources, obj) + } + } + + return resources, nil +} + func renderResource(unstruct *unstructured.Unstructured, path string, outStream io.Writer, colorLevel color.Level) error { resourceJSONBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, unstruct) if err != nil { diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 689138e2..299976f9 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -14,6 +14,7 @@ import ( "github.com/goccy/go-yaml" "github.com/samber/lo" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/discovery" "github.com/werf/nelm/pkg/common" @@ -34,6 +35,7 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/lookup" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/ts" ) @@ -51,6 +53,7 @@ type RenderChartOptions struct { HelmOptions helmopts.HelmOptions IgnoreBundleJS bool LocalKubeVersion string + LocalLookupResources []*unstructured.Unstructured NoStandaloneCRDs bool Remote bool SubchartNotes bool @@ -196,6 +199,9 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s engine = lo.ToPtr(helmengine.New(clientFactory.KubeConfig().RestConfig)) } else { engine = lo.ToPtr(helmengine.Engine{}) + if len(opts.LocalLookupResources) > 0 { + engine.SetClientProvider(lookup.NewLocalClientProvider(opts.LocalLookupResources)) + } } engine.EnableDNS = opts.TemplatesAllowDNS diff --git a/pkg/helm/pkg/engine/engine.go b/pkg/helm/pkg/engine/engine.go index e12eeed0..1d65f5b5 100644 --- a/pkg/helm/pkg/engine/engine.go +++ b/pkg/helm/pkg/engine/engine.go @@ -61,6 +61,12 @@ func New(config *rest.Config) Engine { } } +// SetClientProvider sets the ClientProvider used to resolve lookup calls. It allows resolving +// lookups against a custom source, such as an in-memory set of objects in offline mode. +func (e *Engine) SetClientProvider(clientProvider ClientProvider) { + e.clientProvider = &clientProvider +} + // Render takes a chart, optional values, and value overrides, and attempts to render the Go templates. // // Render can be called repeatedly on the same engine. diff --git a/pkg/lookup/lookup.go b/pkg/lookup/lookup.go new file mode 100644 index 00000000..117592e2 --- /dev/null +++ b/pkg/lookup/lookup.go @@ -0,0 +1,46 @@ +package lookup + +import ( + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + + "github.com/werf/nelm/pkg/helm/pkg/engine" +) + +var _ engine.ClientProvider = (*LocalClientProvider)(nil) + +type LocalClientProvider struct { + client *fake.FakeDynamicClient + namespaced map[schema.GroupVersionKind]bool +} + +// NewLocalClientProvider returns an engine.ClientProvider that resolves lookup calls against the +// provided in-memory set of Kubernetes objects instead of a live cluster. An empty set makes +// every lookup return an empty result, matching the offline stub behavior. +func NewLocalClientProvider(objects []*unstructured.Unstructured) *LocalClientProvider { + runtimeObjects := make([]runtime.Object, 0, len(objects)) + + namespaced := make(map[schema.GroupVersionKind]bool) + for _, obj := range objects { + runtimeObjects = append(runtimeObjects, obj) + if obj.GetNamespace() != "" { + namespaced[obj.GroupVersionKind()] = true + } + } + + return &LocalClientProvider{ + client: fake.NewSimpleDynamicClient(runtime.NewScheme(), runtimeObjects...), + namespaced: namespaced, + } +} + +func (p *LocalClientProvider) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) { + gvk := schema.FromAPIVersionAndKind(apiVersion, kind) + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + + return p.client.Resource(gvr), p.namespaced[gvk], nil +} diff --git a/pkg/lookup/lookup_ai_test.go b/pkg/lookup/lookup_ai_test.go new file mode 100644 index 00000000..3fb4db29 --- /dev/null +++ b/pkg/lookup/lookup_ai_test.go @@ -0,0 +1,98 @@ +//go:build ai_tests + +package lookup + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/pkg/engine" + "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" +) + +func TestAI_LocalClientProviderEmpty(t *testing.T) { + provider := NewLocalClientProvider(nil) + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chart.File{ + {Name: "templates/empty", Data: []byte(`{{ (lookup "v1" "Pod" "default" "pod1") }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "map[]", out["moby/templates/empty"]) +} + +func TestAI_LocalClientProviderLookup(t *testing.T) { + provider := NewLocalClientProvider([]*unstructured.Unstructured{ + makeUnstructured("v1", "Namespace", "default", ""), + makeUnstructured("v1", "Pod", "pod1", "default"), + makeUnstructured("v1", "Pod", "pod2", "ns1"), + makeUnstructured("v1", "Pod", "pod3", "ns1"), + }) + + templates := map[string]string{ + "cluster-single": `{{ (lookup "v1" "Namespace" "" "default").metadata.name }}`, + "namespaced-get": `{{ (lookup "v1" "Pod" "default" "pod1").metadata.name }}`, + "namespaced-list": `{{ (lookup "v1" "Pod" "ns1" "").items | len }}`, + "all-ns-list": `{{ (lookup "v1" "Pod" "" "").items | len }}`, + "missing-get": `{{ (lookup "v1" "Pod" "" "absent") }}`, + } + expected := map[string]string{ + "cluster-single": "default", + "namespaced-get": "pod1", + "namespaced-list": "2", + "all-ns-list": "3", + "missing-get": "map[]", + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "moby", Version: "1.2.3"}, + Values: map[string]any{}, + } + + for name, tpl := range templates { + c.Templates = append(c.Templates, &chart.File{ + Name: path.Join("templates", name), + Data: []byte(tpl), + }) + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + + for name, want := range expected { + t.Run(name, func(t *testing.T) { + require.Equal(t, want, out[path.Join("moby/templates", name)]) + }) + } +} + +func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + }} + if namespace != "" { + obj.Object["metadata"].(map[string]interface{})["namespace"] = namespace + } + + return obj +} From ed15e8b40f387e9c572b02b86265930f4e6dc66f Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 13 Jul 2026 14:07:16 +0300 Subject: [PATCH 02/10] feat: add --lookup-resources for offline lookup in chart lint Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 8 ++++++++ pkg/action/chart_lint.go | 14 ++++++++++++++ pkg/chart/chart_render.go | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index dbb77f99..a4cf8800 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -175,6 +175,14 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Paths to manifest files with resources for the lookup template function to resolve against in non-remote mode", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, + Group: mainFlagGroup, + Type: cli.FlagTypeFile, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.LocalKubeVersion, "kube-version", common.DefaultLocalKubeVersion, "Kubernetes version stub for non-remote mode", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index c3de8cc1..66ced972 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -94,6 +94,10 @@ type ChartLintOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for linting when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string + // LocalLookupResourcesPaths are paths to YAML/JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // Not allowed together with Remote. + LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int @@ -151,6 +155,15 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { lo.Must0(os.Setenv("WERF_SECRET_KEY", opts.SecretKey)) } + if opts.Remote && len(opts.LocalLookupResourcesPaths) > 0 { + return fmt.Errorf("local lookup resources are not allowed together with remote mode") + } + + localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) + if err != nil { + return fmt.Errorf("parse local lookup resources: %w", err) + } + if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -266,6 +279,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, + LocalLookupResources: localLookupResources, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 299976f9..482f65dc 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -198,7 +198,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s if opts.Remote && clientFactory.KubeClient() != nil { engine = lo.ToPtr(helmengine.New(clientFactory.KubeConfig().RestConfig)) } else { - engine = lo.ToPtr(helmengine.Engine{}) + engine = &helmengine.Engine{} if len(opts.LocalLookupResources) > 0 { engine.SetClientProvider(lookup.NewLocalClientProvider(opts.LocalLookupResources)) } From 5d4ba50b7d46e878fb9d5015393b8d7c2bba8ae9 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 13 Jul 2026 19:35:16 +0300 Subject: [PATCH 03/10] refactor: move parseLocalLookupResources to common.go Signed-off-by: Dmitry Mordvinov --- pkg/action/chart_render.go | 22 ---------------------- pkg/action/common.go | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index c326fa0a..2be5681a 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -18,7 +18,6 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" @@ -463,27 +462,6 @@ func applyChartRenderOptionsDefaults(opts ChartRenderOptions, currentDir, homeDi return opts, nil } -func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { - var resources []*unstructured.Unstructured - for _, path := range paths { - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read file %q: %w", path, err) - } - - for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { - obj := &unstructured.Unstructured{} - if err := yaml.Unmarshal([]byte(manifest), &obj.Object); err != nil { - return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) - } - - resources = append(resources, obj) - } - } - - return resources, nil -} - func renderResource(unstruct *unstructured.Unstructured, path string, outStream io.Writer, colorLevel color.Level) error { resourceJSONBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, unstruct) if err != nil { diff --git a/pkg/action/common.go b/pkg/action/common.go index 52e7ff1b..70b850c7 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -12,9 +12,11 @@ import ( "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/styles" + "github.com/goccy/go-yaml" "github.com/gookit/color" "github.com/samber/lo" "github.com/xo/terminfo" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/werf/kubedog/pkg/informer" "github.com/werf/kubedog/pkg/trackers/dyntracker/logstore" @@ -22,6 +24,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util" "github.com/werf/nelm/pkg/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/releaseutil" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" @@ -115,6 +118,27 @@ func handleBuildPlanErr(ctx context.Context, installPlan *plan.Plan, planErr err log.Default.Warn(ctx, "Plan graph saved to %q for debugging", graphPath) } +func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { + var resources []*unstructured.Unstructured + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file %q: %w", path, err) + } + + for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { + obj := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(manifest), &obj.Object); err != nil { + return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) + } + + resources = append(resources, obj) + } + } + + return resources, nil +} + func printNotes(ctx context.Context, notes string) { if notes == "" { return From 7c6de9d6f7dcdbbbdef5ca110e84378993dc5ef0 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 13 Jul 2026 20:01:15 +0300 Subject: [PATCH 04/10] chore: reword --lookup-resources flag description Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 2 +- cmd/nelm/chart_render.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index a4cf8800..138c59bf 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -175,7 +175,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Paths to manifest files with resources for the lookup template function to resolve against in non-remote mode", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, Group: mainFlagGroup, Type: cli.FlagTypeFile, diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index a677ca74..be7ecac8 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -157,7 +157,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Paths to manifest files with resources for the lookup template function to resolve against in non-remote mode", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, Group: mainFlagGroup, Type: cli.FlagTypeFile, From 927a28debe275d8b58785de59c46929e87f98c75 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 14:13:59 +0300 Subject: [PATCH 05/10] feat: deduplicate lookup resources + change goccy/go-yaml to UniversalDecoder Signed-off-by: Dmitry Mordvinov --- docs/reference.md | 8 ++++++++ pkg/action/common.go | 27 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index cdfadc28..f602d85d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1891,6 +1891,10 @@ nelm chart lint [options...] [chart-dir] Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_LINT\_KUBE\_VERSION +- `--lookup-resources` (default: `[]`) + + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_LINT\_LOOKUP\_RESOURCES\_\* + - `-n`, `--namespace` (default: `"stub-namespace"`) The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_CHART\_LINT\_NAMESPACE @@ -2235,6 +2239,10 @@ nelm chart render [options...] [chart-dir] Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_RENDER\_KUBE\_VERSION +- `--lookup-resources` (default: `[]`) + + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_RENDER\_LOOKUP\_RESOURCES\_\* + - `-n`, `--namespace` (default: `"stub-namespace"`) The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_CHART\_RENDER\_NAMESPACE diff --git a/pkg/action/common.go b/pkg/action/common.go index 70b850c7..20369043 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -12,11 +12,11 @@ import ( "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/styles" - "github.com/goccy/go-yaml" "github.com/gookit/color" "github.com/samber/lo" "github.com/xo/terminfo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes/scheme" "github.com/werf/kubedog/pkg/informer" "github.com/werf/kubedog/pkg/trackers/dyntracker/logstore" @@ -29,6 +29,7 @@ import ( "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" + "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" ) @@ -120,6 +121,9 @@ func handleBuildPlanErr(ctx context.Context, installPlan *plan.Plan, planErr err func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { var resources []*unstructured.Unstructured + + seen := make(map[string]bool) + for _, path := range paths { content, err := os.ReadFile(path) if err != nil { @@ -127,12 +131,27 @@ func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, er } for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { - obj := &unstructured.Unstructured{} - if err := yaml.Unmarshal([]byte(manifest), &obj.Object); err != nil { + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) + if err != nil { return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) } - resources = append(resources, obj) + unstruct := obj.(*unstructured.Unstructured) + + if unstruct.GetAPIVersion() == "" { + return nil, fmt.Errorf("parse file %q (document %d): apiVersion is missing", path, i) + } + + gvk := unstruct.GroupVersionKind() + id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + + if seen[id] { + return nil, fmt.Errorf("parse file %q (document %d): duplicate resource %s", path, i, spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + } + + seen[id] = true + + resources = append(resources, unstruct) } } From c7fcf0cc0f95db41fdd0fdc5f698d90091ada46d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 15:51:05 +0300 Subject: [PATCH 06/10] fix: return empty list for un-stubbed offline lookup kinds Signed-off-by: Dmitry Mordvinov --- pkg/lookup/lookup.go | 69 ++++++++++++++++++++++++++++++++++-- pkg/lookup/lookup_ai_test.go | 40 +++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/pkg/lookup/lookup.go b/pkg/lookup/lookup.go index 117592e2..2ac1be4b 100644 --- a/pkg/lookup/lookup.go +++ b/pkg/lookup/lookup.go @@ -1,7 +1,12 @@ package lookup import ( + "context" + "fmt" + "strings" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -11,7 +16,10 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/engine" ) -var _ engine.ClientProvider = (*LocalClientProvider)(nil) +var ( + _ engine.ClientProvider = (*LocalClientProvider)(nil) + _ dynamic.NamespaceableResourceInterface = (*emptyListResource)(nil) +) type LocalClientProvider struct { client *fake.FakeDynamicClient @@ -42,5 +50,62 @@ func (p *LocalClientProvider) GetClientFor(apiVersion, kind string) (dynamic.Nam gvk := schema.FromAPIVersionAndKind(apiVersion, kind) gvr, _ := meta.UnsafeGuessKindToResource(gvk) - return p.client.Resource(gvr), p.namespaced[gvk], nil + return &emptyListResource{ + NamespaceableResourceInterface: p.client.Resource(gvr), + listGVK: gvk.GroupVersion().WithKind(gvk.Kind + "List"), + }, p.namespaced[gvk], nil +} + +// emptyListResource wraps a fake dynamic resource interface so that a LIST for a kind not registered +// with the fake client returns an empty list instead of panicking with the fake's +// "you must register resource to list kind" coding error, preserving the offline stub semantics +// where a lookup for an absent kind yields an empty result. +type emptyListResource struct { + dynamic.NamespaceableResourceInterface + + listGVK schema.GroupVersionKind +} + +func (r *emptyListResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listOrEmpty(r.NamespaceableResourceInterface, r.listGVK, ctx, opts) +} + +func (r *emptyListResource) Namespace(namespace string) dynamic.ResourceInterface { + return &emptyListNamespacedResource{ + ResourceInterface: r.NamespaceableResourceInterface.Namespace(namespace), + listGVK: r.listGVK, + } +} + +type emptyListNamespacedResource struct { + dynamic.ResourceInterface + + listGVK schema.GroupVersionKind +} + +func (r *emptyListNamespacedResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listOrEmpty(r.ResourceInterface, r.listGVK, ctx, opts) +} + +func listOrEmpty(delegate dynamic.ResourceInterface, listGVK schema.GroupVersionKind, ctx context.Context, opts metav1.ListOptions) (list *unstructured.UnstructuredList, err error) { + defer func() { + if rec := recover(); rec != nil { + msg, ok := rec.(string) + if !ok || !strings.Contains(msg, "you must register resource to list kind") { + panic(rec) + } + + list = &unstructured.UnstructuredList{} + list.SetGroupVersionKind(listGVK) + + err = nil + } + }() + + list, err = delegate.List(ctx, opts) + if err != nil { + return nil, fmt.Errorf("list resources: %w", err) + } + + return list, nil } diff --git a/pkg/lookup/lookup_ai_test.go b/pkg/lookup/lookup_ai_test.go index 3fb4db29..de518b76 100644 --- a/pkg/lookup/lookup_ai_test.go +++ b/pkg/lookup/lookup_ai_test.go @@ -82,6 +82,46 @@ func TestAI_LocalClientProviderLookup(t *testing.T) { } } +func TestAI_LocalClientProviderUnstubbedListEmptyProvider(t *testing.T) { + provider := NewLocalClientProvider(nil) + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chart.File{ + {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "Pod" "" "").items | len }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "0", out["moby/templates/list"]) +} + +func TestAI_LocalClientProviderUnstubbedListOtherKind(t *testing.T) { + provider := NewLocalClientProvider([]*unstructured.Unstructured{ + makeUnstructured("v1", "Pod", "pod1", "default"), + }) + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chart.File{ + {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "ConfigMap" "" "").items | len }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "0", out["moby/templates/list"]) +} + func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { obj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": apiVersion, From 4d199158e1949779ca01e94c41aebb18eee63176 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 15:51:11 +0300 Subject: [PATCH 07/10] fix: expand kind:List documents in --lookup-resources Signed-off-by: Dmitry Mordvinov --- pkg/action/common.go | 48 +++++++++-- pkg/action/common_ai_test.go | 163 +++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 pkg/action/common_ai_test.go diff --git a/pkg/action/common.go b/pkg/action/common.go index 20369043..8cfafeec 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -16,6 +16,7 @@ import ( "github.com/samber/lo" "github.com/xo/terminfo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "github.com/werf/kubedog/pkg/informer" @@ -138,26 +139,55 @@ func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, er unstruct := obj.(*unstructured.Unstructured) - if unstruct.GetAPIVersion() == "" { - return nil, fmt.Errorf("parse file %q (document %d): apiVersion is missing", path, i) - } + if unstruct.IsList() { + item := 0 + + if err := unstruct.EachListItem(func(o runtime.Object) error { + res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) + if err != nil { + return err + } - gvk := unstruct.GroupVersionKind() - id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + item++ + resources = append(resources, res) - if seen[id] { - return nil, fmt.Errorf("parse file %q (document %d): duplicate resource %s", path, i, spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + return nil + }); err != nil { + return nil, fmt.Errorf("parse file %q (document %d, item %d): %w", path, i, item, err) + } + + continue } - seen[id] = true + res, err := collectLocalLookupResource(unstruct, seen) + if err != nil { + return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) + } - resources = append(resources, unstruct) + resources = append(resources, res) } } return resources, nil } +func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { + if unstruct.GetAPIVersion() == "" { + return nil, fmt.Errorf("apiVersion is missing") + } + + gvk := unstruct.GroupVersionKind() + id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + + if seen[id] { + return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + } + + seen[id] = true + + return unstruct, nil +} + func printNotes(ctx context.Context, notes string) { if notes == "" { return diff --git a/pkg/action/common_ai_test.go b/pkg/action/common_ai_test.go new file mode 100644 index 00000000..08437cf4 --- /dev/null +++ b/pkg/action/common_ai_test.go @@ -0,0 +1,163 @@ +//go:build ai_tests + +package action + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAI_ParseLocalLookupResourcesDuplicateAfterExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate resource") + require.Contains(t, err.Error(), "item 0") +} + +func TestAI_ParseLocalLookupResourcesListExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default + - apiVersion: v1 + kind: Pod + metadata: + name: pod2 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "pod1", resources[0].GetName()) + require.Equal(t, "pod2", resources[1].GetName()) + for _, r := range resources { + require.Equal(t, "Pod", r.GetKind()) + } +} + +func TestAI_ParseLocalLookupResourcesListItemMissingAPIVersion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: List +items: + - kind: Pod + metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "apiVersion is missing") + require.Contains(t, err.Error(), "item 0") +} + +func TestAI_ParseLocalLookupResourcesMultiDoc(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "Pod", resources[0].GetKind()) + require.Equal(t, "ConfigMap", resources[1].GetKind()) +} + +func TestAI_ParseLocalLookupResourcesTopLevelDuplicate(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate resource") +} + +func TestAI_ParseLocalLookupResourcesTopLevelMissingAPIVersion(t *testing.T) { + path := writeLocalLookupFile(t, ` +kind: Pod +metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "apiVersion is missing") +} + +func TestAI_ParseLocalLookupResourcesTypedListExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: PodList +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, "Pod", resources[0].GetKind()) + require.Equal(t, "pod1", resources[0].GetName()) +} + +func writeLocalLookupFile(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "resources.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + return path +} From 5394822c735fc386453f8e5fab46aa97e64c2bfd Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 18:03:06 +0300 Subject: [PATCH 08/10] fix: support List kinds in local lookup resources Signed-off-by: Dmitry Mordvinov --- pkg/action/chart_lint.go | 7 +- pkg/action/chart_render.go | 7 +- pkg/action/common.go | 73 ------------ pkg/chart/chart_render.go | 109 +++++++++++++++--- .../parse_local_lookup_resources_ai_test.go} | 2 +- pkg/lookup/lookup.go | 6 +- 6 files changed, 98 insertions(+), 106 deletions(-) rename pkg/{action/common_ai_test.go => chart/parse_local_lookup_resources_ai_test.go} (99%) diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 66ced972..7fbd968e 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -159,11 +159,6 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { return fmt.Errorf("local lookup resources are not allowed together with remote mode") } - localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) - if err != nil { - return fmt.Errorf("parse local lookup resources: %w", err) - } - if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -279,7 +274,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, - LocalLookupResources: localLookupResources, + LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 2be5681a..a5e24360 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -166,11 +166,6 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu return nil, fmt.Errorf("local lookup resources are not allowed together with remote mode") } - localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) - if err != nil { - return nil, fmt.Errorf("parse local lookup resources: %w", err) - } - if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -282,7 +277,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, - LocalLookupResources: localLookupResources, + LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, diff --git a/pkg/action/common.go b/pkg/action/common.go index 8cfafeec..52e7ff1b 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -15,9 +15,6 @@ import ( "github.com/gookit/color" "github.com/samber/lo" "github.com/xo/terminfo" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" "github.com/werf/kubedog/pkg/informer" "github.com/werf/kubedog/pkg/trackers/dyntracker/logstore" @@ -25,12 +22,10 @@ import ( kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util" "github.com/werf/nelm/pkg/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" - "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" ) @@ -120,74 +115,6 @@ func handleBuildPlanErr(ctx context.Context, installPlan *plan.Plan, planErr err log.Default.Warn(ctx, "Plan graph saved to %q for debugging", graphPath) } -func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { - var resources []*unstructured.Unstructured - - seen := make(map[string]bool) - - for _, path := range paths { - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read file %q: %w", path, err) - } - - for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { - obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) - if err != nil { - return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) - } - - unstruct := obj.(*unstructured.Unstructured) - - if unstruct.IsList() { - item := 0 - - if err := unstruct.EachListItem(func(o runtime.Object) error { - res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) - if err != nil { - return err - } - - item++ - resources = append(resources, res) - - return nil - }); err != nil { - return nil, fmt.Errorf("parse file %q (document %d, item %d): %w", path, i, item, err) - } - - continue - } - - res, err := collectLocalLookupResource(unstruct, seen) - if err != nil { - return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) - } - - resources = append(resources, res) - } - } - - return resources, nil -} - -func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { - if unstruct.GetAPIVersion() == "" { - return nil, fmt.Errorf("apiVersion is missing") - } - - gvk := unstruct.GroupVersionKind() - id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) - - if seen[id] { - return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) - } - - seen[id] = true - - return unstruct, nil -} - func printNotes(ctx context.Context, notes string) { if notes == "" { return diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 482f65dc..bede7a00 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -15,7 +15,9 @@ import ( "github.com/goccy/go-yaml" "github.com/samber/lo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes/scheme" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" @@ -44,21 +46,21 @@ type RenderChartOptions struct { common.ChartRepoConnectionOptions common.ValuesOptions - ChartProvenanceKeyring string - ChartProvenanceStrategy string - ChartRepoNoUpdate bool - ChartVersion string - DenoBinaryPath string - ExtraAPIVersions []string - HelmOptions helmopts.HelmOptions - IgnoreBundleJS bool - LocalKubeVersion string - LocalLookupResources []*unstructured.Unstructured - NoStandaloneCRDs bool - Remote bool - SubchartNotes bool - TempDirPath string - TemplatesAllowDNS bool + ChartProvenanceKeyring string + ChartProvenanceStrategy string + ChartRepoNoUpdate bool + ChartVersion string + DenoBinaryPath string + ExtraAPIVersions []string + HelmOptions helmopts.HelmOptions + IgnoreBundleJS bool + LocalKubeVersion string + LocalLookupResourcesPaths []string + NoStandaloneCRDs bool + Remote bool + SubchartNotes bool + TempDirPath string + TemplatesAllowDNS bool } type RenderChartResult struct { @@ -199,8 +201,13 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s engine = lo.ToPtr(helmengine.New(clientFactory.KubeConfig().RestConfig)) } else { engine = &helmengine.Engine{} - if len(opts.LocalLookupResources) > 0 { - engine.SetClientProvider(lookup.NewLocalClientProvider(opts.LocalLookupResources)) + if len(opts.LocalLookupResourcesPaths) > 0 { + localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) + if err != nil { + return nil, fmt.Errorf("parse local lookup resources: %w", err) + } + + engine.SetClientProvider(lookup.NewLocalClientProvider(localLookupResources)) } } @@ -433,3 +440,71 @@ func validateChart(ctx context.Context, chart *helmchart.Chart) error { return nil } + +func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { + var resources []*unstructured.Unstructured + + seen := make(map[string]bool) + + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file %q: %w", path, err) + } + + for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) + if err != nil { + return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) + } + + unstruct := obj.(*unstructured.Unstructured) + + if unstruct.IsList() { + item := 0 + + if err := unstruct.EachListItem(func(o runtime.Object) error { + res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) + if err != nil { + return err + } + + item++ + resources = append(resources, res) + + return nil + }); err != nil { + return nil, fmt.Errorf("parse file %q (document %d, item %d): %w", path, i, item, err) + } + + continue + } + + res, err := collectLocalLookupResource(unstruct, seen) + if err != nil { + return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) + } + + resources = append(resources, res) + } + } + + return resources, nil +} + +func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { + if unstruct.GetAPIVersion() == "" { + return nil, fmt.Errorf("apiVersion is missing") + } + + gvk := unstruct.GroupVersionKind() + id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + + if seen[id] { + return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + } + + seen[id] = true + + return unstruct, nil +} diff --git a/pkg/action/common_ai_test.go b/pkg/chart/parse_local_lookup_resources_ai_test.go similarity index 99% rename from pkg/action/common_ai_test.go rename to pkg/chart/parse_local_lookup_resources_ai_test.go index 08437cf4..69d69194 100644 --- a/pkg/action/common_ai_test.go +++ b/pkg/chart/parse_local_lookup_resources_ai_test.go @@ -1,6 +1,6 @@ //go:build ai_tests -package action +package chart import ( "os" diff --git a/pkg/lookup/lookup.go b/pkg/lookup/lookup.go index 2ac1be4b..9271861f 100644 --- a/pkg/lookup/lookup.go +++ b/pkg/lookup/lookup.go @@ -67,7 +67,7 @@ type emptyListResource struct { } func (r *emptyListResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { - return listOrEmpty(r.NamespaceableResourceInterface, r.listGVK, ctx, opts) + return listOrEmpty(ctx, r.NamespaceableResourceInterface, r.listGVK, opts) } func (r *emptyListResource) Namespace(namespace string) dynamic.ResourceInterface { @@ -84,10 +84,10 @@ type emptyListNamespacedResource struct { } func (r *emptyListNamespacedResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { - return listOrEmpty(r.ResourceInterface, r.listGVK, ctx, opts) + return listOrEmpty(ctx, r.ResourceInterface, r.listGVK, opts) } -func listOrEmpty(delegate dynamic.ResourceInterface, listGVK schema.GroupVersionKind, ctx context.Context, opts metav1.ListOptions) (list *unstructured.UnstructuredList, err error) { +func listOrEmpty(ctx context.Context, delegate dynamic.ResourceInterface, listGVK schema.GroupVersionKind, opts metav1.ListOptions) (list *unstructured.UnstructuredList, err error) { defer func() { if rec := recover(); rec != nil { msg, ok := rec.(string) From 72fe9f30c6472ad1a9692260ebf6156fc7af9e8a Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 19:31:56 +0300 Subject: [PATCH 09/10] fix: prevent panic on unregistered list kinds Signed-off-by: Dmitry Mordvinov --- pkg/chart/chart_render.go | 136 +++++++++++++++++++------------------- pkg/lookup/lookup.go | 57 +++++++--------- 2 files changed, 92 insertions(+), 101 deletions(-) diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index bede7a00..57ecb17a 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -285,6 +285,57 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } +func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { + var resources []*unstructured.Unstructured + + seen := make(map[string]bool) + + for _, filePath := range paths { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file %q: %w", filePath, err) + } + + for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) + if err != nil { + return nil, fmt.Errorf("decode resource #%d for %q: %w", i+1, filePath, err) + } + + unstruct := obj.(*unstructured.Unstructured) + + if unstruct.IsList() { + item := 0 + + if err := unstruct.EachListItem(func(o runtime.Object) error { + res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) + if err != nil { + return err + } + + item++ + resources = append(resources, res) + + return nil + }); err != nil { + return nil, fmt.Errorf("collect resource #%d for %q (item %d): %w", i+1, filePath, item, err) + } + + continue + } + + res, err := collectLocalLookupResource(unstruct, seen) + if err != nil { + return nil, fmt.Errorf("collect resource #%d for %q: %w", i+1, filePath, err) + } + + resources = append(resources, res) + } + } + + return resources, nil +} + func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartutil.Capabilities, error) { capabilities := &chartutil.Capabilities{ HelmVersion: chartutil.DefaultCapabilities.HelmVersion, @@ -379,6 +430,23 @@ func buildContextFromJSONSets(jsonSets []string) (map[string]interface{}, error) return context, nil } +func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { + if unstruct.GetAPIVersion() == "" { + return nil, fmt.Errorf("apiVersion is missing") + } + + gvk := unstruct.GroupVersionKind() + id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + + if seen[id] { + return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + } + + seen[id] = true + + return unstruct, nil +} + func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } @@ -440,71 +508,3 @@ func validateChart(ctx context.Context, chart *helmchart.Chart) error { return nil } - -func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { - var resources []*unstructured.Unstructured - - seen := make(map[string]bool) - - for _, path := range paths { - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read file %q: %w", path, err) - } - - for i, manifest := range releaseutil.SplitManifestsToSlice(string(content)) { - obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) - if err != nil { - return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) - } - - unstruct := obj.(*unstructured.Unstructured) - - if unstruct.IsList() { - item := 0 - - if err := unstruct.EachListItem(func(o runtime.Object) error { - res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) - if err != nil { - return err - } - - item++ - resources = append(resources, res) - - return nil - }); err != nil { - return nil, fmt.Errorf("parse file %q (document %d, item %d): %w", path, i, item, err) - } - - continue - } - - res, err := collectLocalLookupResource(unstruct, seen) - if err != nil { - return nil, fmt.Errorf("parse file %q (document %d): %w", path, i, err) - } - - resources = append(resources, res) - } - } - - return resources, nil -} - -func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { - if unstruct.GetAPIVersion() == "" { - return nil, fmt.Errorf("apiVersion is missing") - } - - gvk := unstruct.GroupVersionKind() - id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) - - if seen[id] { - return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) - } - - seen[id] = true - - return unstruct, nil -} diff --git a/pkg/lookup/lookup.go b/pkg/lookup/lookup.go index 9271861f..03803928 100644 --- a/pkg/lookup/lookup.go +++ b/pkg/lookup/lookup.go @@ -2,8 +2,6 @@ package lookup import ( "context" - "fmt" - "strings" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,6 +22,7 @@ var ( type LocalClientProvider struct { client *fake.FakeDynamicClient namespaced map[schema.GroupVersionKind]bool + registered map[schema.GroupVersionKind]bool } // NewLocalClientProvider returns an engine.ClientProvider that resolves lookup calls against the @@ -32,17 +31,22 @@ type LocalClientProvider struct { func NewLocalClientProvider(objects []*unstructured.Unstructured) *LocalClientProvider { runtimeObjects := make([]runtime.Object, 0, len(objects)) + registered := make(map[schema.GroupVersionKind]bool) + namespaced := make(map[schema.GroupVersionKind]bool) for _, obj := range objects { runtimeObjects = append(runtimeObjects, obj) if obj.GetNamespace() != "" { namespaced[obj.GroupVersionKind()] = true } + + registered[obj.GroupVersionKind()] = true } return &LocalClientProvider{ client: fake.NewSimpleDynamicClient(runtime.NewScheme(), runtimeObjects...), namespaced: namespaced, + registered: registered, } } @@ -50,24 +54,27 @@ func (p *LocalClientProvider) GetClientFor(apiVersion, kind string) (dynamic.Nam gvk := schema.FromAPIVersionAndKind(apiVersion, kind) gvr, _ := meta.UnsafeGuessKindToResource(gvk) - return &emptyListResource{ - NamespaceableResourceInterface: p.client.Resource(gvr), - listGVK: gvk.GroupVersion().WithKind(gvk.Kind + "List"), - }, p.namespaced[gvk], nil + if !p.registered[gvk] { + return &emptyListResource{ + NamespaceableResourceInterface: p.client.Resource(gvr), + listGVK: gvk.GroupVersion().WithKind(gvk.Kind + "List"), + }, p.namespaced[gvk], nil + } + + return p.client.Resource(gvr), p.namespaced[gvk], nil } -// emptyListResource wraps a fake dynamic resource interface so that a LIST for a kind not registered -// with the fake client returns an empty list instead of panicking with the fake's -// "you must register resource to list kind" coding error, preserving the offline stub semantics -// where a lookup for an absent kind yields an empty result. +// emptyListResource wraps a fake dynamic resource interface for a kind with no registered objects, +// so that List returns an empty list (the fake client would otherwise panic for an unregistered +// list kind), matching the offline stub semantics where an absent kind yields an empty result. type emptyListResource struct { dynamic.NamespaceableResourceInterface listGVK schema.GroupVersionKind } -func (r *emptyListResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { - return listOrEmpty(ctx, r.NamespaceableResourceInterface, r.listGVK, opts) +func (r *emptyListResource) List(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) } func (r *emptyListResource) Namespace(namespace string) dynamic.ResourceInterface { @@ -83,29 +90,13 @@ type emptyListNamespacedResource struct { listGVK schema.GroupVersionKind } -func (r *emptyListNamespacedResource) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { - return listOrEmpty(ctx, r.ResourceInterface, r.listGVK, opts) +func (r *emptyListNamespacedResource) List(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) } -func listOrEmpty(ctx context.Context, delegate dynamic.ResourceInterface, listGVK schema.GroupVersionKind, opts metav1.ListOptions) (list *unstructured.UnstructuredList, err error) { - defer func() { - if rec := recover(); rec != nil { - msg, ok := rec.(string) - if !ok || !strings.Contains(msg, "you must register resource to list kind") { - panic(rec) - } - - list = &unstructured.UnstructuredList{} - list.SetGroupVersionKind(listGVK) - - err = nil - } - }() - - list, err = delegate.List(ctx, opts) - if err != nil { - return nil, fmt.Errorf("list resources: %w", err) - } +func listEmpty(listGVK schema.GroupVersionKind) (*unstructured.UnstructuredList, error) { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(listGVK) return list, nil } From 90afd45f1016d52b66c03fb29910c17fcaa23583 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 14 Jul 2026 20:20:37 +0300 Subject: [PATCH 10/10] docs: update --lookup-resources flag description Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 2 +- cmd/nelm/chart_render.go | 2 +- docs/reference.md | 4 ++-- pkg/action/chart_lint.go | 6 ++++-- pkg/action/chart_render.go | 6 ++++-- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index 138c59bf..bebda4ce 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -175,7 +175,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode. Multi-document and kind:List supported", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, Group: mainFlagGroup, Type: cli.FlagTypeFile, diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index be7ecac8..d05222a4 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -157,7 +157,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode. Multi-document and kind:List supported", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, Group: mainFlagGroup, Type: cli.FlagTypeFile, diff --git a/docs/reference.md b/docs/reference.md index f602d85d..e331ed26 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1893,7 +1893,7 @@ nelm chart lint [options...] [chart-dir] - `--lookup-resources` (default: `[]`) - Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_LINT\_LOOKUP\_RESOURCES\_\* + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Multi\-document and kind:List supported\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_LINT\_LOOKUP\_RESOURCES\_\* - `-n`, `--namespace` (default: `"stub-namespace"`) @@ -2241,7 +2241,7 @@ nelm chart render [options...] [chart-dir] - `--lookup-resources` (default: `[]`) - Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_RENDER\_LOOKUP\_RESOURCES\_\* + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Multi\-document and kind:List supported\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_RENDER\_LOOKUP\_RESOURCES\_\* - `-n`, `--namespace` (default: `"stub-namespace"`) diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 7fbd968e..a11586c4 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -94,9 +94,11 @@ type ChartLintOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for linting when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string - // LocalLookupResourcesPaths are paths to YAML/JSON manifest files whose resources the "lookup" + // LocalLookupResourcesPaths are paths to YAML or JSON manifest files whose resources the "lookup" // template function resolves against in local mode (Remote=false), instead of a live cluster. - // Not allowed together with Remote. + // Each file may hold multiple documents separated by "---" and/or a kind: List (or typed *List) + // wrapper whose items are expanded; a bare top-level JSON array is not supported. Duplicate + // resources are rejected. Not allowed together with Remote. LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index a5e24360..901d39f2 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -91,9 +91,11 @@ type ChartRenderOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for template rendering when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string - // LocalLookupResourcesPaths are paths to YAML/JSON manifest files whose resources the "lookup" + // LocalLookupResourcesPaths are paths to YAML or JSON manifest files whose resources the "lookup" // template function resolves against in local mode (Remote=false), instead of a live cluster. - // Not allowed together with Remote. + // Each file may hold multiple documents separated by "---" and/or a kind: List (or typed *List) + // wrapper whose items are expanded; a bare top-level JSON array is not supported. Duplicate + // resources are rejected. Not allowed together with Remote. LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0.