Skip to content

Commit 23ed0ae

Browse files
wrkodeclaude
andcommitted
feat: surface reconcile status on the node (ADR-4-S)
The reconcile runs as a fire-and-forget boot stage whose exit code never flows back to kairos-agent, so a node that failed to converge was a black box unless you SSH in and read journald. Publish the outcome through two best-effort, bounded channels instead. Layer 1 (always): a small, secret-free YAML status doc written atomically to /run/provider-kubernetes/status.yaml plus a /var/log mirror, mode 0640 root:adm. This is the only channel that works pre-membership - exactly the failure you most need to debug (bad endpoint, CA-pin mismatch, init refused). The schema is closed (camelCase keys) and the only free-text field is sanitized, so it cannot carry a secret. Layer 2 (post-membership): the same outcome as own-Node annotations under provider-kubernetes.kairos.io/* via a single bounded kubectl-argv call, reusing the least-privilege kubelet.conf identity already on the node - no client-go, no new RBAC. It no-ops when the node is not a member. Status writing never blocks, never retries forever, and never masks the real reconcile result (#4099-1). Plan stays pure; surfacing is executor and run-wiring only. Validated live on libvirt: a converged control plane publishes phase=Converged plus all annotations, and a worker pointed at an unreachable endpoint writes phase=Failed/reason=ControlPlaneUnreachable with no annotation. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: William Rizzo <william.rizzo@gmail.com>
1 parent 9e42fa6 commit 23ed0ae

15 files changed

Lines changed: 2402 additions & 25 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Usage documentation lives in [`docs/`](./docs/):
181181
| [CNI](./docs/cni.md) | Installing a CNI. |
182182
| [Security model](./docs/security.md) | Tokens, the cert-key blast radius, CA pinning, at-rest encryption. |
183183
| [Lifecycle and reset](./docs/lifecycle.md) | Reconcile, reset, the version window, upgrades. |
184+
| [Node status](./docs/status.md) | Why a node did or did not converge: status file + Node annotations. |
184185
| [Troubleshooting](./docs/troubleshooting.md) | Logs, common failures, filing issues. |
185186

186187
## Contributing

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Usage documentation for **provider-kubernetes**, the Go-native
2525
| [CNI](./cni.md) | Installing a CNI (the provider installs none). |
2626
| [Security model](./security.md) | `cluster_token`, certificate-key blast radius, CA pinning, at-rest encryption, the trust boundary. |
2727
| [Lifecycle and reset](./lifecycle.md) | Reboot idempotency, reset / `EventClusterReset`, the supported version window, upgrades. |
28+
| [Node status](./status.md) | Why a node did or did not converge: the local status file and the own-Node annotations. |
2829
| [Troubleshooting](./troubleshooting.md) | Where logs are, common failures, and what "fail loud, never hang" means in practice. |
2930

3031
## How it works in one paragraph

docs/status.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Node status (why a node did or did not converge)
2+
3+
The provider runs one bounded reconcile pass on each boot. Because that pass runs
4+
as a fire-and-forget boot-time stage, its exit code does not flow back to
5+
`kairos-agent` - so the provider publishes its outcome through two channels you can
6+
inspect directly, instead of making you read journald. Both are **best-effort**: a
7+
status write never blocks the boot, never retries forever, and never changes or
8+
masks the real reconcile result.
9+
10+
## Layer 1 - the local status file (always written)
11+
12+
Every reconcile pass and every reset writes a small YAML status document to:
13+
14+
- `/run/provider-kubernetes/status.yaml` - current boot, on tmpfs (authoritative).
15+
- `/var/log/provider-kubernetes/status.yaml` - a persistent mirror that survives
16+
reboot, for post-mortem after a failed boot.
17+
18+
Both are written atomically (temp file + rename, so a reader never sees a partial
19+
doc) with mode **0640, owner root, group adm** (group-readable by an `adm`-group
20+
monitoring agent; world-unreadable). This is the only channel that works when a
21+
node never joined the cluster - the very failure you most need to debug.
22+
23+
The document carries **no secrets by construction**: every field except `message`
24+
is a closed enum, and `message` is sanitized (bootstrap tokens, PEM blocks, and
25+
long secret-like runs are redacted; it is truncated to one short line).
26+
27+
### Example
28+
29+
```yaml
30+
apiVersion: provider-kubernetes.kairos.io/v1
31+
phase: Failed
32+
role: worker
33+
membership: uninitialized
34+
outcome: failure
35+
reason: ControlPlaneUnreachable
36+
terminal: false
37+
lastAction: wait-for-control-plane
38+
message: "control-plane endpoint not reachable within budget"
39+
budget:
40+
attempts: 3
41+
maxAttempts: 3
42+
updatedAt: "2026-06-03T12:00:00Z"
43+
bootID: 7c9e6679-7425-40de-944b-e07fc1f90ae7
44+
version: v0.2.0
45+
```
46+
47+
### Fields
48+
49+
| Field | Meaning |
50+
|-------|---------|
51+
| `phase` | `Reconciling` (in progress), `Converged` (success), `Failed`, or `Reset`. |
52+
| `role` | The node's declared role: `init`, `controlplane`, or `worker`. |
53+
| `membership` | `uninitialized`, `initialized`, or `joined` (probed actual state). |
54+
| `outcome` | `success` or `failure`. |
55+
| `reason` | Closed enum, empty on success. See table below. |
56+
| `terminal` | `true` if the failure will not be retried on the next boot (fail-fast); `false` if a later boot may converge. |
57+
| `lastAction` | The last kubeadm action attempted (e.g. `run-join`, `upgrade-apply`). |
58+
| `message` | One short, sanitized, operator-facing line. |
59+
| `budget` | `attempts` used out of `maxAttempts`. |
60+
| `updatedAt` | RFC3339 timestamp of this status. |
61+
| `bootID` | The kernel boot ID, so you can tell which boot produced it. |
62+
| `version` | The provider build version. |
63+
64+
### Reason codes
65+
66+
| `reason` | What happened |
67+
|----------|---------------|
68+
| `ControlPlaneUnreachable` | The control-plane endpoint was not reachable within the budget. |
69+
| `JoinTimeout` | A join did not complete within the budget. |
70+
| `InitRefused` | A second `role: init` was refused to avoid clobbering an existing cluster (#4099-5). |
71+
| `UpgradeRefused` | A downgrade, skip-level, or out-of-window upgrade pin was refused. |
72+
| `BudgetExhausted` | The bounded retry budget ran out. |
73+
| `KubeadmError` | A kubeadm action failed. |
74+
| `ConfigInvalid` | The supplied cluster config was invalid (e.g. empty/short `cluster_token`). |
75+
| `ResetFailed` / `ResetOK` | The outcome of an `EventClusterReset`. |
76+
77+
## Layer 2 - Node annotations (when the node is a cluster member)
78+
79+
When - and only when - the node is already a member and a local kubeconfig exists,
80+
the provider **also** records the same outcome as annotations on its own Node
81+
object, so you can see it cluster-wide without SSH:
82+
83+
```sh
84+
kubectl get node <name> -o jsonpath='{.metadata.annotations}' | tr ',' '\n' | grep provider-kubernetes.kairos.io
85+
```
86+
87+
Keys published (all under `provider-kubernetes.kairos.io/`):
88+
`phase`, `outcome`, `reason`, `terminal`, `last-action`, `updated-at`, `version`.
89+
90+
Notes:
91+
92+
- The free-text `message` is **not** published as an annotation - only the closed
93+
enum fields are, so an annotation can never carry a secret.
94+
- These annotations are readable by any principal with `get node`. They are coarse
95+
lifecycle metadata and intentionally carry no sensitive data.
96+
- The write uses the **least-privilege** credential already on the node
97+
(`kubelet.conf`, the `system:node:<name>` identity, preferred over `admin.conf`).
98+
The provider creates **no** new RBAC, ServiceAccount, or token.
99+
- If the node is not a member, has no kubeconfig, or the API is unreachable, this
100+
layer is silently skipped - Layer 1 still has the truth.
101+
102+
## How to use it
103+
104+
- A node didn't come up? Read `/run/provider-kubernetes/status.yaml` (current boot)
105+
or, after a reboot, the `/var/log` mirror. `phase: Failed` + `reason` + `message`
106+
tells you what to fix; `terminal: true` means the next boot won't retry on its
107+
own.
108+
- Watching a fleet? Scrape the Node annotations with `kubectl`.
109+
110+
See also [Lifecycle and reset](./lifecycle.md) and
111+
[Troubleshooting](./troubleshooting.md).

docs/troubleshooting.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Where to look first
44

5+
- **Status file (start here):** `/run/provider-kubernetes/status.yaml` (current
6+
boot) or `/var/log/provider-kubernetes/status.yaml` (persists across reboot). A
7+
one-glance `phase` / `reason` / `message` summary of why the node did or did not
8+
converge, plus whether the failure is `terminal`. On a cluster member the same
9+
outcome is also on the Node as `provider-kubernetes.kairos.io/*` annotations. See
10+
[Node status](./status.md).
511
- **Reconcile log:** `/var/log/provider-kubernetes-reconcile.log` on the node. It
612
records the role, observed membership, the planned actions, and any bounded
713
failure (with kubeadm output, secret-sanitized).

internal/provider/options.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const (
1717
// ReconcileLogPath is where the boot-time reconcile subcommand writes its
1818
// stdout/stderr; surfacing failures to operators (OQ-4 logs-only for v1).
1919
ReconcileLogPath = "/var/log/provider-kubernetes-reconcile.log"
20+
21+
// The Layer-1 structured status paths are defined in internal/status:
22+
// status.StatusRunPath = "/run/provider-kubernetes/status.yaml" (tmpfs, 0640)
23+
// status.StatusLogPath = "/var/log/provider-kubernetes/status.yaml" (persistent, 0640)
24+
// See ADR-4-S for the full design.
2025
)
2126

2227
// Context is the parsed, validated provider input derived from a Cluster. It is a

internal/provider/reset_handler.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"time"
78

89
"github.com/kairos-io/kairos-sdk/bus"
910
"github.com/kairos-io/kairos-sdk/clusterplugin"
@@ -12,22 +13,25 @@ import (
1213
"gopkg.in/yaml.v3"
1314

1415
"github.com/kairos-io/provider-kubernetes/internal/kubeadm"
16+
"github.com/kairos-io/provider-kubernetes/internal/reconcile/actualstate"
1517
"github.com/kairos-io/provider-kubernetes/internal/reset"
18+
"github.com/kairos-io/provider-kubernetes/internal/status"
19+
"github.com/kairos-io/provider-kubernetes/version"
1620
)
1721

1822
// HandleClusterReset is the Kairos "cluster.reset" event handler. It parses the
1923
// payload and performs a bounded, idempotent reset (ADR-4). It deliberately does
2024
// NOT run cluster_token validation: reset must succeed regardless of token state.
2125
func HandleClusterReset(event *pluggable.Event) pluggable.EventResponse {
22-
return handleClusterReset(event, kubeadm.ExecRunner{})
26+
return handleClusterReset(event, kubeadm.ExecRunner{}, status.NewFileSink())
2327
}
2428

2529
// maxResetPayloadBytes caps the event payload size before unmarshaling, to bound
2630
// CPU/memory of (in-process but still untrusted-by-shape) YAML/JSON parsing.
2731
const maxResetPayloadBytes = 1 << 20 // 1 MiB
2832

29-
// handleClusterReset is the testable core (runner injected).
30-
func handleClusterReset(event *pluggable.Event, runner kubeadm.Runner) pluggable.EventResponse {
33+
// handleClusterReset is the testable core (runner and sink injected).
34+
func handleClusterReset(event *pluggable.Event, runner kubeadm.Runner, sink status.StatusSink) pluggable.EventResponse {
3135
var resp pluggable.EventResponse
3236
if event == nil {
3337
return resp
@@ -69,12 +73,35 @@ func handleClusterReset(event *pluggable.Event, runner kubeadm.Runner) pluggable
6973
}
7074

7175
logrus.Infof("provider-kubernetes: handling cluster reset (root=%s)", rootPath)
72-
if err := reset.Run(context.Background(), reset.Options{
76+
resetErr := reset.Run(context.Background(), reset.Options{
7377
Runner: runner,
7478
RootPath: rootPath,
7579
CRISocket: criSocket,
76-
}); err != nil {
77-
resp.Error = fmt.Sprintf("cluster reset: %s", err)
80+
})
81+
82+
// S4: write terminal reset status. Best-effort, bounded, swallowed on error.
83+
writeResetStatus(sink, actualstate.Role(string(config.Cluster.Role)), resetErr)
84+
85+
if resetErr != nil {
86+
resp.Error = fmt.Sprintf("cluster reset: %s", resetErr)
7887
}
7988
return resp
8089
}
90+
91+
// writeResetStatus records a terminal Phase=Reset status after a cluster reset.
92+
// It is best-effort: write errors are swallowed so they never mask the reset result.
93+
func writeResetStatus(sink status.StatusSink, role actualstate.Role, resetErr error) {
94+
if sink == nil {
95+
return
96+
}
97+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
98+
defer cancel()
99+
sink.Record(ctx, status.BuildStatus(status.BuildParams{
100+
Role: role,
101+
IsReset: true,
102+
ResetErr: resetErr,
103+
Now: time.Now().UTC().Format(time.RFC3339),
104+
BootID: readBootID(),
105+
Version: version.Version,
106+
}))
107+
}

internal/provider/reset_handler_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestHandleClusterResetRunsReset(t *testing.T) {
3838
clusterYAML := "cluster:\n providerConfig:\n cluster_root_path: " + root + "\n role: init\n"
3939
fr := &resetFakeRunner{}
4040

41-
resp := handleClusterReset(resetEvent(t, clusterYAML), fr)
41+
resp := handleClusterReset(resetEvent(t, clusterYAML), fr, nil)
4242
if resp.Error != "" {
4343
t.Fatalf("unexpected error: %s", resp.Error)
4444
}
@@ -51,14 +51,14 @@ func TestHandleClusterResetRunsReset(t *testing.T) {
5151
}
5252

5353
func TestHandleClusterResetNilEventIsSafe(t *testing.T) {
54-
if resp := handleClusterReset(nil, &resetFakeRunner{}); resp.Error != "" {
54+
if resp := handleClusterReset(nil, &resetFakeRunner{}, nil); resp.Error != "" {
5555
t.Fatalf("nil event must be a safe no-op, got %q", resp.Error)
5656
}
5757
}
5858

5959
func TestHandleClusterResetNoClusterIsNoop(t *testing.T) {
6060
fr := &resetFakeRunner{}
61-
resp := handleClusterReset(resetEvent(t, "other: value\n"), fr)
61+
resp := handleClusterReset(resetEvent(t, "other: value\n"), fr, nil)
6262
if resp.Error != "" {
6363
t.Fatalf("unexpected error: %s", resp.Error)
6464
}
@@ -73,7 +73,7 @@ func TestHandleClusterResetRejectsOversizedPayload(t *testing.T) {
7373
for i := range big {
7474
big[i] = 'a'
7575
}
76-
resp := handleClusterReset(&pluggable.Event{Data: string(big)}, &resetFakeRunner{})
76+
resp := handleClusterReset(&pluggable.Event{Data: string(big)}, &resetFakeRunner{}, nil)
7777
if resp.Error == "" {
7878
t.Fatal("expected reset to reject oversized payload, got nil error")
7979
}
@@ -83,7 +83,7 @@ func TestHandleClusterResetIgnoresTokenValidation(t *testing.T) {
8383
// Reset must work even with an empty/invalid cluster_token (no token gate).
8484
root := t.TempDir()
8585
clusterYAML := "cluster:\n cluster_token: \"\"\n providerConfig:\n cluster_root_path: " + root + "\n"
86-
resp := handleClusterReset(resetEvent(t, clusterYAML), &resetFakeRunner{})
86+
resp := handleClusterReset(resetEvent(t, clusterYAML), &resetFakeRunner{}, nil)
8787
if resp.Error != "" {
8888
t.Fatalf("reset must not fail on empty token, got %q", resp.Error)
8989
}

0 commit comments

Comments
 (0)