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
62 changes: 43 additions & 19 deletions internal/fleet/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"errors"
"fmt"
"io"
"strings"
"time"

connect "connectrpc.com/connect"
Expand All @@ -40,7 +41,11 @@ const (
phaseAwaitingReboot = "awaiting-reboot"
phaseCeremony = "ceremony"
phaseEstablished = "established"
phaseError = "error"
// phaseAwaitingCertificate is the terminal phase for a subordinate
// (intermediate/issuing) adoption: the node is provisioned and awaiting a
// parent-signed certificate, delivered separately by a SUBORDINATE enrollment.
phaseAwaitingCertificate = "awaiting-certificate"
phaseError = "error"
)

// rebootWait bounds how long AdoptNode waits for a node to install, self-reboot,
Expand Down Expand Up @@ -177,24 +182,30 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest
}
defer func() { _ = conn.Close() }()

// Drive the first-boot ceremony, relaying each event as a ceremony phase.
if err := send(phaseCeremony, "starting first-boot ceremony", false); err != nil {
return err
}
yaml, err := marshalConfigYAML(cfg)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: marshal config: %w", err))
}
cstream, err := conn.StartCeremony(ctx, cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, yaml)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: start ceremony: %w", err))
}
complete, err := relayCeremony(cstream, send)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, err)
}
if !complete {
return s.adoptFail(send, connect.CodeInternal, errors.New("fleet: ceremony stream ended before completing"))
// A root self-signs its CA via the first-boot ceremony. A subordinate
// (intermediate/issuing) node cannot: the node has already staged its own
// subordinate CSR on boot and is awaiting a parent-signed chain, which a
// SUBORDINATE enrollment delivers as a separate admin-approved step. Only a
// root reaches "established" during adoption.
if isRootRole(cfg) {
if err := send(phaseCeremony, "starting first-boot ceremony", false); err != nil {
return err
}
yaml, err := marshalConfigYAML(cfg)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: marshal config: %w", err))
}
cstream, err := conn.StartCeremony(ctx, cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, yaml)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: start ceremony: %w", err))
}
complete, err := relayCeremony(cstream, send)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, err)
}
if !complete {
return s.adoptFail(send, connect.CodeInternal, errors.New("fleet: ceremony stream ended before completing"))
}
}

// Register the node with the manager-held bootstrap admin credentials so
Expand All @@ -211,6 +222,10 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest
TargetPath: "/nodes/" + adoptedNodeName(cfg, endpoint),
})

if !isRootRole(cfg) {
return send(phaseAwaitingCertificate,
"subordinate node provisioned and awaiting a parent-signed certificate (complete via a subordinate enrollment)", true)
}
return send(phaseEstablished, "node adopted and established", true)
}

Expand Down Expand Up @@ -338,6 +353,15 @@ func adoptedNodeRole(cfg *cryptosv1.MachineConfig) string {
return "node"
}

// isRootRole reports whether the config declares a root CA node — the only role
// that self-signs via the first-boot ceremony. Intermediate and issuing nodes
// are subordinates, established later by a parent-signed enrollment. An omitted
// role is treated as root, matching the adopt wizard's default.
func isRootRole(cfg *cryptosv1.MachineConfig) bool {
kind := cfg.GetRole().GetKind()
return kind == "" || strings.EqualFold(kind, "root")
}

// marshalConfigYAML renders a MachineConfig for the node's StartCeremony, which
// parses the bytes with a strict (KnownFields) YAML decoder. JSON is valid
// YAML, so protojson output parses server-side — but the node's config keys are
Expand Down
43 changes: 43 additions & 0 deletions internal/fleet/adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,49 @@ func TestRunAdoption_HappyPath_StreamsPhasesRegistersAndAudits(t *testing.T) {
}
}

func TestRunAdoption_Subordinate_AwaitsCertificate_NoCeremony(t *testing.T) {
adoptCredsBaseDir = t.TempDir()
st := memory.New(nil)
mconn := &fakeConn{applyConfigResp: &cryptosv1.ApplyConfigResponse{RequiresReboot: true, Generation: 1}}
// A subordinate node comes back running but has NO ceremony: it staged its
// own subordinate CSR on boot and awaits a parent-signed chain. The ceremony
// stream is intentionally absent — running it would be a bug.
running := &fakeConn{status: &cryptosv1.GetStatusResponse{}}
svc := New(st, dialFor(map[string]*fakeConn{"sub-node": running})).WithAdoption(nil,
func(endpoint, pin, clientCertPEM, clientKeyPEM string) (NodeConn, error) { return mconn, nil })

restore := setRebootTiming(5*time.Millisecond, 1*time.Millisecond, 1*time.Millisecond)
defer restore()

cfg := &cryptosv1.MachineConfig{
Metadata: &cryptosv1.Metadata{Name: "sub-node"},
Role: &cryptosv1.Role{Kind: "intermediate"},
}
sink := &collectSink{}
err := svc.runAdoption(context.Background(), &fleetv1.AdoptNodeRequest{
Endpoint: "node:4443", PinnedCertSha256: "abc", Config: cfg,
}, sink.send)
if err != nil {
t.Fatalf("runAdoption subordinate error = %v", err)
}

if !sink.done {
t.Error("subordinate adoption did not stream a terminal done phase")
}
if !containsPhase(sink.phases, phaseAwaitingCertificate) {
t.Errorf("phases = %v, want a terminal awaiting-certificate", sink.phases)
}
if containsPhase(sink.phases, phaseCeremony) || containsPhase(sink.phases, phaseEstablished) {
t.Errorf("phases = %v, a subordinate must not run the root ceremony or reach established", sink.phases)
}
if len(running.gotCeremonyYAML) != 0 {
t.Error("StartCeremony was called on a subordinate node")
}
if _, ok := st.Node("sub-node"); !ok {
t.Error("adopted subordinate was not registered in the inventory")
}
}

func TestRunAdoption_RebootNeverReturns_StreamsErrorPhase_NoHang(t *testing.T) {
adoptCredsBaseDir = t.TempDir()
st := memory.New(nil)
Expand Down
Loading