diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index dbb77f99..bebda4ce 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, "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, + }); 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/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 837ce4e6..d05222a4 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, "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, + }); 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/docs/reference.md b/docs/reference.md index cdfadc28..e331ed26 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\. Multi\-document and kind:List supported\. 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\. Multi\-document and kind:List supported\. 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/chart_lint.go b/pkg/action/chart_lint.go index c3de8cc1..a11586c4 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -94,6 +94,12 @@ 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 or JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // 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. NetworkParallelism int @@ -151,6 +157,10 @@ 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") + } + if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -266,6 +276,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, + 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 8e1c5f0c..901d39f2 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -91,6 +91,12 @@ 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 or JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // 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. NetworkParallelism int @@ -158,6 +164,10 @@ 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") + } + if !opts.Remote { opts.ReleaseStorageDriver = common.ReleaseStorageDriverMemory } @@ -269,6 +279,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, + LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 689138e2..57ecb17a 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -14,7 +14,10 @@ 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" @@ -34,6 +37,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" ) @@ -42,20 +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 - 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 { @@ -195,7 +200,15 @@ 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.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)) + } } engine.EnableDNS = opts.TemplatesAllowDNS @@ -272,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, @@ -366,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, ".") } diff --git a/pkg/chart/parse_local_lookup_resources_ai_test.go b/pkg/chart/parse_local_lookup_resources_ai_test.go new file mode 100644 index 00000000..69d69194 --- /dev/null +++ b/pkg/chart/parse_local_lookup_resources_ai_test.go @@ -0,0 +1,163 @@ +//go:build ai_tests + +package chart + +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 +} 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..03803928 --- /dev/null +++ b/pkg/lookup/lookup.go @@ -0,0 +1,102 @@ +package lookup + +import ( + "context" + + "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" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + + "github.com/werf/nelm/pkg/helm/pkg/engine" +) + +var ( + _ engine.ClientProvider = (*LocalClientProvider)(nil) + _ dynamic.NamespaceableResourceInterface = (*emptyListResource)(nil) +) + +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 +// 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)) + + 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, + } +} + +func (p *LocalClientProvider) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) { + gvk := schema.FromAPIVersionAndKind(apiVersion, kind) + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + + 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 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(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) +} + +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(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) +} + +func listEmpty(listGVK schema.GroupVersionKind) (*unstructured.UnstructuredList, error) { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(listGVK) + + return list, nil +} diff --git a/pkg/lookup/lookup_ai_test.go b/pkg/lookup/lookup_ai_test.go new file mode 100644 index 00000000..de518b76 --- /dev/null +++ b/pkg/lookup/lookup_ai_test.go @@ -0,0 +1,138 @@ +//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 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, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + }} + if namespace != "" { + obj.Object["metadata"].(map[string]interface{})["namespace"] = namespace + } + + return obj +}