Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 65 additions & 42 deletions pkg/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the code around lines 121 and 157 in pkg/commands/apply.go
head -160 pkg/commands/apply.go | tail -50

Repository: cozystack/talm

Length of output: 2030


🏁 Script executed:

# Also check what GlobalArgs is and its type definition
rg "GlobalArgs\s*=" pkg/commands/ -A 2 -B 2 | head -30

Repository: cozystack/talm

Length of output: 393


🏁 Script executed:

# Search for GlobalArgs struct/variable definition
rg "GlobalArgs" pkg/ -t go | grep -E "(var|const|type).*GlobalArgs" | head -20

Repository: cozystack/talm

Length of output: 105


🏁 Script executed:

# Find the definition of global.Args struct
fd "args.go" pkg/ --type f

Repository: cozystack/talm

Length of output: 40


🏁 Script executed:

# Search for Args struct in global package
rg "type Args struct" pkg/ -A 20

Repository: cozystack/talm

Length of output: 40


🏁 Script executed:

# Find all Go files to locate the global package
find pkg -name "*.go" | xargs grep -l "package global"

Repository: cozystack/talm

Length of output: 42


🏁 Script executed:

# Search for Nodes and Endpoints field declarations
rg "Nodes|Endpoints" pkg/ -t go -B 2 -A 2 | head -50

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:

# Search more broadly for Args struct definition in entire codebase
rg "Nodes.*\[\]string|Endpoints.*\[\]string" pkg/ -t go -B 5 -A 5 | head -60

Repository: cozystack/talm

Length of output: 3336


🏁 Script executed:

# Verify the exact context around GlobalArgs type assignment
grep -n "GlobalArgs" pkg/commands/root.go -A 5 -B 5

Repository: cozystack/talm

Length of output: 3573


🏁 Script executed:

# Check if global.Args is imported from talos or defined locally
rg "import.*global|from.*global" pkg/commands/ -t go

Repository: cozystack/talm

Length of output: 155


Use %v instead of %s for []string values in format strings.

GlobalArgs.Nodes and GlobalArgs.Endpoints are []string types. The %s format specifier is for single strings and will print Go's error-like formatting (e.g., %!s([]string=[...])) instead of readable values. Use %v to properly display slices.

🩹 Proposed fix
-				fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints)
+				fmt.Printf("- talm: file=%s, nodes=%v, endpoints=%v\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints)

Also applies to: 157-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/commands/apply.go` at line 121, The fmt.Printf call that prints slice
values uses %s which misformats []string; update the format specifier to use %v
for GlobalArgs.Nodes and GlobalArgs.Endpoints in the Printf on the line with
fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile,
GlobalArgs.Nodes, GlobalArgs.Endpoints) and likewise update the other occurrence
(around the second reported location) so both slices are formatted with %v
instead of %s.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use %w instead of %s to wrap the error. This is a best practice in Go as it allows the caller to use errors.Is or errors.As to inspect the underlying error.

Suggested change
if err != nil {
return fmt.Errorf("error applying new configuration: %s", err)
}
if err != nil {
return fmt.Errorf("error applying new configuration: %w", err)
}


helpers.PrintApplyResults(resp)

return nil
})
} else {
// Direct patch path: apply config file as patch against empty bundle
opts := buildApplyPatchOptions(withSecretsPath)
Expand All @@ -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
}
Expand All @@ -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,
}
Expand Down
10 changes: 8 additions & 2 deletions pkg/commands/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@ func TestBuildApplyRenderOptions(t *testing.T) {
origTalosVersion := applyCmdFlags.talosVersion
origKubeVersion := applyCmdFlags.kubernetesVersion
origDebug := applyCmdFlags.debug
origInsecure := applyCmdFlags.insecure
origRootDir := Config.RootDir
defer func() {
applyCmdFlags.talosVersion = origTalosVersion
applyCmdFlags.kubernetesVersion = origKubeVersion
applyCmdFlags.debug = origDebug
applyCmdFlags.insecure = origInsecure
Config.RootDir = origRootDir
}()

applyCmdFlags.talosVersion = "v1.12"
applyCmdFlags.kubernetesVersion = "1.31.0"
applyCmdFlags.debug = false
applyCmdFlags.insecure = true
Config.RootDir = "/project"

opts := buildApplyRenderOptions(
Expand All @@ -36,8 +39,11 @@ func TestBuildApplyRenderOptions(t *testing.T) {
if !opts.Full {
t.Error("expected Full=true for template rendering path")
}
if !opts.Offline {
t.Error("expected Offline=true for template rendering path")
if opts.Offline {
t.Error("expected Offline=false for online template rendering path")
}
if !opts.Insecure {
t.Error("expected Insecure=true to be passed through from flags")
}
if opts.Root != "/project" {
t.Errorf("expected Root=/project, got %s", opts.Root)
Expand Down
183 changes: 183 additions & 0 deletions pkg/engine/render_test.go
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

TestRenderOfflineSkipsLookupFunc is tautological and doesn’t validate the claimed behavior.

On Line 168-Line 176, the test only checks the sentinel function you just assigned; it never invokes Render, so it can still pass even if Offline=true starts replacing LookupFunc. Please either call the real render path in this test or rename/remove it to avoid false confidence.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/engine/render_test.go` around lines 154 - 183, The test
TestRenderOfflineSkipsLookupFunc is tautological because it never calls Render
and thus doesn't verify that Offline=true prevents replacement of
helmEngine.LookupFunc; update the test to either (A) exercise the real render
path by invoking Render with an Options{Offline:true} and a minimal chart/client
(so the code path that would set helmEngine.LookupFunc =
newLookupFunction(ctx,c) runs and you can assert the sentinel remains), or (B)
if invoking Render is impractical, rename or remove the test to avoid a
misleading assertion; reference helmEngine.LookupFunc, Options{Offline:true},
Render, newLookupFunction, and the test name TestRenderOfflineSkipsLookupFunc
when making the change.

Loading