Skip to content

Commit c4b5a5d

Browse files
committed
fix(commands): render templates per node in the apply callback
PR #119 moved talm apply's template rendering from offline to online. The new online path runs inside withApplyClient, whose wrapWithNodeContext helper batches every node from GlobalArgs.Nodes into a single gRPC context. engine.Render then calls helpers.FailIfMultiNodes which rejects multi-node contexts, so a node file with `nodes=[ip1, ip2]` (or `--nodes A,B,C` on the command line) fails before any rendering happens. The pre-#119 direct-patch flow handled multi-node fan-out at the wire level inside ApplyConfiguration, so this never surfaced. Render once per node instead. Add applyTemplatesPerNode that takes a nodes slice and injection points for the render and apply functions, then iterates: for each node it builds a single-node context with client.WithNodes, calls engine.Render (which now sees one node and satisfies the FailIfMultiNodes guard), merges the modeline'd file as a patch, and applies the result. Per-node iteration is also the correct semantic — discovery via lookup() resolves each node's own network topology rather than mashing everything together. Split withApplyClient into a public form (still wraps with the legacy multi-node context for the direct-patch branch) and withApplyClientBare which skips the wrap so the per-node loop can attach contexts itself. Three tests pin the contract: that each render and apply call sees exactly one node in its outgoing-context metadata; that engine.Render with a batched multi-node context still errors (the pre-condition the loop exists to satisfy); that an empty nodes slice errors loudly rather than silently doing nothing. Manually verified the loop assertion fails when the per-iteration context is built with all nodes instead of one. Closes #120 Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent 03f1bfb commit c4b5a5d

2 files changed

Lines changed: 184 additions & 36 deletions

File tree

pkg/commands/apply.go

Lines changed: 78 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -113,40 +113,34 @@ func apply(args []string) error {
113113
withSecretsPath := ResolveSecretsPath(applyCmdFlags.withSecrets)
114114

115115
if len(modelineTemplates) > 0 {
116-
// Template rendering path: connect to the node first, render templates
117-
// online (so lookup() functions resolve real discovery data), then apply.
116+
// Template rendering path: connect to the node first, render
117+
// templates online per node (so lookup() functions resolve each
118+
// node's own discovery data), merge the node file as a patch,
119+
// then apply. The bare client wrapper is used so the per-node
120+
// loop can attach a single-node gRPC context per iteration —
121+
// engine.Render's FailIfMultiNodes guard rejects a batched
122+
// multi-node context.
118123
opts := buildApplyRenderOptions(modelineTemplates, withSecretsPath)
124+
nodes := append([]string(nil), GlobalArgs.Nodes...)
119125

120-
if err := withApplyClient(func(ctx context.Context, c *client.Client) error {
126+
if err := withApplyClientBare(func(ctx context.Context, c *client.Client) error {
121127
fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints)
122-
123-
result, err := engine.Render(ctx, c, opts)
124-
if err != nil {
125-
return fmt.Errorf("template rendering error: %w", err)
126-
}
127-
128-
// Overlay any per-node config from the modeline'd file on top
129-
// of the rendered template. Without this merge, hostname,
130-
// secondary interfaces, VIP placement and other per-node
131-
// fields defined in the node file are silently lost.
132-
result, err = engine.MergeFileAsPatch(result, configFile)
133-
if err != nil {
134-
return fmt.Errorf("merging node file as patch: %w", err)
135-
}
136-
137-
resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{
138-
Data: result,
139-
Mode: applyCmdFlags.Mode.Mode,
140-
DryRun: applyCmdFlags.dryRun,
141-
TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout),
142-
})
143-
if err != nil {
144-
return fmt.Errorf("error applying new configuration: %w", err)
145-
}
146-
147-
helpers.PrintApplyResults(resp)
148-
149-
return nil
128+
return applyTemplatesPerNode(ctx, c, opts, configFile, nodes,
129+
engine.Render,
130+
func(ctx context.Context, c *client.Client, data []byte) error {
131+
resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{
132+
Data: data,
133+
Mode: applyCmdFlags.Mode.Mode,
134+
DryRun: applyCmdFlags.dryRun,
135+
TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout),
136+
})
137+
if err != nil {
138+
return fmt.Errorf("error applying new configuration: %w", err)
139+
}
140+
helpers.PrintApplyResults(resp)
141+
return nil
142+
},
143+
)
150144
}); err != nil {
151145
return err
152146
}
@@ -197,22 +191,70 @@ func apply(args []string) error {
197191
}
198192

199193
// withApplyClient creates a Talos client appropriate for the current apply mode
200-
// and invokes the given action with it.
194+
// and invokes the given action with it. The action receives a context with
195+
// every node from GlobalArgs.Nodes batched into the gRPC metadata, matching
196+
// the legacy direct-patch fan-out behaviour.
201197
func withApplyClient(f func(ctx context.Context, c *client.Client) error) error {
198+
return withApplyClientBare(wrapWithNodeContext(f))
199+
}
200+
201+
// withApplyClientBare connects to Talos for the current apply mode but does
202+
// NOT inject GlobalArgs.Nodes into the context. The template-rendering path
203+
// uses this so its per-node loop can attach a single-node context per
204+
// iteration instead — engine.Render's FailIfMultiNodes guard rejects a
205+
// batched multi-node context, and discovery via lookup() is per-node anyway.
206+
func withApplyClientBare(f func(ctx context.Context, c *client.Client) error) error {
202207
if applyCmdFlags.insecure {
203208
// Maintenance mode connects directly to the node IP without talosconfig;
204209
// node context injection is not needed — the maintenance client handles
205210
// node targeting internally via GlobalArgs.Nodes.
206211
return WithClientMaintenance(applyCmdFlags.certFingerprints, f)
207212
}
208213

209-
wrappedF := wrapWithNodeContext(f)
210-
211214
if GlobalArgs.SkipVerify {
212-
return WithClientSkipVerify(wrappedF)
215+
return WithClientSkipVerify(f)
213216
}
214217

215-
return WithClientNoNodes(wrappedF)
218+
return WithClientNoNodes(f)
219+
}
220+
221+
// renderFunc and applyFunc are injection points for applyTemplatesPerNode so
222+
// unit tests can drive the loop with fakes instead of a real Talos client.
223+
type renderFunc func(ctx context.Context, c *client.Client, opts engine.Options) ([]byte, error)
224+
type applyFunc func(ctx context.Context, c *client.Client, data []byte) error
225+
226+
// applyTemplatesPerNode runs render → MergeFileAsPatch → apply once per node,
227+
// each iteration carrying a single-node gRPC context. Enables multi-node
228+
// modelines and `--nodes A,B,C` against the template-rendering branch:
229+
// engine.Render's FailIfMultiNodes guard requires a single-node context, and
230+
// each node should resolve its own discovery via lookup() in any case.
231+
func applyTemplatesPerNode(
232+
parentCtx context.Context,
233+
c *client.Client,
234+
opts engine.Options,
235+
configFile string,
236+
nodes []string,
237+
render renderFunc,
238+
apply applyFunc,
239+
) error {
240+
if len(nodes) == 0 {
241+
return fmt.Errorf("no nodes specified for template-rendering apply")
242+
}
243+
for _, node := range nodes {
244+
perCtx := client.WithNodes(parentCtx, node)
245+
rendered, err := render(perCtx, c, opts)
246+
if err != nil {
247+
return fmt.Errorf("node %s: template rendering: %w", node, err)
248+
}
249+
merged, err := engine.MergeFileAsPatch(rendered, configFile)
250+
if err != nil {
251+
return fmt.Errorf("node %s: merging node file as patch: %w", node, err)
252+
}
253+
if err := apply(perCtx, c, merged); err != nil {
254+
return fmt.Errorf("node %s: %w", node, err)
255+
}
256+
}
257+
return nil
216258
}
217259

218260
// buildApplyRenderOptions constructs engine.Options for the template rendering path.

pkg/commands/apply_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"slices"
88
"testing"
99

10+
"github.com/cozystack/talm/pkg/engine"
1011
"github.com/siderolabs/talos/pkg/machinery/client"
1112
"google.golang.org/grpc/metadata"
1213
)
@@ -241,3 +242,108 @@ func TestWrapWithNodeContext_NoNodesNoClient(t *testing.T) {
241242
t.Error("expected error when no nodes and no client config context, got nil")
242243
}
243244
}
245+
246+
// nodesFromOutgoingCtx pulls the gRPC outgoing-metadata "nodes" key — the same
247+
// place client.WithNodes writes to. Used by the per-node loop tests to assert
248+
// each iteration sees a single-node context.
249+
func nodesFromOutgoingCtx(t *testing.T, ctx context.Context) []string {
250+
t.Helper()
251+
md, ok := metadata.FromOutgoingContext(ctx)
252+
if !ok {
253+
return nil
254+
}
255+
return md.Get("nodes")
256+
}
257+
258+
// TestApplyTemplatesPerNode_LoopsOncePerNodeWithSingleNodeContext covers #120:
259+
// the multi-node fan-out previously batched every node into a single gRPC
260+
// context, which engine.Render's FailIfMultiNodes guard then rejected. The
261+
// per-node loop must run render + merge + apply once per node, each with a
262+
// context carrying exactly that one node, so the guard passes and each node
263+
// resolves its own discovery.
264+
func TestApplyTemplatesPerNode_LoopsOncePerNodeWithSingleNodeContext(t *testing.T) {
265+
dir := t.TempDir()
266+
configFile := filepath.Join(dir, "node.yaml")
267+
// Modeline-only file so MergeFileAsPatch is a no-op and the test stays
268+
// focused on the loop semantics rather than patch behaviour.
269+
if err := os.WriteFile(configFile, []byte("# talm: nodes=[\"a\",\"b\",\"c\"], templates=[\"templates/controlplane.yaml\"]\n"), 0o644); err != nil {
270+
t.Fatalf("write configFile: %v", err)
271+
}
272+
273+
want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}
274+
var renderCalls, applyCalls []string
275+
276+
render := func(ctx context.Context, _ *client.Client, _ engine.Options) ([]byte, error) {
277+
got := nodesFromOutgoingCtx(t, ctx)
278+
if len(got) != 1 {
279+
t.Errorf("render: expected single-node ctx, got %v", got)
280+
}
281+
renderCalls = append(renderCalls, got...)
282+
return []byte("version: v1alpha1\nmachine:\n type: worker\n"), nil
283+
}
284+
apply := func(ctx context.Context, _ *client.Client, _ []byte) error {
285+
got := nodesFromOutgoingCtx(t, ctx)
286+
if len(got) != 1 {
287+
t.Errorf("apply: expected single-node ctx, got %v", got)
288+
}
289+
applyCalls = append(applyCalls, got...)
290+
return nil
291+
}
292+
293+
if err := applyTemplatesPerNode(context.Background(), nil, engine.Options{}, configFile, want, render, apply); err != nil {
294+
t.Fatalf("applyTemplatesPerNode: %v", err)
295+
}
296+
297+
if !slices.Equal(renderCalls, want) {
298+
t.Errorf("render calls = %v, want %v", renderCalls, want)
299+
}
300+
if !slices.Equal(applyCalls, want) {
301+
t.Errorf("apply calls = %v, want %v", applyCalls, want)
302+
}
303+
}
304+
305+
// TestApplyTemplatesPerNode_BatchedContextIsRejected covers the regression
306+
// vector that motivated the per-node loop: feeding engine.Render a context
307+
// with multiple nodes produces a FailIfMultiNodes error. The per-node loop is
308+
// the cure; this test pins the disease.
309+
func TestApplyTemplatesPerNode_BatchedContextIsRejected(t *testing.T) {
310+
dir := t.TempDir()
311+
configFile := filepath.Join(dir, "node.yaml")
312+
if err := os.WriteFile(configFile, []byte("# talm: nodes=[\"a\",\"b\"]\n"), 0o644); err != nil {
313+
t.Fatalf("write configFile: %v", err)
314+
}
315+
316+
// Sanity: when the loop hands engine.Render a single-node ctx, the
317+
// guard is satisfied. We exercise this above. Here we assert that if
318+
// somebody tried to feed a multi-node ctx to render directly, the
319+
// real engine.Render would reject it — the bug we are working around.
320+
multiCtx := client.WithNodes(context.Background(), "10.0.0.1", "10.0.0.2")
321+
_, err := engine.Render(multiCtx, nil, engine.Options{Offline: false, CommandName: "talm apply"})
322+
if err == nil {
323+
t.Fatal("engine.Render expected to reject multi-node ctx, got nil")
324+
}
325+
}
326+
327+
// TestApplyTemplatesPerNode_NoNodesIsAnError guards against silently
328+
// short-circuiting when GlobalArgs.Nodes is empty. The previous structure
329+
// would happily run zero iterations — this pin makes it loud.
330+
func TestApplyTemplatesPerNode_NoNodesIsAnError(t *testing.T) {
331+
dir := t.TempDir()
332+
configFile := filepath.Join(dir, "node.yaml")
333+
if err := os.WriteFile(configFile, []byte("# talm: nodes=[]\n"), 0o644); err != nil {
334+
t.Fatalf("write configFile: %v", err)
335+
}
336+
337+
render := func(_ context.Context, _ *client.Client, _ engine.Options) ([]byte, error) {
338+
t.Fatal("render must not be called when there are zero nodes")
339+
return nil, nil
340+
}
341+
apply := func(_ context.Context, _ *client.Client, _ []byte) error {
342+
t.Fatal("apply must not be called when there are zero nodes")
343+
return nil
344+
}
345+
346+
if err := applyTemplatesPerNode(context.Background(), nil, engine.Options{}, configFile, nil, render, apply); err == nil {
347+
t.Fatal("expected an error for empty nodes list, got nil")
348+
}
349+
}

0 commit comments

Comments
 (0)