Skip to content
169 changes: 135 additions & 34 deletions pkg/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ package commands

import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"

"github.com/cozystack/talm/pkg/engine"
Expand Down Expand Up @@ -77,7 +78,7 @@ var applyCmd = &cobra.Command{
}
applyCmdFlags.nodesFromArgs = len(GlobalArgs.Nodes) > 0
applyCmdFlags.endpointsFromArgs = len(GlobalArgs.Endpoints) > 0
// Set dummy endpoint to avoid errors on building clinet
// Set dummy endpoint to avoid errors on building client
if len(GlobalArgs.Endpoints) == 0 {
GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, "127.0.0.1")
}
Expand All @@ -104,54 +105,51 @@ func apply(args []string) error {
}

for _, configFile := range expandedFiles {
if err := processModelineAndUpdateGlobals(configFile, applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs, true); err != nil {
modelineTemplates, err := processModelineAndUpdateGlobals(configFile, applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs, true)
if err != nil {
return err
}

// Resolve secrets.yaml path relative to project root if not absolute
withSecretsPath := ResolveSecretsPath(applyCmdFlags.withSecrets)

opts := engine.Options{
TalosVersion: applyCmdFlags.talosVersion,
WithSecrets: withSecretsPath,
KubernetesVersion: applyCmdFlags.kubernetesVersion,
Debug: applyCmdFlags.debug,
}

patches := []string{"@" + configFile}
configBundle, machineType, err := engine.FullConfigProcess(ctx, opts, patches)
if err != nil {
return fmt.Errorf("full config processing error: %s", err)
}
var result []byte
if len(modelineTemplates) > 0 {
// Template rendering path: render templates via Helm engine to produce full config
opts := buildApplyRenderOptions(modelineTemplates, withSecretsPath)
result, err = engine.Render(ctx, nil, opts)
if err != nil {
return fmt.Errorf("template rendering error: %w", err)
}
} else {
// Direct patch path: apply config file as patch against empty bundle
opts := buildApplyPatchOptions(withSecretsPath)
patches := []string{"@" + configFile}
configBundle, machineType, err := engine.FullConfigProcess(ctx, opts, patches)
if err != nil {
return fmt.Errorf("full config processing error: %w", err)
}

result, err := engine.SerializeConfiguration(configBundle, machineType)
if err != nil {
return fmt.Errorf("error serializing configuration: %s", err)
result, err = engine.SerializeConfiguration(configBundle, machineType)
if err != nil {
return fmt.Errorf("error serializing configuration: %w", err)
}
}
Comment on lines +116 to 136
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 2, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't turn modeline-bearing -f files into metadata-only inputs.

Lines 116-123 render only modelineTemplates; configFile is never merged in this branch. Since processModelineAndUpdateGlobals only extracts the modeline metadata, any YAML overrides that remain in the same node file are dropped, so talm apply -f nodes/node0.yaml applies the shared templates but loses the node-specific patch from the file the user passed in. Please keep configFile in the merge step after rendering the template set.

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

In `@pkg/commands/apply.go` around lines 116 - 136, When modelineTemplates is
non-empty the code renders only the templates and never merges the original
configFile, causing node-specific YAML in configFile to be dropped; after
rendering via buildApplyRenderOptions/engine.Render (the modelineTemplates
branch) include the original configFile in the merge/patch step the same way the
else branch does (e.g., pass "@" + configFile into engine.FullConfigProcess or
otherwise merge configFile into the rendered result) so that
processModelineAndUpdateGlobals and engine.FullConfigProcess receive both the
rendered templates and the original file's overrides before calling
engine.SerializeConfiguration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional and matches the existing behavior of talm template -f.

The templateWithFiles function in template.go (lines 127-218) also does NOT pass configFile as a patch to engine.Render. The node file serves as a modeline container (nodes, endpoints, templates metadata) and as the output target for --in-place rendering. The actual cluster configuration is produced entirely by the Helm templates + values.yaml.

The two-step workaround talm template -f node.yaml | talm apply -f - works the same way: template renders full config from templates, apply sends it to the node. This PR makes talm apply -f do both steps internally.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lexfrei, thank you for the clear explanation! That makes sense — the node file is purely a modeline carrier (nodes, endpoints, template references) and the full configuration is produced entirely by the Helm templates and values.yaml, consistent with how templateWithFiles works. I've noted this design pattern for future reviews.


✏️ Learnings added
Learnt from: lexfrei
Repo: cozystack/talm PR: 113
File: pkg/commands/apply.go:116-136
Timestamp: 2026-04-02T08:59:38.723Z
Learning: In the talm project (pkg/commands/apply.go and pkg/commands/template.go), when a node file (e.g. nodes/node0.yaml) is passed via `-f`, it serves only as a modeline container providing metadata (nodes, endpoints, template paths). The actual cluster configuration is produced entirely by Helm templates + values.yaml. The configFile is intentionally NOT passed as a patch to engine.Render. This matches templateWithFiles behavior in template.go and is equivalent to the two-step `talm template -f node.yaml | talm apply -f -` pattern.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


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(f)
return WithClientSkipVerify(wrappedF)
}

return WithClientNoNodes(func(ctx context.Context, cli *client.Client) error {
if len(GlobalArgs.Nodes) < 1 {
configContext := cli.GetConfigContext()
if configContext == nil {
return errors.New("failed to resolve config context")
}

GlobalArgs.Nodes = configContext.Nodes
}

ctx = client.WithNodes(ctx, GlobalArgs.Nodes...)

return f(ctx, cli)
})
return WithClientNoNodes(wrappedF)
}

err = withClient(func(ctx context.Context, c *client.Client) error {
Expand Down Expand Up @@ -186,6 +184,109 @@ func apply(args []string) error {
return nil
}

// 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.
func buildApplyRenderOptions(modelineTemplates []string, withSecretsPath string) engine.Options {
resolvedTemplates := resolveTemplatePaths(modelineTemplates, Config.RootDir)
return engine.Options{
TalosVersion: applyCmdFlags.talosVersion,
WithSecrets: withSecretsPath,
KubernetesVersion: applyCmdFlags.kubernetesVersion,
Debug: applyCmdFlags.debug,
Full: true,
Offline: true,
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

Hardcoding Offline: true in the template rendering path prevents the use of the lookup function in Helm templates during talm apply. This is a regression in functionality compared to the talm template | talm apply workaround mentioned in the PR description, where lookup works by default. Consider making this configurable or initializing a client earlier to support online rendering if the templates require it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. The Talos client is not available at rendering time — it is created later in withClient, after the config is already built. Supporting online rendering would require restructuring the flow to establish the client connection before template rendering.

Added a documenting comment in b393aa5 and filed #114 to track this as a follow-up.

Root: Config.RootDir,
TemplateFiles: resolvedTemplates,
}
}

// buildApplyPatchOptions constructs engine.Options for the direct patch path.
func buildApplyPatchOptions(withSecretsPath string) engine.Options {
return engine.Options{
TalosVersion: applyCmdFlags.talosVersion,
WithSecrets: withSecretsPath,
KubernetesVersion: applyCmdFlags.kubernetesVersion,
Debug: applyCmdFlags.debug,
}
}

// wrapWithNodeContext wraps a client action function to resolve and inject node
// context. If GlobalArgs.Nodes is already set, uses those directly. Otherwise,
// attempts to resolve nodes from the client's config context.
// This function does not mutate GlobalArgs. It reads GlobalArgs.Nodes at
// invocation time (not at wrapper creation time) and makes a defensive copy.
func wrapWithNodeContext(f func(ctx context.Context, c *client.Client) error) func(ctx context.Context, c *client.Client) error {
return func(ctx context.Context, c *client.Client) error {
nodes := append([]string(nil), GlobalArgs.Nodes...)
if len(nodes) < 1 {
if c == nil {
return fmt.Errorf("failed to resolve config context: no client available")
}
configContext := c.GetConfigContext()
if configContext == nil {
return fmt.Errorf("failed to resolve config context")
}
nodes = configContext.Nodes
}
Comment on lines +223 to +232
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

The logic to resolve nodes from the client's config context appears to be unreachable in the apply command. processModelineAndUpdateGlobals is called at the start of the loop (line 108) and it already enforces that GlobalArgs.Nodes is non-empty (returning an error otherwise at lines 250-252 in root.go). If this wrapper is intended only for apply, this block can be simplified to remove the unreachable resolution logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentionally kept as defensive code. wrapWithNodeContext is a standalone helper — while processModelineAndUpdateGlobals guarantees non-empty nodes in the current apply call site, the function could be reused by other callers that do not enforce this precondition. Removing the fallback would make the helper fragile on reuse.


ctx = client.WithNodes(ctx, nodes...)
return f(ctx, c)
}
}

// resolveTemplatePaths resolves template file paths relative to the project root,
// normalizing them for the Helm engine (forward slashes).
// Relative paths from the modeline are resolved against rootDir, not CWD.
//
// Note: template.go has similar path resolution in generateOutput() but resolves
// against CWD via filepath.Abs and has an additional fallback that tries
// templates/<basename> when a path resolves outside the root. This function
// intentionally resolves against rootDir (modeline paths are root-relative by
// convention) and does not perform the basename fallback to avoid silently
// substituting a different file.
func resolveTemplatePaths(templates []string, rootDir string) []string {
resolved := make([]string, len(templates))
if rootDir == "" {
// No rootDir specified — normalize paths only, don't resolve against CWD
for i, p := range templates {
resolved[i] = engine.NormalizeTemplatePath(p)
}
return resolved
}
absRootDir, rootErr := filepath.Abs(rootDir)
if rootErr != nil {
for i, p := range templates {
resolved[i] = engine.NormalizeTemplatePath(p)
}
return resolved
}

for i, templatePath := range templates {
var absTemplatePath string
if filepath.IsAbs(templatePath) {
absTemplatePath = templatePath
} else {
// Resolve relative paths against rootDir, not CWD
absTemplatePath = filepath.Join(absRootDir, templatePath)
}
relPath, relErr := filepath.Rel(absRootDir, absTemplatePath)
if relErr != nil {
resolved[i] = engine.NormalizeTemplatePath(templatePath)
continue
}
relPath = filepath.Clean(relPath)
if strings.HasPrefix(relPath, "..") {
// Path goes outside project root — use original path as-is
resolved[i] = engine.NormalizeTemplatePath(templatePath)
continue
}
resolved[i] = engine.NormalizeTemplatePath(relPath)
}
return resolved
}

func init() {
applyCmd.Flags().BoolVarP(&applyCmdFlags.insecure, "insecure", "i", false, "apply using the insecure (encrypted with no auth) maintenance service")
applyCmd.Flags().StringSliceVarP(&applyCmdFlags.configFiles, "file", "f", nil, "specify config files or patches in a YAML file (can specify multiple)")
Expand Down
Loading
Loading