Skip to content

Commit b7df415

Browse files
committed
fix: address remaining round-3 review notes
- Sync openClientFunc docstring with the WithNode (single-target) metadata key the auth-mode opener actually uses; the old docstring still mentioned WithNodes from the previous version. - TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes now drives the production openClientPerNodeMaintenance with an injected fake maintenanceClientFunc instead of an inline stub. The fake snapshots GlobalArgs.Nodes at the moment WithClientMaintenance reads it, proving the narrow-and-restore-via-defer logic is exercised end to end. A regression in the production function now fails this test (previously it would have continued to pass against the test-local stub). - New TestApplyTemplatesPerNode_AuthModeUsesSingleNodeMetadataKey pins the gRPC metadata key the auth-mode opener writes so a future swap back to WithNodes (which would round-trip through nodesFromOutgoingCtx unnoticed) gets caught. - README documents that node files can carry per-node patches in their body, that talm apply applies them as a strategic merge over the rendered template, and that talm template intentionally does not. Recommends apply --dry-run for previewing the exact bytes apply will send. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent dcb214c commit b7df415

3 files changed

Lines changed: 108 additions & 33 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ Re-template and update generated file in place (this will overwrite it):
140140
talm template -f nodes/node1.yaml -I
141141
```
142142

143+
> **Per-node patches inside node files.** A node file can carry Talos config
144+
> below its modeline (for example, a custom `hostname`, secondary
145+
> interfaces with `deviceSelector`, VIP placement, or extra etcd args).
146+
> When `talm apply -f node.yaml` runs the template-rendering branch, that
147+
> body is applied as a strategic merge patch on top of the rendered
148+
> template before the result is sent to the node — so per-node fields
149+
> survive even when the template auto-generates conflicting values
150+
> (e.g. `hostname: talos-XXXXX`).
151+
>
152+
> `talm template -f node.yaml` (with or without `-I`) does **not** apply
153+
> the same overlay: its output is the rendered template plus the modeline
154+
> and the auto-generated warning, byte-identical to what the template
155+
> alone would produce. Routing it through the patcher would drop every
156+
> YAML comment (including the modeline) and re-sort keys, breaking
157+
> downstream commands that read the file back. Use `apply --dry-run` if
158+
> you want to preview the exact bytes that will be sent to the node.
159+
143160
## Using talosctl commands
144161

145162
Talm offers a similar set of commands to those provided by talosctl.

pkg/commands/apply.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func apply(args []string) error {
135135
}
136136

137137
if applyCmdFlags.insecure {
138-
openClient := openClientPerNodeMaintenance(applyCmdFlags.certFingerprints)
138+
openClient := openClientPerNodeMaintenance(applyCmdFlags.certFingerprints, WithClientMaintenance)
139139
if err := applyTemplatesPerNode(opts, configFile, nodes, openClient, engine.Render, applyClosure); err != nil {
140140
return err
141141
}
@@ -229,10 +229,10 @@ type applyFunc func(ctx context.Context, c *client.Client, data []byte) error
229229

230230
// openClientFunc opens a Talos client suitable for a single node and runs
231231
// action with it. Authenticated mode reuses one parent client and rotates
232-
// the node via gRPC metadata (client.WithNodes); insecure (maintenance)
233-
// mode opens a fresh single-endpoint client per node because Talos's
234-
// maintenance client ignores nodes-in-context and round-robins between its
235-
// configured endpoints.
232+
// the node via single-target gRPC metadata (client.WithNode); insecure
233+
// (maintenance) mode opens a fresh single-endpoint client per node because
234+
// Talos's maintenance client ignores node metadata in the context and
235+
// round-robins between its configured endpoints.
236236
type openClientFunc func(node string, action func(ctx context.Context, c *client.Client) error) error
237237

238238
// applyTemplatesPerNode runs render → MergeFileAsPatch → apply once per
@@ -269,19 +269,29 @@ func applyTemplatesPerNode(
269269
return nil
270270
}
271271

272+
// maintenanceClientFunc is the contract WithClientMaintenance satisfies in
273+
// production and a fake satisfies in tests. Injection lets the unit tests
274+
// run the real openClientPerNodeMaintenance body without dialing a Talos
275+
// node.
276+
type maintenanceClientFunc func(fingerprints []string, action func(ctx context.Context, c *client.Client) error) error
277+
272278
// openClientPerNodeMaintenance returns an openClientFunc that opens a
273279
// fresh single-endpoint maintenance client per node. Multi-node insecure
274280
// apply (first bootstrap of a multi-node cluster) needs this because
275281
// WithClientMaintenance creates a client with all endpoints and gRPC then
276282
// round-robins ApplyConfiguration across them — most nodes never see the
277283
// config. Narrowing GlobalArgs.Nodes to the current iteration's node and
278284
// restoring it via defer keeps the wrapper's signature unchanged.
279-
func openClientPerNodeMaintenance(fingerprints []string) openClientFunc {
285+
//
286+
// mkClient is normally WithClientMaintenance; tests pass a fake that
287+
// captures the GlobalArgs.Nodes value at the moment WithClientMaintenance
288+
// would have read it.
289+
func openClientPerNodeMaintenance(fingerprints []string, mkClient maintenanceClientFunc) openClientFunc {
280290
return func(node string, action func(ctx context.Context, c *client.Client) error) error {
281291
savedNodes := append([]string(nil), GlobalArgs.Nodes...)
282292
GlobalArgs.Nodes = []string{node}
283293
defer func() { GlobalArgs.Nodes = savedNodes }()
284-
return WithClientMaintenance(fingerprints, action)
294+
return mkClient(fingerprints, action)
285295
}
286296
}
287297

pkg/commands/apply_test.go

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -431,48 +431,96 @@ func TestApplyTemplatesPerNode_MaintenanceModeOpensFreshClientPerNode(t *testing
431431
}
432432
}
433433

434-
// TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes verifies
435-
// the production maintenance opener narrows GlobalArgs.Nodes to the
436-
// iteration's single endpoint while WithClientMaintenance reads it, and
437-
// restores the prior value afterwards regardless of whether the action
438-
// succeeded. Without this narrowing, WithClientMaintenance would build a
439-
// client with every endpoint and gRPC would round-robin
440-
// ApplyConfiguration.
434+
// TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes drives
435+
// the real openClientPerNodeMaintenance with an injected
436+
// maintenanceClientFunc fake. The fake captures GlobalArgs.Nodes at the
437+
// moment a real WithClientMaintenance would have read it for endpoint
438+
// resolution. The contract: every iteration narrows GlobalArgs.Nodes to
439+
// exactly the iteration's node, and the prior value is restored after
440+
// the action returns regardless of success. Without the narrowing, the
441+
// real WithClientMaintenance would dial every endpoint at once and gRPC
442+
// would round-robin ApplyConfiguration across them.
441443
func TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes(t *testing.T) {
442444
saved := append([]string(nil), GlobalArgs.Nodes...)
443445
defer func() { GlobalArgs.Nodes = saved }()
444446

445447
GlobalArgs.Nodes = []string{"original-A", "original-B"}
446448

447-
// We can't invoke the real WithClientMaintenance without a Talos
448-
// endpoint, but openClientPerNodeMaintenance's narrowing is
449-
// observable: stub the action to capture GlobalArgs.Nodes at the
450-
// moment WithClientMaintenance reads them. WithClientMaintenance
451-
// dials a TCP socket; stubbing it requires a fake. We instead
452-
// replicate the narrow/defer/restore logic on an inline maintenance
453-
// stub of the same shape.
454-
openWithStub := func(node string, action func(ctx context.Context, c *client.Client) error) error {
455-
savedNodes := append([]string(nil), GlobalArgs.Nodes...)
456-
GlobalArgs.Nodes = []string{node}
457-
defer func() { GlobalArgs.Nodes = savedNodes }()
458-
459-
// In production WithClientMaintenance reads GlobalArgs.Nodes here.
460-
// Capture the value and exit without doing real network IO.
461-
if got := GlobalArgs.Nodes; len(got) != 1 || got[0] != node {
462-
t.Errorf("expected GlobalArgs.Nodes pinned to %q during open; got %v", node, got)
463-
}
449+
type call struct {
450+
fingerprints []string
451+
nodesAtCall []string
452+
}
453+
var calls []call
454+
455+
fakeMaintenance := func(fingerprints []string, action func(ctx context.Context, c *client.Client) error) error {
456+
// WithClientMaintenance reads GlobalArgs.Nodes for its endpoints
457+
// at this point. Snapshot the value so the test can inspect it.
458+
calls = append(calls, call{
459+
fingerprints: append([]string(nil), fingerprints...),
460+
nodesAtCall: append([]string(nil), GlobalArgs.Nodes...),
461+
})
464462
return action(context.Background(), nil)
465463
}
466464

465+
openClient := openClientPerNodeMaintenance([]string{"fp-1"}, fakeMaintenance)
466+
467467
for _, node := range []string{"10.0.0.1", "10.0.0.2"} {
468-
if err := openWithStub(node, func(_ context.Context, _ *client.Client) error { return nil }); err != nil {
469-
t.Fatalf("openWithStub(%q): %v", node, err)
468+
if err := openClient(node, func(_ context.Context, _ *client.Client) error { return nil }); err != nil {
469+
t.Fatalf("openClient(%q): %v", node, err)
470470
}
471471
}
472472

473473
if !slices.Equal(GlobalArgs.Nodes, []string{"original-A", "original-B"}) {
474474
t.Errorf("GlobalArgs.Nodes not restored after maintenance loop: got %v", GlobalArgs.Nodes)
475475
}
476+
if len(calls) != 2 {
477+
t.Fatalf("maintenance fake should have been called twice, got %d times", len(calls))
478+
}
479+
for i, want := range []string{"10.0.0.1", "10.0.0.2"} {
480+
if !slices.Equal(calls[i].nodesAtCall, []string{want}) {
481+
t.Errorf("call %d: GlobalArgs.Nodes at WithClientMaintenance time = %v, want [%q]", i, calls[i].nodesAtCall, want)
482+
}
483+
if !slices.Equal(calls[i].fingerprints, []string{"fp-1"}) {
484+
t.Errorf("call %d: fingerprints passed through = %v, want [\"fp-1\"]", i, calls[i].fingerprints)
485+
}
486+
}
487+
}
488+
489+
// TestApplyTemplatesPerNode_AuthModeUsesSingleNodeMetadataKey pins the
490+
// gRPC metadata key the auth-mode opener writes. WithNode sets "node"
491+
// (single-target proxy); WithNodes sets "nodes" (apid aggregation).
492+
// engine.Render's FailIfMultiNodes guard treats len("nodes") > 1 as the
493+
// multi-node case, so single-target metadata under "node" passes
494+
// trivially. A future refactor that swaps WithNode back to WithNodes
495+
// would slip past nodesFromOutgoingCtx (which reads either key) — this
496+
// assertion catches that regression directly.
497+
func TestApplyTemplatesPerNode_AuthModeUsesSingleNodeMetadataKey(t *testing.T) {
498+
dir := t.TempDir()
499+
configFile := filepath.Join(dir, "node.yaml")
500+
if err := os.WriteFile(configFile, []byte("# talm: nodes=[\"a\"]\n"), 0o644); err != nil {
501+
t.Fatalf("write configFile: %v", err)
502+
}
503+
504+
const node = "10.0.0.1"
505+
render := func(ctx context.Context, _ *client.Client, _ engine.Options) ([]byte, error) {
506+
md, ok := metadata.FromOutgoingContext(ctx)
507+
if !ok {
508+
t.Fatal("expected outgoing metadata on per-iteration ctx")
509+
}
510+
if got := md.Get("node"); !slices.Equal(got, []string{node}) {
511+
t.Errorf(`metadata key "node" = %v, want [%q]`, got, node)
512+
}
513+
if got := md.Get("nodes"); len(got) != 0 {
514+
t.Errorf(`metadata key "nodes" must be unset for single-target apply, got %v`, got)
515+
}
516+
return []byte("version: v1alpha1\nmachine:\n type: worker\n"), nil
517+
}
518+
apply := func(_ context.Context, _ *client.Client, _ []byte) error { return nil }
519+
520+
openClient := openClientPerNodeAuth(context.Background(), nil)
521+
if err := applyTemplatesPerNode(engine.Options{}, configFile, []string{node}, openClient, render, apply); err != nil {
522+
t.Fatalf("applyTemplatesPerNode: %v", err)
523+
}
476524
}
477525

478526
// TestTemplateAndApplyDiverge_NodeBodyOverlayLimitation pins a known

0 commit comments

Comments
 (0)