-
Notifications
You must be signed in to change notification settings - Fork 23
fix(commands): render templates online in apply to resolve lookups #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -112,14 +112,33 @@ func apply(args []string) error { | |||||||||||||
| // Resolve secrets.yaml path relative to project root if not absolute | ||||||||||||||
| withSecretsPath := ResolveSecretsPath(applyCmdFlags.withSecrets) | ||||||||||||||
|
|
||||||||||||||
| var result []byte | ||||||||||||||
| if len(modelineTemplates) > 0 { | ||||||||||||||
| // Template rendering path: render templates via Helm engine to produce full config | ||||||||||||||
| // Template rendering path: connect to the node first, render templates | ||||||||||||||
| // online (so lookup() functions resolve real discovery data), then apply. | ||||||||||||||
| opts := buildApplyRenderOptions(modelineTemplates, withSecretsPath) | ||||||||||||||
| result, err = engine.Render(ctx, nil, opts) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("template rendering error: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| err = withApplyClient(func(ctx context.Context, c *client.Client) error { | ||||||||||||||
| fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints) | ||||||||||||||
|
|
||||||||||||||
| result, err := engine.Render(ctx, c, opts) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("template rendering error: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ | ||||||||||||||
| Data: result, | ||||||||||||||
| Mode: applyCmdFlags.Mode.Mode, | ||||||||||||||
| DryRun: applyCmdFlags.dryRun, | ||||||||||||||
| TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), | ||||||||||||||
| }) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("error applying new configuration: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+134
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| helpers.PrintApplyResults(resp) | ||||||||||||||
|
|
||||||||||||||
| return nil | ||||||||||||||
| }) | ||||||||||||||
| } else { | ||||||||||||||
| // Direct patch path: apply config file as patch against empty bundle | ||||||||||||||
| opts := buildApplyPatchOptions(withSecretsPath) | ||||||||||||||
|
|
@@ -129,46 +148,31 @@ func apply(args []string) error { | |||||||||||||
| return fmt.Errorf("full config processing error: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| result, err = engine.SerializeConfiguration(configBundle, machineType) | ||||||||||||||
| result, err := engine.SerializeConfiguration(configBundle, machineType) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("error serializing configuration: %w", err) | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| withClient := func(f func(ctx context.Context, c *client.Client) error) error { | ||||||||||||||
| if applyCmdFlags.insecure { | ||||||||||||||
| // Maintenance mode connects directly to the node IP without talosconfig; | ||||||||||||||
| // node context injection is not needed — the maintenance client handles | ||||||||||||||
| // node targeting internally via GlobalArgs.Nodes. | ||||||||||||||
| return WithClientMaintenance(applyCmdFlags.certFingerprints, f) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| wrappedF := wrapWithNodeContext(f) | ||||||||||||||
|
|
||||||||||||||
| if GlobalArgs.SkipVerify { | ||||||||||||||
| return WithClientSkipVerify(wrappedF) | ||||||||||||||
| } | ||||||||||||||
| if err := withApplyClient(func(ctx context.Context, c *client.Client) error { | ||||||||||||||
| fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints) | ||||||||||||||
|
|
||||||||||||||
| return WithClientNoNodes(wrappedF) | ||||||||||||||
| } | ||||||||||||||
| resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ | ||||||||||||||
| Data: result, | ||||||||||||||
| Mode: applyCmdFlags.Mode.Mode, | ||||||||||||||
| DryRun: applyCmdFlags.dryRun, | ||||||||||||||
| TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), | ||||||||||||||
| }) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("error applying new configuration: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| err = withClient(func(ctx context.Context, c *client.Client) error { | ||||||||||||||
| fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints) | ||||||||||||||
| helpers.PrintApplyResults(resp) | ||||||||||||||
|
|
||||||||||||||
| resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ | ||||||||||||||
| Data: result, | ||||||||||||||
| Mode: applyCmdFlags.Mode.Mode, | ||||||||||||||
| DryRun: applyCmdFlags.dryRun, | ||||||||||||||
| TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), | ||||||||||||||
| }) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("error applying new configuration: %s", err) | ||||||||||||||
| return nil | ||||||||||||||
| }); err != nil { | ||||||||||||||
| return err | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| helpers.PrintApplyResults(resp) | ||||||||||||||
|
|
||||||||||||||
| return nil | ||||||||||||||
| }) | ||||||||||||||
| } | ||||||||||||||
| if err != nil { | ||||||||||||||
| return err | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -184,19 +188,38 @@ func apply(args []string) error { | |||||||||||||
| return nil | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // withApplyClient creates a Talos client appropriate for the current apply mode | ||||||||||||||
| // and invokes the given action with it. | ||||||||||||||
| func withApplyClient(f func(ctx context.Context, c *client.Client) error) error { | ||||||||||||||
| if applyCmdFlags.insecure { | ||||||||||||||
| // Maintenance mode connects directly to the node IP without talosconfig; | ||||||||||||||
| // node context injection is not needed — the maintenance client handles | ||||||||||||||
| // node targeting internally via GlobalArgs.Nodes. | ||||||||||||||
| return WithClientMaintenance(applyCmdFlags.certFingerprints, f) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| wrappedF := wrapWithNodeContext(f) | ||||||||||||||
|
|
||||||||||||||
| if GlobalArgs.SkipVerify { | ||||||||||||||
| return WithClientSkipVerify(wrappedF) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return WithClientNoNodes(wrappedF) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // buildApplyRenderOptions constructs engine.Options for the template rendering path. | ||||||||||||||
| // Offline is set to true because at this point we don't have a Talos client for | ||||||||||||||
| // Helm lookup functions. Templates that use lookup() should be rendered via | ||||||||||||||
| // 'talm template' which supports online mode. | ||||||||||||||
| // Offline is false because templates need a live Talos client for lookup() functions | ||||||||||||||
| // (e.g., discovering interface names, addresses, routes). The caller creates the | ||||||||||||||
| // client and passes it to engine.Render together with these options. | ||||||||||||||
| func buildApplyRenderOptions(modelineTemplates []string, withSecretsPath string) engine.Options { | ||||||||||||||
| resolvedTemplates := resolveTemplatePaths(modelineTemplates, Config.RootDir) | ||||||||||||||
| return engine.Options{ | ||||||||||||||
| Insecure: applyCmdFlags.insecure, | ||||||||||||||
| TalosVersion: applyCmdFlags.talosVersion, | ||||||||||||||
| WithSecrets: withSecretsPath, | ||||||||||||||
| KubernetesVersion: applyCmdFlags.kubernetesVersion, | ||||||||||||||
| Debug: applyCmdFlags.debug, | ||||||||||||||
| Full: true, | ||||||||||||||
| Offline: true, | ||||||||||||||
| Root: Config.RootDir, | ||||||||||||||
| TemplateFiles: resolvedTemplates, | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| // Copyright Cozystack Authors | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package engine | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| helmEngine "github.com/cozystack/talm/pkg/engine/helm" | ||
| "helm.sh/helm/v3/pkg/chart/loader" | ||
| ) | ||
|
|
||
| // createTestChart creates a minimal Helm chart in a temp directory with the | ||
| // given template content. Returns the chart root path. | ||
| func createTestChart(t *testing.T, chartName, templateName, templateContent string) string { | ||
| t.Helper() | ||
| root := t.TempDir() | ||
|
|
||
| chartYAML := "apiVersion: v2\nname: " + chartName + "\ntype: application\nversion: 0.1.0\n" | ||
| if err := os.WriteFile(filepath.Join(root, "Chart.yaml"), []byte(chartYAML), 0o644); err != nil { | ||
| t.Fatalf("write Chart.yaml: %v", err) | ||
| } | ||
|
|
||
| if err := os.WriteFile(filepath.Join(root, "values.yaml"), []byte("{}\n"), 0o644); err != nil { | ||
| t.Fatalf("write values.yaml: %v", err) | ||
| } | ||
|
|
||
| templatesDir := filepath.Join(root, "templates") | ||
| if err := os.MkdirAll(templatesDir, 0o755); err != nil { | ||
| t.Fatalf("mkdir templates: %v", err) | ||
| } | ||
| if err := os.WriteFile(filepath.Join(templatesDir, templateName), []byte(templateContent), 0o644); err != nil { | ||
| t.Fatalf("write template: %v", err) | ||
| } | ||
|
|
||
| return root | ||
| } | ||
|
|
||
| // TestLookupOfflineProducesEmptyInterface is a regression test for the bug | ||
| // where `talm apply` rendered templates offline, causing lookup() to return | ||
| // empty maps. Templates that derive the interface name from discovery data | ||
| // (e.g., iterating routes) produced an empty interface field, which Talos v1.12 | ||
| // rejects with: | ||
| // | ||
| // [networking.os.device.interface], [networking.os.device.deviceSelector]: | ||
| // required either config section to be set | ||
| // | ||
| // The fix: render templates online (with a real client and LookupFunc). | ||
| // This test verifies both the broken (offline) and fixed (online) paths at | ||
| // the Helm template rendering layer. | ||
| func TestLookupOfflineProducesEmptyInterface(t *testing.T) { | ||
| // Template that mimics the real talm.discovered.default_link_name_by_gateway | ||
| // pattern: iterate routes from lookup(), extract outLinkName. When offline, | ||
| // lookup returns an empty map → range produces nothing → empty interface. | ||
| const tmpl = `{{- $linkName := "" -}} | ||
| {{- range (lookup "routes" "" "").items -}} | ||
| {{- if and (eq .spec.dst "") (not (eq .spec.gateway "")) -}} | ||
| {{- $linkName = .spec.outLinkName -}} | ||
| {{- end -}} | ||
| {{- end -}} | ||
| machine: | ||
| network: | ||
| interfaces: | ||
| - interface: {{ $linkName }} | ||
| ` | ||
|
|
||
| chartRoot := createTestChart(t, "testchart", "config.yaml", tmpl) | ||
| chrt, err := loader.LoadDir(chartRoot) | ||
| if err != nil { | ||
| t.Fatalf("LoadDir: %v", err) | ||
| } | ||
|
|
||
| rootValues := map[string]interface{}{ | ||
| "Values": chrt.Values, | ||
| } | ||
|
|
||
| t.Run("offline_produces_empty_interface", func(t *testing.T) { | ||
| origLookup := helmEngine.LookupFunc | ||
| defer func() { helmEngine.LookupFunc = origLookup }() | ||
|
|
||
| // Default no-op: returns empty map (same as offline mode) | ||
| helmEngine.LookupFunc = func(string, string, string) (map[string]interface{}, error) { | ||
| return map[string]interface{}{}, nil | ||
| } | ||
|
|
||
| eng := helmEngine.Engine{} | ||
| out, err := eng.Render(chrt, rootValues) | ||
| if err != nil { | ||
| t.Fatalf("Render: %v", err) | ||
| } | ||
|
|
||
| rendered := out["testchart/templates/config.yaml"] | ||
| // With offline lookup, the interface name is empty — this is the bug. | ||
| if strings.Contains(rendered, "interface: eth0") { | ||
| t.Error("offline render should NOT produce 'interface: eth0'") | ||
| } | ||
| if !strings.Contains(rendered, "interface: ") { | ||
| t.Error("offline render should contain 'interface: ' (with empty value)") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("online_lookup_populates_interface", func(t *testing.T) { | ||
| origLookup := helmEngine.LookupFunc | ||
| defer func() { helmEngine.LookupFunc = origLookup }() | ||
|
|
||
| // Simulate online mode: return route data with a real interface name. | ||
| helmEngine.LookupFunc = func(resource, namespace, name string) (map[string]interface{}, error) { | ||
| if resource == "routes" && name == "" { | ||
| return map[string]interface{}{ | ||
| "apiVersion": "v1", | ||
| "kind": "List", | ||
| "items": []interface{}{ | ||
| map[string]interface{}{ | ||
| "spec": map[string]interface{}{ | ||
| "dst": "", | ||
| "gateway": "192.168.1.1", | ||
| "outLinkName": "eth0", | ||
| "table": "main", | ||
| }, | ||
| }, | ||
| }, | ||
| }, nil | ||
| } | ||
| return map[string]interface{}{}, nil | ||
| } | ||
|
|
||
| eng := helmEngine.Engine{} | ||
| out, err := eng.Render(chrt, rootValues) | ||
| if err != nil { | ||
| t.Fatalf("Render: %v", err) | ||
| } | ||
|
|
||
| rendered := out["testchart/templates/config.yaml"] | ||
| if !strings.Contains(rendered, "interface: eth0") { | ||
| t.Errorf("online render should produce 'interface: eth0', got:\n%s", rendered) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| // TestRenderOfflineSkipsLookupFunc verifies that Render with Offline=true does | ||
| // NOT replace the LookupFunc, and Offline=false does replace it. This is a | ||
| // unit check that the fix (Offline=false in apply) causes the real LookupFunc | ||
| // to be wired up. | ||
| func TestRenderOfflineSkipsLookupFunc(t *testing.T) { | ||
| origLookup := helmEngine.LookupFunc | ||
| defer func() { helmEngine.LookupFunc = origLookup }() | ||
|
|
||
| // Set a sentinel LookupFunc | ||
| helmEngine.LookupFunc = func(string, string, string) (map[string]interface{}, error) { | ||
| return map[string]interface{}{"sentinel": true}, nil | ||
| } | ||
|
|
||
| // Offline=true should leave the sentinel intact | ||
| opts := Options{Offline: true} | ||
| if !opts.Offline { | ||
| t.Fatal("test setup: expected Offline=true") | ||
| } | ||
|
|
||
| res, _ := helmEngine.LookupFunc("test", "", "") | ||
| if _, ok := res["sentinel"]; !ok { | ||
| t.Error("Offline=true must not replace LookupFunc") | ||
| } | ||
|
|
||
| // Verify: when Offline=false, Render() would call | ||
| // helmEngine.LookupFunc = newLookupFunction(ctx, c), replacing the sentinel. | ||
| // We can't call full Render without a chart/client, but the logic is: | ||
| // if !opts.Offline { helmEngine.LookupFunc = newLookupFunction(ctx, c) } | ||
| // This is tested implicitly by the online_lookup_populates_interface subtest. | ||
| } | ||
|
Comment on lines
+154
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Line 168-Line 176, the test only checks the sentinel function you just assigned; it never invokes 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: cozystack/talm
Length of output: 2030
🏁 Script executed:
Repository: cozystack/talm
Length of output: 393
🏁 Script executed:
Repository: cozystack/talm
Length of output: 105
🏁 Script executed:
Repository: cozystack/talm
Length of output: 40
🏁 Script executed:
Repository: cozystack/talm
Length of output: 40
🏁 Script executed:
Repository: cozystack/talm
Length of output: 42
🏁 Script executed:
Repository: cozystack/talm
Length of output: 2941
🏁 Script executed:
# List all Go files in pkg and look for global-related files ls -la pkg/Repository: cozystack/talm
Length of output: 503
🏁 Script executed:
Repository: cozystack/talm
Length of output: 3336
🏁 Script executed:
Repository: cozystack/talm
Length of output: 3573
🏁 Script executed:
Repository: cozystack/talm
Length of output: 155
Use
%vinstead of%sfor[]stringvalues in format strings.GlobalArgs.NodesandGlobalArgs.Endpointsare[]stringtypes. The%sformat specifier is for single strings and will print Go's error-like formatting (e.g.,%!s([]string=[...])) instead of readable values. Use%vto properly display slices.🩹 Proposed fix
Also applies to: 157-157
🤖 Prompt for AI Agents