recovery: harden post-start auto-heal
This commit is contained in:
parent
305be42805
commit
72ce4a942c
@ -56,6 +56,9 @@ var (
|
||||
etcdRestoreOrchestratorCommand = func(ctx context.Context, orch *cluster.Orchestrator, opts cluster.EtcdRestoreOptions) error {
|
||||
return orch.EtcdRestore(ctx, opts)
|
||||
}
|
||||
autoHealOrchestratorCommand = func(ctx context.Context, orch *cluster.Orchestrator) error {
|
||||
return orch.RunPostStartAutoHeal(ctx)
|
||||
}
|
||||
daemonRunCommand = func(ctx context.Context, daemon *service.Daemon) error { return daemon.Run(ctx) }
|
||||
readIntentCommand = state.ReadIntent
|
||||
writeIntentCommand = state.MustWriteIntent
|
||||
@ -132,6 +135,25 @@ func runStartup(logger *log.Logger, args []string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// runAutoHeal runs one orchestration or CLI step.
|
||||
// Signature: runAutoHeal(logger *log.Logger, args []string) error.
|
||||
// Why: operators need a narrow way to run Ananke's daemon repair pass once
|
||||
// without invoking a full startup or depending on the UPS daemon loop.
|
||||
func runAutoHeal(logger *log.Logger, args []string) error {
|
||||
fs := flag.NewFlagSet("auto-heal", flag.ExitOnError)
|
||||
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
|
||||
execute := fs.Bool("execute", false, "Actually execute changes (default dry-run)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
_, orch, err := buildOrchestratorCommand(logger, *configPath, !*execute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
return autoHealOrchestratorCommand(ctx, orch)
|
||||
}
|
||||
|
||||
// runShutdown runs one orchestration or CLI step.
|
||||
// Signature: runShutdown(logger *log.Logger, args []string) error.
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
|
||||
40
cmd/ananke/command_handlers_autoheal_test.go
Normal file
40
cmd/ananke/command_handlers_autoheal_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"scm.bstein.dev/bstein/ananke/internal/cluster"
|
||||
"scm.bstein.dev/bstein/ananke/internal/config"
|
||||
)
|
||||
|
||||
// TestRunAutoHealInvokesPostStartRepair runs one orchestration or CLI step.
|
||||
// Signature: TestRunAutoHealInvokesPostStartRepair(t *testing.T).
|
||||
// Why: the one-shot auto-heal command must call the same repair entrypoint as
|
||||
// the daemon loop so operators can run Ananke's post-start repairs directly.
|
||||
func TestRunAutoHealInvokesPostStartRepair(t *testing.T) {
|
||||
restore := stubCommandHandlerHooks()
|
||||
defer restore()
|
||||
|
||||
called := false
|
||||
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
|
||||
cfg := minimalHandlerConfig(t)
|
||||
return cfg, newTestOrchestrator(cfg, dryRun), nil
|
||||
}
|
||||
autoHealOrchestratorCommand = func(_ context.Context, orch *cluster.Orchestrator) error {
|
||||
called = true
|
||||
if orch == nil {
|
||||
t.Fatalf("expected orchestrator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
if err := runAutoHeal(logger, []string{"--config", "/ignored.yaml", "--execute"}); err != nil {
|
||||
t.Fatalf("expected auto-heal success, got %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatalf("expected auto-heal orchestrator hook to be called")
|
||||
}
|
||||
}
|
||||
@ -403,6 +403,7 @@ func stubCommandHandlerHooks() func() {
|
||||
prevStartup := startupOrchestratorCommand
|
||||
prevShutdown := shutdownOrchestratorCommand
|
||||
prevRestore := etcdRestoreOrchestratorCommand
|
||||
prevAutoHeal := autoHealOrchestratorCommand
|
||||
prevDaemon := daemonRunCommand
|
||||
prevReadIntent := readIntentCommand
|
||||
prevWriteIntent := writeIntentCommand
|
||||
@ -416,6 +417,7 @@ func stubCommandHandlerHooks() func() {
|
||||
startupOrchestratorCommand = prevStartup
|
||||
shutdownOrchestratorCommand = prevShutdown
|
||||
etcdRestoreOrchestratorCommand = prevRestore
|
||||
autoHealOrchestratorCommand = prevAutoHeal
|
||||
daemonRunCommand = prevDaemon
|
||||
readIntentCommand = prevReadIntent
|
||||
writeIntentCommand = prevWriteIntent
|
||||
|
||||
@ -11,6 +11,7 @@ var (
|
||||
shutdownCommand = runShutdown
|
||||
etcdRestoreCommand = runEtcdRestore
|
||||
daemonCommand = runDaemon
|
||||
autoHealCommand = runAutoHeal
|
||||
statusCommand = runStatus
|
||||
intentCommand = runIntent
|
||||
)
|
||||
@ -54,6 +55,11 @@ func runMain(logger *log.Logger, args []string) int {
|
||||
logger.Printf("daemon failed: %v", err)
|
||||
return 1
|
||||
}
|
||||
case "auto-heal":
|
||||
if err := autoHealCommand(logger, args[2:]); err != nil {
|
||||
logger.Printf("auto-heal failed: %v", err)
|
||||
return 1
|
||||
}
|
||||
case "status":
|
||||
if err := statusCommand(logger, args[2:]); err != nil {
|
||||
logger.Printf("status failed: %v", err)
|
||||
@ -88,6 +94,7 @@ Commands:
|
||||
shutdown Perform graceful cluster shutdown
|
||||
etcd-restore Restore etcd from snapshot on a control plane
|
||||
daemon Monitor UPS and auto-trigger shutdown
|
||||
auto-heal Run one post-start auto-heal pass
|
||||
status Print current ananke status and estimates
|
||||
intent Read or manually set intent state
|
||||
|
||||
@ -96,6 +103,7 @@ Examples:
|
||||
ananke shutdown --config /etc/ananke/ananke.yaml --execute --mode cluster-only --reason "manual-maintenance"
|
||||
ananke etcd-restore --config /etc/ananke/ananke.yaml --execute
|
||||
ananke daemon --config /etc/ananke/ananke.yaml
|
||||
ananke auto-heal --config /etc/ananke/ananke.yaml --execute
|
||||
ananke status --config /etc/ananke/ananke.yaml
|
||||
ananke intent --config /etc/ananke/ananke.yaml --set normal --reason "manual-clear" --execute
|
||||
|
||||
|
||||
@ -71,10 +71,11 @@ func TestRunMainDispatchSuccessPaths(t *testing.T) {
|
||||
func(_ *log.Logger, _ []string) error { return nil },
|
||||
func(_ *log.Logger, _ []string) error { return nil },
|
||||
func(_ *log.Logger, _ []string) error { return nil },
|
||||
func(_ *log.Logger, _ []string) error { return nil },
|
||||
)
|
||||
defer restore()
|
||||
|
||||
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "status", "intent", "help", "-h", "--help"}
|
||||
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "auto-heal", "status", "intent", "help", "-h", "--help"}
|
||||
for _, command := range commands {
|
||||
code := runMain(logger, []string{"ananke", command})
|
||||
if code != 0 {
|
||||
@ -96,10 +97,11 @@ func TestRunMainDispatchErrorPaths(t *testing.T) {
|
||||
func(_ *log.Logger, _ []string) error { return fail },
|
||||
func(_ *log.Logger, _ []string) error { return fail },
|
||||
func(_ *log.Logger, _ []string) error { return fail },
|
||||
func(_ *log.Logger, _ []string) error { return fail },
|
||||
)
|
||||
defer restore()
|
||||
|
||||
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "status", "intent"}
|
||||
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "auto-heal", "status", "intent"}
|
||||
for _, command := range commands {
|
||||
code := runMain(logger, []string{"ananke", command})
|
||||
if code != 1 {
|
||||
@ -122,13 +124,14 @@ func TestRunMainUsageAndUnknownPaths(t *testing.T) {
|
||||
}
|
||||
|
||||
// stubDispatchCommands runs one orchestration or CLI step.
|
||||
// Signature: stubDispatchCommands(startup, shutdown, etcdRestore, daemon, status, intent func(*log.Logger, []string) error) func().
|
||||
// Signature: stubDispatchCommands(startup, shutdown, etcdRestore, daemon, autoHeal, status, intent func(*log.Logger, []string) error) func().
|
||||
// Why: keeps command-var overrides scoped and safely restorable across tests.
|
||||
func stubDispatchCommands(
|
||||
startup,
|
||||
shutdown,
|
||||
etcdRestore,
|
||||
daemon,
|
||||
autoHeal,
|
||||
status,
|
||||
intent func(*log.Logger, []string) error,
|
||||
) func() {
|
||||
@ -136,6 +139,7 @@ func stubDispatchCommands(
|
||||
prevShutdown := shutdownCommand
|
||||
prevEtcdRestore := etcdRestoreCommand
|
||||
prevDaemon := daemonCommand
|
||||
prevAutoHeal := autoHealCommand
|
||||
prevStatus := statusCommand
|
||||
prevIntent := intentCommand
|
||||
|
||||
@ -143,6 +147,7 @@ func stubDispatchCommands(
|
||||
shutdownCommand = shutdown
|
||||
etcdRestoreCommand = etcdRestore
|
||||
daemonCommand = daemon
|
||||
autoHealCommand = autoHeal
|
||||
statusCommand = status
|
||||
intentCommand = intent
|
||||
|
||||
@ -151,6 +156,7 @@ func stubDispatchCommands(
|
||||
shutdownCommand = prevShutdown
|
||||
etcdRestoreCommand = prevEtcdRestore
|
||||
daemonCommand = prevDaemon
|
||||
autoHealCommand = prevAutoHeal
|
||||
statusCommand = prevStatus
|
||||
intentCommand = prevIntent
|
||||
}
|
||||
|
||||
465
docs/ananke-hardening-prompt-2026-07-07.md
Normal file
465
docs/ananke-hardening-prompt-2026-07-07.md
Normal file
@ -0,0 +1,465 @@
|
||||
# Prompt For Ananke Recovery Hardening
|
||||
|
||||
You are a fresh, stateless Codex session working in the Ananke repository. Your goal is to implement, test, and validate generalized recovery improvements in Ananke based on a real Titan cluster power-loss incident on 2026-07-07. Treat this as production reliability work: read the repo first, follow existing code style and architecture, keep changes cohesive, and solve failure classes categorically rather than hard-coding node names, namespaces, or one-off incidents.
|
||||
|
||||
## Mission
|
||||
|
||||
Make Ananke resilient and automatic during post-power-loss cluster recovery. After your changes, one Ananke startup/recovery process should be able to:
|
||||
|
||||
- Bring Vault and critical dependencies back safely.
|
||||
- Repair or escalate wedged k3s/containerd nodes using existing Ananke SSH/host access.
|
||||
- Use sudo credentials from Vault, securely and noninteractively, for host repair and diagnostics.
|
||||
- Avoid leaving Longhorn storage nodes cordoned indefinitely.
|
||||
- Detect Kubernetes/Longhorn readiness mismatches and repair label/manager/VolumeAttachment drift.
|
||||
- Resolve stale pod/PVC ownership safely, with different behavior for sidecar-only stale owners versus still-running application containers that mount the PVC.
|
||||
- Continue post-success convergence monitoring long enough to catch late node/runtime/storage wedges.
|
||||
- Report concise, actionable blockers without noisy repeated pod recycling.
|
||||
|
||||
Do not implement special cases for `titan-04`, `titan-05`, `titan-15`, `titan-17`, `titan-22`, Firefly, Jellyfin, or any specific app. Use those names only as incident fixtures/test names. The code should operate on generic managed nodes, generic Longhorn nodes, generic Kubernetes workloads, and generic RWO PVCs.
|
||||
|
||||
## Incident Context
|
||||
|
||||
The Titan cluster lost power for a few minutes. Ariadne may have started shutdown behavior. The operator asked for a single Ananke process to bring the cluster back up and learn from any manual help required.
|
||||
|
||||
Ananke did a lot correctly:
|
||||
|
||||
- It recovered Vault and unsealed it.
|
||||
- It recovered critical workloads, Vault injector, Postgres, Keycloak, OpenSearch, oauth2 logs, CoreDNS, metrics-server, and many app pods.
|
||||
- It ran a single startup workflow and eventually completed successfully.
|
||||
- It recycled many stuck pods and ran several checks.
|
||||
|
||||
But Ananke needed manual assistance in multiple reusable failure classes:
|
||||
|
||||
- Node runtime wedges where Kubernetes readiness did not fully reflect kubelet/containerd health.
|
||||
- Host repairs blocked by sudo privilege gaps.
|
||||
- Longhorn nodes cordoned by Kubernetes remained unavailable for storage scheduling.
|
||||
- Longhorn readiness diverged from Kubernetes readiness.
|
||||
- Missing node labels caused Longhorn manager pods to disappear.
|
||||
- Stale terminating pods held RWO volumes and blocked replacement pods.
|
||||
- Repeated pod deletes did not resolve storage ownership or runtime reservation issues.
|
||||
- Ananke declared startup success before all late runtime/storage fallout was resolved.
|
||||
|
||||
## Live Incident Facts To Encode As Tests
|
||||
|
||||
Use these as fixtures for tests and documentation, not as hard-coded behavior:
|
||||
|
||||
- `titan-05`: k3s-agent restart hung. Host SSH worked. `k3s-agent.service` entered `deactivating`, `final-sigkill`, `activating/start`, and `Result=timeout`. Kubelet port `10250` stayed closed. Kubernetes heartbeat was stale with `node.kubernetes.io/unreachable` taints. In-cluster helper pod could not start. Manual controlled reboot recovered it.
|
||||
- `titan-04`: after Ananke reported startup success, the node was Kubernetes `Ready` and kubelet `10250` initially open, but new pods failed with `FailedCreatePodSandBox`, `failed to reserve sandbox name`, and `context deadline exceeded`. A non-blocking `k3s-agent` restart then stuck in `deactivating/stop-sigkill`, kubelet `10250` closed, Kubernetes moved to `Ready=Unknown`, and a controlled reboot recovered it.
|
||||
- `titan-07`: a pod looked running in the API, but `kubectl exec` failed with `cannot exec in a deleted state`; restarting k3s-agent and recreating the pod fixed it.
|
||||
- `titan-14`: a replacement pod hit `failed to reserve container name` / `CreateContainerError`; k3s-agent restart and rerunning the in-cluster helper fixed it. Later another pod on the same node showed the same sandbox reservation pattern, but then progressed after enough time. Tests should distinguish transient progress from a stall.
|
||||
- `titan-15` and `titan-17`: Ananke cordoned them after encrypted Longhorn mounts exposed missing `cryptsetup`. Longhorn itself had `allowScheduling=true` and ready disks, but Longhorn `Schedulable=False` because Kubernetes had cordoned the nodes. Manual `kubectl uncordon titan-15 titan-17` restored Kubernetes scheduling and Longhorn schedulability. Ananke had attempted `cryptsetup-bin` repair but failed because `atlas` sudo required a password.
|
||||
- `titan-22`: Kubernetes `Ready=True`, CSI plugin and engine images were running, but Longhorn node `Ready=False` because `longhorn-manager` pod was missing. The root cause was missing node label `longhorn-host=true`, while the `longhorn-manager` DaemonSet selected `longhorn-host=true`. Manual `kubectl label node titan-22 longhorn-host=true --overwrite` started `longhorn-manager` and Longhorn node Ready became true. A pending wallet pod still carried an old failed VolumeAttachment until the pod was deleted/recreated.
|
||||
- Firefly stale owner, safe-to-force variant: `finance/firefly-...-q8rtq` was terminating for more than 25 minutes, had no finalizers, and only a Vault sidecar remained running. Replacement pod was blocked by `Multi-Attach` for an RWO PVC. Force-deleting the stale pod cleared ownership and allowed Longhorn attach to the replacement.
|
||||
- Firefly stale owner, unsafe-to-force variant: a later terminating Firefly pod still had the main application container running and mounting the RWO PVC. It was not safe to blindly force-delete. Waiting allowed kubelet to finish termination, then Longhorn attached the volume to the replacement.
|
||||
- Manual runtime inspection attempt on `titan-06`: `sudo -n crictl ...` failed because `atlas` required a sudo password. Ananke is expected to have access to sudo passwords in Vault, so this must be designed as a first-class Ananke capability rather than manual operator shell access.
|
||||
- Vault health check robustness: at one point `kubectl exec vault-0 -- vault status` was killed, but HTTP health via Kubernetes pod proxy showed `initialized=true`, `sealed=false`, `standby=false`. Startup should not fail solely on transient `kubectl exec` failure when endpoint and HTTP health prove Vault is available.
|
||||
- Image/DNS issues: several image pulls failed with `lookup registry-1.docker.io`, `lookup auth.docker.io`, or `lookup production.cloudfront.docker.com: Try again`, then later succeeded. Ananke repeatedly recycling unchanged ImagePullBackOff pods created noise.
|
||||
|
||||
## Required Implementation Areas
|
||||
|
||||
### 1. Secure Sudo Capability From Vault
|
||||
|
||||
Ananke should be able to run bounded host diagnostics and repairs that require sudo.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Discover and use the existing Vault integration and secret naming conventions already present in the repo/config. Do not invent a new hard-coded secret path if the repo has one.
|
||||
- Support noninteractive sudo for configured managed nodes.
|
||||
- Never log sudo passwords, Vault tokens, rendered secrets, command input containing secrets, or full environment dumps.
|
||||
- Provide a preflight check: for each managed node, can Ananke retrieve host sudo material and can it run a harmless command such as `sudo -S -p '' true` with a timeout?
|
||||
- If credentials are missing, wrong, or Vault is unavailable, Ananke should report `host-privilege-unavailable` with node, intended action, and secret lookup class, not spin.
|
||||
- Use strict command allowlists for privileged commands. The recovery path should not become arbitrary remote root execution.
|
||||
- Use bounded timeouts for all host commands and classify timeout versus auth failure versus command failure.
|
||||
- Prefer safe wrappers for:
|
||||
- `systemctl show/is-active/restart --no-block k3s-agent`
|
||||
- `systemctl reboot` when configured and escalated
|
||||
- `crictl ps/pods/inspect/stop` for recovery diagnostics and controlled container stop
|
||||
- package checks/install for `cryptsetup-bin`, `open-iscsi`, `nfs-common`, `dmsetup`
|
||||
- `modprobe dm_crypt` or equivalent kernel-module validation where appropriate
|
||||
|
||||
Tests:
|
||||
|
||||
- Vault lookup succeeds and sudo command succeeds.
|
||||
- Vault lookup succeeds but sudo password is wrong.
|
||||
- Vault lookup missing.
|
||||
- Vault unavailable while Vault HTTP health is also degraded.
|
||||
- Vault unavailable but action does not require sudo.
|
||||
- Command times out.
|
||||
- Command returns nonzero.
|
||||
- Logs are scanned to ensure no password appears.
|
||||
|
||||
### 2. Managed Node Runtime Recovery Primitive
|
||||
|
||||
Implement a generic state machine for node runtime recovery.
|
||||
|
||||
Detection signals:
|
||||
|
||||
- Kubernetes node `Ready=Unknown` or stale heartbeat.
|
||||
- Kubernetes node `Ready=True` but pods on that node show repeated:
|
||||
- `FailedCreatePodSandBox`
|
||||
- `failed to reserve sandbox name`
|
||||
- `failed to reserve container name`
|
||||
- `CreateContainerError`
|
||||
- `FailedKillPod`
|
||||
- long-lived `ContainerCreating` or `PodInitializing` with no progress
|
||||
- `kubectl exec`/logs failures indicating deleted runtime state
|
||||
- Host SSH works but kubelet port `127.0.0.1:10250` is closed or slow.
|
||||
- `systemctl show k3s-agent` reports `deactivating`, `stop-sigterm`, `stop-sigkill`, `final-sigterm`, `final-sigkill`, `activating/start`, or `Result=timeout`.
|
||||
- Existing in-cluster maintenance helper pod for the node cannot start, stays `ContainerCreating`, or cannot be scheduled because the node runtime is already unhealthy.
|
||||
|
||||
Escalation ladder:
|
||||
|
||||
- Open a single node recovery incident and suppress noisy repeated pod recycling for pods on that node while host repair is in progress.
|
||||
- Cordon the node before host-level intervention unless it is already unreachable/tainted.
|
||||
- Try in-cluster maintenance helper if kubelet/runtime can create pods.
|
||||
- If helper cannot run, use Ananke SSH route and sudo from Vault.
|
||||
- Prefer `systemctl --no-block restart k3s-agent`; never use a blocking restart over SSH as the primary recovery action.
|
||||
- Wait a bounded interval and require both:
|
||||
- Kubernetes Ready=True with fresh heartbeat
|
||||
- local kubelet `10250` open
|
||||
- If k3s-agent remains stuck in stop/kill/activating states or heartbeats remain stale, escalate to configured controlled reboot.
|
||||
- After reboot, wait for SSH, k3s-agent active/running, kubelet port open, Kubernetes Ready=True, and unreachable taints cleared.
|
||||
- Uncordon only after the node is healthy and after storage-specific checks pass.
|
||||
- Continue this monitoring after startup success for a configurable post-success convergence window.
|
||||
|
||||
Tests:
|
||||
|
||||
- Ready node with repeated sandbox reservation events triggers recovery.
|
||||
- Ready node with one transient sandbox event and then progress does not trigger restart.
|
||||
- Unknown node with SSH unavailable reports external blocker without repeated action.
|
||||
- Unknown node with SSH available, kubelet closed, k3s-agent stuck, helper unavailable escalates to reboot.
|
||||
- Non-blocking restart recovers node.
|
||||
- Non-blocking restart moves service to `deactivating/stop-sigkill`; reboot escalation occurs.
|
||||
- Reboot command accepted but SSH does not return within timeout.
|
||||
- Reboot returns and all readiness conditions clear.
|
||||
- False positive prevention: node Ready=True, kubelet open, events old/stale only, no action.
|
||||
- Idempotency: repeated Ananke runs do not issue repeated restarts/reboots while an incident is active.
|
||||
|
||||
### 3. Longhorn And Kubernetes Readiness Reconciliation
|
||||
|
||||
Ananke must not treat Kubernetes node Ready as sufficient for storage workloads.
|
||||
|
||||
Implement a Longhorn readiness reconciler:
|
||||
|
||||
- Read Kubernetes Nodes, Longhorn Node CRs, Longhorn manager DaemonSet, Longhorn manager pods, CSI pods, engine-image pods, Volume CRs, VolumeAttachments, and relevant PVC-bound pods.
|
||||
- Compare:
|
||||
- Kubernetes Ready/SchedulingDisabled/taints
|
||||
- Node labels required by Longhorn manager DaemonSet, especially `longhorn-host=true`
|
||||
- Longhorn Node `Ready` and `Schedulable` conditions
|
||||
- Longhorn Node `spec.allowScheduling`
|
||||
- Longhorn manager pod presence on nodes where Longhorn Node exists or where PVC workloads are allowed
|
||||
- CSI plugin readiness
|
||||
- VolumeAttachment errors that reference stale Longhorn node readiness
|
||||
- Detect label drift: Longhorn node exists and CSI/engine pods exist, but manager pod is missing because the node no longer matches the DaemonSet selector.
|
||||
- Repair label drift only if it is safe:
|
||||
- There is a known desired label source in repo/config/state, or
|
||||
- Longhorn Node CR exists for that Kubernetes node and existing Longhorn metadata indicates the node is intended to be a Longhorn node.
|
||||
- Emit exactly what label will be restored and why.
|
||||
- If safe repair cannot be proven, cordon or avoid scheduling PVC workloads to the node and report a blocker.
|
||||
- After restoring labels or manager pods, wait for Longhorn Node Ready=True.
|
||||
- For VolumeAttachments that failed because Longhorn thought a now-ready node was not ready, trigger a bounded retry:
|
||||
- Prefer deleting/recreating the controller-owned Pending pod if that is the established pattern.
|
||||
- Delete stale failed VolumeAttachment only if Kubernetes controller semantics and repo policy allow it.
|
||||
- Never delete PV/PVC/Longhorn Volume data.
|
||||
|
||||
Specific expectations from incident:
|
||||
|
||||
- `titan-22` class: Kubernetes Ready, missing `longhorn-host=true`, no manager pod, Longhorn Node Ready=False ManagerPodMissing. Ananke should restore label or report exact unsafe reason, then wait for manager pod and Longhorn Ready.
|
||||
- `titan-15`/`titan-17` class: Kubernetes cordon makes Longhorn Schedulable=False despite Longhorn `allowScheduling=true` and ready disks. Ananke should not leave them cordoned after the blocking repair condition is resolved or after operator-approved override.
|
||||
|
||||
Tests:
|
||||
|
||||
- Longhorn manager selector changes are handled generically.
|
||||
- Missing Longhorn label restored from desired state.
|
||||
- Missing Longhorn label not restored if node lacks Longhorn Node CR and no desired-state evidence exists.
|
||||
- Longhorn Node Ready=False ManagerPodMissing becomes Ready after label restoration and manager pod startup.
|
||||
- VolumeAttachment error due stale Longhorn node readiness is retried after Longhorn Ready.
|
||||
- Kubernetes cordoned node causes Longhorn Schedulable=False and is uncordoned after repair.
|
||||
- Kubernetes cordoned node remains cordoned if required package/kernel preflight still fails and no override is configured.
|
||||
- `allowScheduling=false` is respected; Ananke should not enable Longhorn disk scheduling unless explicitly configured, but it may still ensure manager/CSI readiness for attachment if workloads are allowed there.
|
||||
|
||||
### 4. Cordon/Uncordon And Host Package Repair Policy
|
||||
|
||||
Ananke cordoned `titan-15` and `titan-17` because encrypted Longhorn mounts exposed missing `cryptsetup`; it failed to install because sudo required a password. This must become a closed-loop repair.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Distinguish:
|
||||
- Kubernetes scheduling cordon
|
||||
- Longhorn disk scheduling (`allowScheduling`)
|
||||
- Longhorn node readiness/schedulability
|
||||
- ordinary `PreferNoSchedule` taints used as soft placement hints
|
||||
- If Ananke cordons a node, it must record why, when, what remediation is required, and what condition will uncordon it.
|
||||
- If the remediation is package/module installation, use Vault-backed sudo and verify the result.
|
||||
- If package repair succeeds, uncordon automatically and verify Longhorn Schedulable=True.
|
||||
- If package repair fails due missing sudo secret, wrong sudo password, package manager lock, apt/dpkg failure, or network outage, report the exact class and do not leave the node silently disabled.
|
||||
- Support an operator override policy for "uncordon for Longhorn availability even if encrypted workload preflight is not perfect", but this must be explicit/configured and logged.
|
||||
- Do not remove existing soft taints such as `atlas.bstein.dev/spillover=true:PreferNoSchedule` or `longhorn=true:PreferNoSchedule` unless the repo’s desired-state policy says to.
|
||||
|
||||
Tests:
|
||||
|
||||
- Missing cryptsetup with valid sudo installs package, loads module if needed, uncordons node.
|
||||
- Missing cryptsetup with missing sudo secret reports privilege blocker and leaves cordon with reason.
|
||||
- Node already has package but missing kernel module: module load path tested.
|
||||
- Node has Longhorn ready disks but is Kubernetes cordoned: Longhorn schedulability blocker is reported.
|
||||
- Uncordon preserves unrelated soft taints.
|
||||
- Re-running recovery is idempotent.
|
||||
|
||||
### 5. Stale RWO PVC Owner Recovery
|
||||
|
||||
Implement a generic stale-owner recovery flow for controller-owned pods and RWO PVCs.
|
||||
|
||||
Detection:
|
||||
|
||||
- Old pod has `metadata.deletionTimestamp` older than threshold.
|
||||
- Replacement pod from same controller exists and is pending/initializing.
|
||||
- Replacement events include `Multi-Attach`, `FailedAttachVolume`, `Volume is already used by pod(s)`, or `Volume is already exclusively attached to one node`.
|
||||
- PVC is `ReadWriteOnce` or equivalent single-writer mode.
|
||||
- Old and new pods are on different nodes.
|
||||
- Longhorn Volume/VolumeAttachment shows old node still owns attachment or new attach blocked.
|
||||
- Ordinary pod deletes have been attempted repeatedly without changing ownership.
|
||||
|
||||
Recovery grouping:
|
||||
|
||||
- Group old pod, replacement pod, controller, PVC, PV, VolumeAttachment(s), Longhorn Volume, old node, new node, and relevant events into one incident.
|
||||
- Emit one concise operator record.
|
||||
- Stop repeated blind pod deletes while the incident is active.
|
||||
|
||||
Safety decisions:
|
||||
|
||||
- Inspect old pod container statuses and volume mounts.
|
||||
- Classify containers:
|
||||
- PVC-writing application containers: mount the blocked PVC.
|
||||
- Sidecars that do not mount the blocked PVC: Vault agent, projected service account, emptyDir-only, etc.
|
||||
- Unknown containers: treat as unsafe.
|
||||
- If only sidecars remain and no container mounts the PVC, force-delete the stale pod after a bounded wait.
|
||||
- If an application container still runs and mounts the PVC, do not blindly force-delete. Instead:
|
||||
- Wait for kubelet termination if it is making progress.
|
||||
- If stalled and host runtime inspection is available through Vault-backed sudo, inspect and optionally stop the specific container through a controlled allowlisted path.
|
||||
- If Ananke cannot prove the PVC-writing container is stopped, report `unsafe-stale-owner` and do not clear the API object.
|
||||
- If old node kubelet/runtime is unhealthy, route through managed node recovery before stale-owner cleanup.
|
||||
|
||||
Tests:
|
||||
|
||||
- Sidecar-only stale pod force-clears and replacement attaches.
|
||||
- Live app container with blocked PVC waits and does not force-delete.
|
||||
- Live app container eventually exits; replacement attaches without force-delete.
|
||||
- Host runtime inspection unavailable due sudo privilege reports unsafe blocker.
|
||||
- Host runtime inspection available and controlled container stop succeeds.
|
||||
- Longhorn detach/attach follows cleanup.
|
||||
- Replacement readiness is rechecked after attach.
|
||||
- Multiple PVCs in one pod are handled.
|
||||
- Non-RWO PVC is not processed by this recovery path.
|
||||
|
||||
### 6. Vault Health Robustness
|
||||
|
||||
Startup should not fail because one Vault probe path is flaky if other authoritative health signals prove Vault is usable.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Prefer multiple health signals:
|
||||
- pod Running/Ready
|
||||
- service endpoints populated
|
||||
- HTTP `/v1/sys/health` through service or pod proxy
|
||||
- `vault status` through exec as one signal, not the only signal
|
||||
- If `kubectl exec vault status` is killed/transiently fails but HTTP health reports initialized/unsealed/active or standby-acceptable per config, classify as transient exec failure and continue with warning.
|
||||
- If HTTP health and exec disagree, report clearly and retry with bounded backoff.
|
||||
- Never log tokens.
|
||||
|
||||
Tests:
|
||||
|
||||
- exec killed but HTTP health unsealed => startup continues with warning.
|
||||
- exec says sealed and HTTP says sealed => startup blocks/unseals.
|
||||
- endpoints missing => startup waits.
|
||||
- HTTP unavailable but exec succeeds => startup may proceed if policy allows.
|
||||
|
||||
### 7. Image Pull And DNS Failure Classification
|
||||
|
||||
Ananke should not repeatedly recycle pods whose only issue is unchanged image pull or DNS failure.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Detect ImagePullBackOff/ErrImagePull events caused by DNS/registry lookup.
|
||||
- Run separate DNS checks:
|
||||
- CoreDNS pods ready
|
||||
- in-cluster DNS query from a known diagnostic pod if available
|
||||
- node host DNS or kubelet image pull DNS failures grouped by node
|
||||
- registry reachability by hostname class, not one app
|
||||
- If the image pull failure is unchanged and recent, do not repeatedly delete the pod.
|
||||
- Emit a compact blocker: image, registry host, node, last error, and whether DNS is cluster-wide or node-specific.
|
||||
- Retry only after DNS/registry health changes or after bounded interval.
|
||||
|
||||
Tests:
|
||||
|
||||
- Docker Hub auth DNS failure is classified.
|
||||
- Private registry DNS failure is classified.
|
||||
- One-off pull failure that later succeeds is not over-escalated.
|
||||
- Repeated pod recycle is suppressed.
|
||||
|
||||
### 8. Startup Completion And Post-Success Monitoring
|
||||
|
||||
Ananke declared `startup_status=success` while late runtime/storage issues still surfaced. Make completion smarter.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Startup can reach "cluster core recovered" while still monitoring app convergence.
|
||||
- Do not mark total recovery complete until:
|
||||
- critical services pass
|
||||
- all managed nodes are either healthy, intentionally excluded, or explicitly blocked
|
||||
- Longhorn node readiness/schedulability matches desired policy
|
||||
- no active node-runtime incidents exist
|
||||
- no unsafe stale-owner incidents exist
|
||||
- Add a post-success watch window for late events: sandbox reservation, FailedKillPod, kubelet metrics scrape failures, Longhorn manager missing, VolumeAttachment errors.
|
||||
- Distinguish statuses:
|
||||
- `core_recovered`
|
||||
- `converging`
|
||||
- `success`
|
||||
- `success_with_noncritical_blockers`
|
||||
- `blocked_requires_operator`
|
||||
- Report app-level blockers separately from cluster-level blockers.
|
||||
|
||||
Tests:
|
||||
|
||||
- Critical services pass but node runtime wedge appears during post-success window => not final success until resolved.
|
||||
- Noncritical app image pull blocker can be `success_with_noncritical_blockers`.
|
||||
- Unsafe stale RWO owner blocks full success.
|
||||
- All clear => success.
|
||||
|
||||
## Status And Operator Reporting
|
||||
|
||||
Improve operator output. The user should not need to read journals to know what is happening.
|
||||
|
||||
Status should include:
|
||||
|
||||
- Whether a startup/recovery workflow is already running.
|
||||
- Current phase and elapsed time.
|
||||
- Current incidents grouped by type:
|
||||
- node runtime recovery
|
||||
- Longhorn/Kubernetes drift
|
||||
- stale RWO owner
|
||||
- Vault health
|
||||
- image/DNS pull
|
||||
- host privilege/preflight
|
||||
- For each incident: resource, trigger signals, action taken, next action, elapsed time, and whether operator input is needed.
|
||||
- Counts should be accurate and stable.
|
||||
- Repeated noisy details should be summarized with "same blocker still present for X minutes" instead of repeated lines.
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- Do not log sudo passwords, Vault tokens, unsealed keys, or rendered secret contents.
|
||||
- Redact secrets in command arguments and environment.
|
||||
- Use command allowlists for privileged host actions.
|
||||
- Make destructive operations explicit and guarded:
|
||||
- reboot only after escalation criteria
|
||||
- force-delete pod only after stale-owner safety checks
|
||||
- deleting VolumeAttachments only if repo policy allows and after safeguards
|
||||
- Keep audit logs of actions: node/resource, reason, command class, result, elapsed time, but not secrets.
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
Before editing:
|
||||
|
||||
- Read Ananke’s existing startup/recovery code, Kubernetes client abstractions, Vault access layer, SSH/host command layer, Longhorn helpers if any, status/progress writer, and tests.
|
||||
- Reuse existing abstractions. Do not invent a parallel framework unless necessary.
|
||||
- Use fake clients and table-driven tests where the repo already uses them.
|
||||
- Keep changes modular:
|
||||
- host privilege provider
|
||||
- node runtime incident detector/reconciler
|
||||
- Longhorn readiness reconciler
|
||||
- stale RWO owner reconciler
|
||||
- image/DNS classifier
|
||||
- status/progress summarizer
|
||||
- Ensure all reconcilers are idempotent and bounded.
|
||||
|
||||
Suggested model:
|
||||
|
||||
- Define incident structs with stable keys, timestamps, observed signals, action history, next action, and terminal status.
|
||||
- Every recovery action should require:
|
||||
- detection evidence
|
||||
- policy permission
|
||||
- safety gate
|
||||
- timeout
|
||||
- postcondition check
|
||||
- Avoid "delete pod until healthy" loops. Replace with evidence-driven remediation.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
Run the repository’s standard checks first, then add targeted tests.
|
||||
|
||||
Required validation:
|
||||
|
||||
- Unit tests for each new classifier/reconciler.
|
||||
- Tests for redaction/no secret leakage.
|
||||
- Tests for idempotency on repeated reconcile calls.
|
||||
- Tests for progress/status output.
|
||||
- Integration-style tests with fake Kubernetes/Longhorn objects:
|
||||
- nodes, pods, events, PVCs, PVs, VolumeAttachments, Longhorn Node CRs, Longhorn Volume CRs, DaemonSets, and pods.
|
||||
- Host command fake tests:
|
||||
- SSH unavailable
|
||||
- sudo unavailable
|
||||
- sudo succeeds
|
||||
- command timeout
|
||||
- k3s-agent service states
|
||||
- reboot accepted/not accepted
|
||||
- Vault fake tests:
|
||||
- secret found
|
||||
- secret missing
|
||||
- Vault sealed/unavailable
|
||||
- HTTP health fallback
|
||||
- End-to-end scenario tests composed from the incident:
|
||||
- `titan-05` hard runtime wedge -> reboot -> Ready
|
||||
- `titan-04` post-success sandbox wedge -> restart fails -> reboot -> Ready
|
||||
- `titan-15`/`titan-17` cryptsetup/cordon flow -> sudo repair or explicit blocker -> uncordon when safe
|
||||
- `titan-22` missing `longhorn-host` label -> restore label -> manager pod appears -> Longhorn Ready
|
||||
- Firefly sidecar-only stale owner -> force delete -> attach replacement
|
||||
- Firefly live app container stale owner -> wait/report unsafe, no force delete
|
||||
- Vault exec killed but HTTP healthy -> continue
|
||||
- DNS image pull errors -> classify without repeated recycle
|
||||
|
||||
Run commands appropriate for the repo, likely including:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
If the repo uses other tooling, discover and run it. If any expensive/integration tests require cluster credentials, document how to run them and ensure fake-client tests cover the logic by default.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The work is complete only when:
|
||||
|
||||
- Ananke can retrieve and use Vault-backed sudo credentials securely or clearly reports why it cannot.
|
||||
- Node runtime wedges are detected and recovered by a bounded escalation ladder.
|
||||
- Ananke can handle Ready nodes that cannot create sandboxes.
|
||||
- Ananke can recover from k3s-agent stop-sigkill/timeout by controlled reboot when configured.
|
||||
- Ananke does not leave Longhorn nodes cordoned indefinitely after repair/override conditions are satisfied.
|
||||
- Ananke detects and fixes or reports Longhorn/Kubernetes drift, including missing manager-pod labels.
|
||||
- Ananke verifies Longhorn node readiness before trusting Kubernetes node readiness for PVC workloads.
|
||||
- Ananke handles stale RWO ownership safely, distinguishing sidecar-only stale pods from live PVC-writing app containers.
|
||||
- Ananke avoids noisy repeated deletion of unchanged image-pull failures.
|
||||
- Vault health checks tolerate transient exec failures when HTTP health is authoritative.
|
||||
- Startup success reflects real cluster recovery, with explicit `success_with_noncritical_blockers` or `blocked_requires_operator` states where appropriate.
|
||||
- All changes are covered by rigorous tests and pass the repo’s standard test suite.
|
||||
|
||||
## What Not To Do
|
||||
|
||||
- Do not hard-code Titan node names, app names, PVC names, or namespaces.
|
||||
- Do not log secrets.
|
||||
- Do not delete PVs, PVCs, or Longhorn Volume data.
|
||||
- Do not force-delete pods with live app containers mounting RWO PVCs unless a controlled, audited stop path has proven storage safety.
|
||||
- Do not repeatedly restart/reboot nodes without incident state and cooldowns.
|
||||
- Do not treat Kubernetes Ready as equivalent to Longhorn Ready.
|
||||
- Do not declare full success while node-runtime or unsafe stale-owner incidents are active.
|
||||
|
||||
## Useful Terminology From The Incident
|
||||
|
||||
- "Node runtime wedge": Kubernetes or host state where kubelet/containerd cannot create/kill sandboxes even if the node appears partially Ready.
|
||||
- "Stale RWO owner": a terminating pod or runtime survivor that still holds an exclusive PVC and blocks replacement attach.
|
||||
- "Sidecar-only stale owner": old pod still exists, but only non-PVC sidecars remain; force deletion may be safe after checks.
|
||||
- "Unsafe stale owner": app container still running and mounting the PVC; wait or controlled stop required.
|
||||
- "Longhorn/Kubernetes drift": Kubernetes node readiness/labels and Longhorn node readiness/manager state disagree.
|
||||
- "Host privilege unavailable": Ananke cannot run necessary sudo actions despite needing host-level repair.
|
||||
|
||||
Use this prompt to implement production-grade, categorical recovery logic in Ananke, not one-off incident handling.
|
||||
207
docs/ananke-recovery-2026-07-07.md
Normal file
207
docs/ananke-recovery-2026-07-07.md
Normal file
@ -0,0 +1,207 @@
|
||||
# Ananke Recovery Notes - 2026-07-07
|
||||
|
||||
## Context
|
||||
|
||||
- Titan cluster lost power briefly; Ariadne may have started shutdown behavior.
|
||||
- User goal: let one Ananke recovery process bring the cluster back to health, monitor it, and record any manual help or automation gaps.
|
||||
- Operator view is from `/home/brad/Development/bstein_dev_home`; Kubernetes API is reachable through `/home/brad/titan/kubeconfig`.
|
||||
|
||||
## Current Ananke Run
|
||||
|
||||
- Coordinator host: `titan-db`.
|
||||
- `ananke.service` daemon is active.
|
||||
- `ananke-bootstrap.service` is active and running:
|
||||
- command: `/usr/local/bin/ananke startup --config /etc/ananke/ananke.yaml --execute --force-flux-branch main --auto-peer-failover --peer-wait-seconds 180`
|
||||
- current run started: `2026-07-07T17:19:50Z`
|
||||
- current phase observed: `critical-workloads`
|
||||
- checklist observed: `10 passed / 0 failed / 2 running`
|
||||
- auto-heals observed: pod recycling for `VaultInitStuck`, `ContainerRuntimeWedge`, `CrashLoopBackOff`, stale terminating pods, and Longhorn attach blocked on unready nodes.
|
||||
- previous run failed at `2026-07-07T17:19:19Z` waiting on `logging/deployment/oauth2-proxy-logs`; systemd restarted the bootstrap service.
|
||||
|
||||
## Observed Recovery Progress
|
||||
|
||||
- Latest snapshot at approximately `2026-07-07T17:58Z`:
|
||||
- Ananke is still running in `convergence-checks` with `17/19` checks passed, `0` failed, and `2` running.
|
||||
- Ananke has recorded `31` auto-heal actions, mostly repeated stuck-pod recycling.
|
||||
- `titan-22` and `titan-24` both recovered to `Ready=True` with fresh kubelet transitions (`titan-22` at `2026-07-07T17:56:29Z`, `titan-24` at `2026-07-07T17:55:11Z`).
|
||||
- Metrics server is running and `kubectl top nodes` works for recovered nodes; `titan-22` had just rejoined and metrics may lag briefly.
|
||||
- Remaining convergence blocker is mostly app workload readiness, led by `finance/firefly`.
|
||||
- Ananke completed the startup workflow successfully at `2026-07-07T17:59:00Z`:
|
||||
- `startup_status=success`
|
||||
- `startup_phase=complete`
|
||||
- `startup_checklist_total=20 passed=19 failed=0 running=1`
|
||||
- `startup_auto_heals=33`
|
||||
- `intent=normal`
|
||||
- Vault recovered:
|
||||
- `vault/vault-0` is `1/1 Running` on `titan-18`.
|
||||
- Vault status reports `initialized=true`, `sealed=false`, `ha_enabled=true`.
|
||||
- Vault became active at `2026-07-07T17:05:46Z`.
|
||||
- `vault-k8s-auth-config-autoheal-*` completed.
|
||||
- Vault injector recovered after `titan-04` kubelet recovery:
|
||||
- `vault-injector-agent-injector-*` became `1/1 Running`.
|
||||
- Vault service endpoints and injector webhook endpoints are populated.
|
||||
- Vault-backed workloads started moving again:
|
||||
- Ariadne's Vault init authenticated successfully and rendered `/vault/secrets/ariadne-env.sh`.
|
||||
- Ariadne is now `2/2 Running` on `titan-08`.
|
||||
- Flux is mostly reconciled, but several app kustomizations are still waiting on app readiness or dependencies.
|
||||
- Longhorn no longer shows the earlier broad faulted volume picture; observed state later was `41 attached`, `1 attaching`, `47 detached`.
|
||||
|
||||
## Current Known Issues
|
||||
|
||||
- `titan-22` and `titan-24` are still `NotReady` / `Unknown` from Kubernetes.
|
||||
- `titan-05` is `Unknown` after power-event recovery and k3s-agent restart attempts:
|
||||
- Ananke SSH route on port `2277` reaches the host.
|
||||
- `systemctl restart k3s-agent` timed out while stopping the old process.
|
||||
- systemd reported `Processes still around after SIGKILL`, then spawned a new `/usr/local/bin/k3s agent`.
|
||||
- `k3s-agent.service` remained `activating/start`; local `127.0.0.1:6444` was open but kubelet `127.0.0.1:10250` was closed.
|
||||
- Kubernetes node heartbeat stayed stale at `2026-07-07T17:21:57Z` with `node.kubernetes.io/unreachable` taints.
|
||||
- `maintenance/k3s-agent-restart-*` on `titan-05` stayed `ContainerCreating`, which confirms the in-cluster helper cannot repair this class once the node runtime is wedged.
|
||||
- `titan-15` and `titan-17` are `Ready,SchedulingDisabled`; Ananke cordoned them because encrypted Longhorn mounts exposed missing `cryptsetup`.
|
||||
- Ananke tried to install `cryptsetup-bin` on `titan-15` and `titan-17`, but the SSH repair failed because `sudo` required a password for `atlas`.
|
||||
- Several workloads are still unready while Ananke recycles stale `VaultInitStuck`, `CrashLoopBackOff`, and `ImagePullBackOff` pods.
|
||||
|
||||
## Manual Actions Taken
|
||||
|
||||
- Verified cluster health with `kubectl` and Flux commands.
|
||||
- Verified Vault directly with `vault status` inside `vault-0`.
|
||||
- Verified Ananke coordinator service state through `titan-db`.
|
||||
- Did not start a second Ananke recovery process after discovering the active `ananke-bootstrap.service`.
|
||||
- Used the existing `maintenance/k3s-agent-restart` DaemonSet as a targeted host-level nudge by deleting its pods on `titan-04`, `titan-05`, `titan-06`, `titan-08`, and `titan-18`; this reruns the DaemonSet's `k3s-agent` restart command on those nodes.
|
||||
- The in-cluster helper successfully reran on `titan-08`, but helper startup/termination was itself wedged on other affected nodes. Queued direct `systemctl --no-block restart k3s-agent` over Ananke SSH for `titan-05`, `titan-06`, and `titan-18`. A direct restart attempt against `titan-04` hung and was stopped.
|
||||
- After discovering Ananke's SSH profile uses `atlas@node:2277`, queued `systemctl --no-block restart k3s-agent` on `titan-04` and `titan-05` through the same route.
|
||||
- `titan-04` recovered to `Ready` after the non-blocking restart and its `maintenance/k3s-agent-restart-*` pod became `Running`.
|
||||
- `titan-05` did not recover: systemd timed out killing the old `k3s-agent`, left containerd shims behind, and started a new agent process without reopening kubelet port `10250` or restoring node heartbeats.
|
||||
- A controlled reboot was issued for `titan-05` through Ananke's SSH route at approximately `2026-07-07T17:32Z` because the node met the proposed escalation criteria: stale Kubernetes heartbeat, kubelet `10250` closed, `k3s-agent.service` stuck in `activating/start`, and the in-cluster helper stuck `ContainerCreating`.
|
||||
- After the reboot command returned, `titan-05` temporarily stopped responding to ping and SSH, then recovered to Kubernetes `Ready=True` with fresh heartbeats and no `unreachable` taints by approximately `2026-07-07T17:35Z`.
|
||||
- Ananke later detected and logged `cordoned container-runtime-wedged node(s): titan-07 pods=4`; direct host checks showed `titan-07` reachable, `k3s-agent` active/running, and kubelet `10250` open. This should become a false-positive/auto-uncordon test case if Kubernetes readiness remains healthy.
|
||||
- Later `titan-07` showed a real runtime/API-state split: `oauth2-proxy-logs` reported container state `running`, but `kubectl exec` failed with `cannot exec in a deleted state` and `kubectl logs --previous` could not retrieve the container logs. Manual assist: queued `systemctl --no-block restart k3s-agent` on `titan-07` and deleted the stale `logging/oauth2-proxy-logs-*` pod.
|
||||
- `titan-07` recovered to `Ready=True` with `k3s-agent` active and kubelet `10250` open. `logging/oauth2-proxy-logs` was recreated on `titan-12` and became `2/2 Running`.
|
||||
- Ananke repeatedly failed and restarted on `logging/deployment/oauth2-proxy-logs` because the replacement pod on `titan-14` hit the same `failed to reserve container name` / `CreateContainerError` runtime wedge signature.
|
||||
- Manual assist: queued `systemctl --no-block restart k3s-agent` on `titan-14` through Ananke SSH and deleted the stuck `logging/oauth2-proxy-logs-5b4d4f87db-2tdzd` pod with `--wait=false` so the controller can recreate it after the runtime reset.
|
||||
- Follow-up on `titan-14`: the direct SSH command timed out from the operator view, but a later status check showed `k3s-agent.service` had a new MainPID and was `activating/start`; the existing `maintenance/k3s-agent-restart` DaemonSet pod for `titan-14` was also deleted to rerun the in-cluster helper.
|
||||
- `titan-14` recovered to fresh `Ready=True` heartbeats and the replacement `maintenance/k3s-agent-restart-*` pod became `Running`.
|
||||
- After the `titan-05` reboot, `logging/opensearch-0` recovered to `1/1 Running`; `logging/oauth2-proxy-logs` was recreated on `titan-07` and was still initializing at the time of note.
|
||||
- Ananke also entered a short retry loop where `kubectl exec vault-0 ... vault status` was killed even though Vault HTTP health through the Kubernetes pod proxy reported `initialized=true`, `sealed=false`, `standby=false`. This should become a separate robustness fix: startup should not fail outright on a transient killed `kubectl exec` when service endpoints/HTTP health prove Vault is active.
|
||||
- After Keycloak completed its slow post-attach startup at approximately `2026-07-07T17:45:55Z`, OIDC-dependent OAuth proxies began recovering.
|
||||
- Ananke advanced past `critical-workloads` into `convergence-checks` by approximately `2026-07-07T17:50:50Z`.
|
||||
- Manual assist: force-deleted stale pod `finance/firefly-6669f46874-q8rtq` after Ananke repeatedly issued ordinary deletes but the pod remained `Terminating` with no finalizers and continued to block `firefly-storage` attachment. The Firefly Longhorn volume moved from `titan-04` to `titan-06` and reported `attached`/`healthy` on the replacement node.
|
||||
- Post-Ananke-completion manual assist: `titan-04` was still `Ready` with kubelet `10250` open, but several newly scheduled pods showed `FailedCreatePodSandBox`, `failed to reserve sandbox name`, and `context deadline exceeded`. Queued `systemctl --no-block restart k3s-agent` on `titan-04` at approximately `2026-07-07T18:02:55Z`.
|
||||
- `titan-04` did not recover from the non-blocking `k3s-agent` restart. It moved to Kubernetes `Ready=Unknown` with `node.kubernetes.io/unreachable` taints; host checks showed `k3s-agent.service` stuck in `deactivating/stop-sigkill`, `Result=timeout`, and kubelet `10250` closed. Manual assist: issued a controlled reboot for `titan-04` through Ananke's SSH route shortly after `2026-07-07T18:06Z`.
|
||||
- `titan-04` recovered after reboot by approximately `2026-07-07T18:10Z`: Kubernetes reported `Ready=True`, taints cleared, `k3s-agent` was `active/running`, and kubelet `10250` was open.
|
||||
- Firefly hit a second stale-owner handoff after the first cleanup. Replacement pod `firefly-6669f46874-pjhg6` on `titan-07` was blocked while terminating pod `firefly-6669f46874-lrz5d` on `titan-06` still had the main `firefly` container running and mounted `firefly-storage`. No force delete was issued for this second handoff because the application container was live and PVC-writing. The old pod later disappeared on its own; Longhorn then attached `pvc-358b4319-60cb-4322-b948-776c34a414a9` to `titan-07` as `attached`/`healthy`.
|
||||
- Attempted to inspect container runtime state on `titan-06` with `crictl`, but `atlas` required a sudo password. This should be part of the automation fix: Ananke either needs a noninteractive, bounded runtime-inspection primitive or must report that it cannot prove a PVC-writing container is stopped.
|
||||
- Manual assist: uncordoned `titan-15` and `titan-17` after confirming Kubernetes `Ready=True`, Longhorn `allowScheduling=true`, and Longhorn disks ready. The remaining blocker for Longhorn on both nodes was Kubernetes `SchedulingDisabled`; after uncordon, Longhorn `Schedulable=True` on both nodes.
|
||||
- Manual assist: resolved the `titan-22` Kubernetes/Longhorn readiness discrepancy. Kubernetes reported `Ready=True`, but Longhorn node `Ready=False` with `ManagerPodMissing`; the Longhorn manager DaemonSet selected `longhorn-host=true`, and `titan-22` was missing that label. Restored `longhorn-host=true`, which started `longhorn-manager-jtsjw` on `titan-22` and brought Longhorn node `Ready=True`.
|
||||
- Manual assist: deleted pending controller-owned pod `crypto/wallet-monero-temp-844bd949c7-28r4p` to force a fresh volume-attach retry after the `titan-22` Longhorn manager came up.
|
||||
- Manual assist: deleted stale unattached VolumeAttachment `csi-0bac99e18b687f6a50647dd959c26d6e2a1b2bf1a4f844de4f646cf3408d41c5` after it retained the pre-fix "node titan-22 is not ready" Longhorn error. Longhorn then attached `pvc-c6a4bf9b-17c7-4bdf-8a88-ca460ad0da5b` to `titan-22` as `attached`/`healthy`.
|
||||
|
||||
## Automation Growth Opportunities
|
||||
|
||||
- Ananke should expose from a remote operator machine whether the coordinator bootstrap is already running, so an operator does not risk starting a duplicate recovery.
|
||||
- The local Ananke config path assumes `/etc/ananke/ananke.yaml` and `/var/lib/ananke`; operator-side `status` failed without root. A read-only remote status wrapper would make this smoother.
|
||||
- Ananke should handle the `cryptsetup-bin` host repair on managed nodes without requiring interactive sudo, or clearly preflight and report the missing node privilege before a power event.
|
||||
- Ananke should separate `Vault pod/data volume unavailable` from `Vault sealed` in status. It did recover correctly, but early status said the unseal check passed while deferring because `vault-0` was pending.
|
||||
- Ananke should avoid repeatedly recycling the same ImagePullBackOff pods if the underlying registry/image problem is unchanged; this creates noisy recovery logs.
|
||||
- Ananke should surface current blocking workloads in a compact operator summary rather than requiring journal inspection.
|
||||
- Ananke should detect pods stuck in `PodInitializing` after init completion, especially when events show `FailedCreatePodSandbox`, `failed to reserve sandbox name`, `FailedKillPod`, or image pulls that never transition to `ImagePullBackOff`.
|
||||
- Ananke should be able to rerun the existing `k3s-agent-restart` maintenance primitive on affected nodes when containerd/kubelet sandbox state is wedged after a power event.
|
||||
- Ananke should fall back from the in-cluster restart helper to SSH `systemctl --no-block restart k3s-agent` when the helper pod cannot start because kubelet/containerd is already wedged. Blocking `systemctl restart` over SSH can hang the operator path.
|
||||
- Ananke should check host and CoreDNS upstream DNS health separately from node SSH reachability. During this incident, node IP reachability passed while hostname resolution and image pulls were intermittently failing.
|
||||
- Ananke should stop repeatedly issuing ordinary pod deletes when the same controller pod remains `Terminating` and continues to own exclusive resources. It should escalate to a stale-owner cleanup flow that proves the old workload is not writing, force-removes the orphaned API object when safe, then verifies Longhorn detach/attach and replacement pod startup.
|
||||
- Ananke should reconcile Kubernetes node labels against Longhorn manager scheduling requirements. During this incident `titan-22` was Kubernetes `Ready=True`, had CSI/engine Longhorn pods, and had a Longhorn Node CR, but no `longhorn-manager` pod because `longhorn-host=true` was missing. Kubernetes Ready alone was not sufficient for PVC workloads.
|
||||
- Ananke should understand that Kubernetes cordon directly affects Longhorn node schedulability. `titan-15` and `titan-17` had Longhorn-ready disks and `allowScheduling=true`, but Longhorn `Schedulable=False` solely because the Kubernetes nodes were cordoned.
|
||||
|
||||
### Generalized Node Runtime Wedge Case
|
||||
|
||||
This incident exposed a reusable failure mode that Ananke should treat as a node-runtime recovery problem, not just an app-pod recovery problem.
|
||||
|
||||
Detection signals:
|
||||
|
||||
- Node is `Ready=Unknown` or has a stale heartbeat while SSH on the managed Ananke route still works.
|
||||
- Host is pingable and local k3s load balancer port `127.0.0.1:6444` may be open, but kubelet port `127.0.0.1:10250` is closed or API proxy calls fail.
|
||||
- `systemctl restart k3s-agent` hangs, or `systemctl show k3s-agent` reports `deactivating`, `final-sigterm`, `final-sigkill`, `activating/start`, or `Result=timeout` for longer than a bounded recovery window.
|
||||
- `journalctl -u k3s-agent` includes `Processes still around after SIGKILL`, `State 'stop-sigterm' timed out`, or `State 'final-sigterm' timed out`.
|
||||
- Pods on the node show `FailedCreatePodSandBox`, `failed to reserve sandbox name`, `CreateContainerError`, `FailedKillPod`, or long-lived `ContainerCreating`/`PodInitializing`.
|
||||
- The `maintenance/k3s-agent-restart` DaemonSet pod for that node cannot start or stays `ContainerCreating`, which means Kubernetes-based repair is unavailable.
|
||||
|
||||
Suggested automated escalation ladder:
|
||||
|
||||
- Mark the node as in node-runtime recovery and pause noisy repeated pod recycling for pods pinned to that node.
|
||||
- Cordon the node or confirm it already has `unreachable` taints before host-level repair.
|
||||
- Try a bounded SSH health probe using Ananke's configured SSH user, port, config, and identity.
|
||||
- Prefer `systemctl --no-block restart k3s-agent` over blocking `systemctl restart k3s-agent`.
|
||||
- After a short wait, require both Kubernetes heartbeat freshness and local kubelet port `10250` to recover.
|
||||
- If the service remains `deactivating`, `final-sigkill`, or `activating/start` with stale heartbeats, escalate to a configured host reboot path.
|
||||
- After reboot, wait for SSH, k3s-agent active/running, kubelet `10250`, Kubernetes `Ready=True`, and removal of `unreachable` taints before resuming app-pod recycling for that node.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- This should be a generic managed-node repair primitive, not hard-coded to `titan-05`.
|
||||
- The primitive should emit one compact operator summary: node, phase, trigger signals, action taken, elapsed time, and next escalation.
|
||||
- Tests should cover a node with SSH available but kubelet closed, a blocking restart timeout, leftover shim evidence, a helper DaemonSet stuck in `ContainerCreating`, and successful recovery through reboot.
|
||||
- The post-completion `titan-04` recurrence confirms the primitive must continue monitoring after Ananke declares cluster startup success. A Ready node with fresh-enough status can still be unable to create new sandboxes, and a later restart can transition it into the same hard `deactivating/stop-sigkill` state seen on `titan-05`.
|
||||
|
||||
### Generalized Stale Pod / Volume Ownership Case
|
||||
|
||||
This incident also exposed a reusable app-convergence failure mode that Ananke should treat as stale Kubernetes/runtime ownership rather than simple pod unhealthiness.
|
||||
|
||||
Concrete example:
|
||||
|
||||
- `finance/firefly-6669f46874-q8rtq` entered deletion at `2026-07-07T17:43:55Z` and remained `Terminating` for more than 25 minutes.
|
||||
- The pod had no finalizers but still reported a running `vault-agent` sidecar on `titan-04`.
|
||||
- The replacement pod `finance/firefly-6669f46874-lrz5d` on `titan-06` stayed `Init:0/2`.
|
||||
- Events showed `FailedAttachVolume` / `Multi-Attach` for PVC `firefly-storage` because volume `pvc-358b4319-60cb-4322-b948-776c34a414a9` was still attached to `titan-04`.
|
||||
- Longhorn reported the volume `attached`, `healthy`, current node `titan-04`.
|
||||
- Ananke repeatedly logged ordinary recycling of the same stuck pod, but did not escalate to clearing the stale owner.
|
||||
- Manual force deletion of the stale pod cleared the stale API owner and allowed Longhorn to attach `firefly-storage` to the replacement pod's node.
|
||||
|
||||
Detection signals:
|
||||
|
||||
- A controller-owned pod has `metadata.deletionTimestamp` older than a bounded threshold and no finalizers.
|
||||
- A replacement pod from the same controller is pending or initializing on another node.
|
||||
- Events include `Multi-Attach`, `FailedAttachVolume`, `Volume is already used by pod(s)`, or `Volume is already exclusively attached to one node`.
|
||||
- The PVC is `ReadWriteOnce` and the old and new pod nodes differ.
|
||||
- Longhorn/CNI/runtime state still shows the old node as the attachment owner.
|
||||
- Ordinary `kubectl delete pod --wait=false` is repeated without changing the deletion timestamp or clearing the volume attachment.
|
||||
|
||||
Suggested automated escalation ladder:
|
||||
|
||||
- Group the old pod, replacement pod, PVC, VolumeAttachment, Longhorn volume, controller, and node into one recovery incident.
|
||||
- Verify whether any application container that mounts the PVC is still running. Sidecars that do not mount the PVC should not block stale-owner cleanup by themselves.
|
||||
- Check node health for the old owner node. If kubelet/runtime is unhealthy, route through the managed-node recovery primitive first.
|
||||
- If the old owner node is healthy but the pod remains deleting past the threshold, issue a bounded force deletion for the stale pod.
|
||||
- Wait for VolumeAttachment/Longhorn ownership to detach from the old node or attach to the replacement node.
|
||||
- Recheck the replacement pod init sequence and controller readiness before declaring the app recovered.
|
||||
- Emit one compact operator event instead of repeatedly logging the same pod recycle.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- This should be generic across any `ReadWriteOnce` PVC workload, not hard-coded to Firefly.
|
||||
- The safety check should reason at container/mount level: a sidecar that only mounts service-account, Vault secret, or emptyDir volumes is materially different from an app container actively mounting the PVC.
|
||||
- Tests should cover a terminating pod without finalizers holding an RWO PVC, an old node with healthy kubelet, an old node with wedged kubelet, a sidecar-only survivor, and a replacement pod blocked by `Multi-Attach`.
|
||||
- Tests should also cover the unsafe variant where the terminating pod's application container is still running and mounts the PVC. In that case Ananke should wait, stop the container through a controlled runtime path if allowed, or report that it lacks the privilege to prove storage safety; it should not blindly force-delete the API object.
|
||||
|
||||
### Generalized Longhorn / Kubernetes Readiness Drift Case
|
||||
|
||||
This incident exposed a reusable storage readiness gap: Kubernetes `Ready=True` is not enough for PVC workloads when Longhorn's node model disagrees.
|
||||
|
||||
Detection signals:
|
||||
|
||||
- Kubernetes node is `Ready=True`, untainted, and schedulable, but Longhorn Node `Ready=False`.
|
||||
- Longhorn Node reason is `ManagerPodMissing`, or the Longhorn manager DaemonSet has no pod on the node.
|
||||
- Longhorn manager DaemonSet selects a node label such as `longhorn-host=true`, and the Kubernetes node is missing it.
|
||||
- Longhorn CSI/engine pods or a Longhorn Node CR exist for the node, proving the node participates in Longhorn even though manager scheduling is broken.
|
||||
- VolumeAttachment errors say Longhorn cannot attach because the node is not ready, despite Kubernetes Ready=True.
|
||||
|
||||
Suggested automated escalation ladder:
|
||||
|
||||
- Compare Kubernetes node readiness, labels, taints, Longhorn Node readiness/schedulability, Longhorn manager DaemonSet selector, manager pod presence, CSI pod presence, and VolumeAttachment errors.
|
||||
- If desired-state evidence proves the node should be a Longhorn node, restore the missing manager selector label and wait for the manager pod and Longhorn Node `Ready=True`.
|
||||
- If desired-state evidence is missing, report a clear unsafe label-drift blocker rather than guessing.
|
||||
- After Longhorn Node `Ready=True`, retry stale VolumeAttachments by recreating the affected controller-owned pending pod or using a safe VolumeAttachment retry policy.
|
||||
- For Kubernetes-cordoned Longhorn nodes with `allowScheduling=true` and ready disks, either complete the host repair and uncordon or report the explicit blocker; do not leave Longhorn capacity disabled silently.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- This must be generic across Longhorn nodes and manager DaemonSet selectors, not hard-coded to `titan-22` or `longhorn-host`.
|
||||
- Tests should cover missing Longhorn selector labels, manager pod missing, Kubernetes Ready but Longhorn NotReady, Kubernetes cordon causing Longhorn Schedulable=False, and stale attach errors that clear after Longhorn readiness is restored.
|
||||
@ -38,8 +38,8 @@ func (o *Orchestrator) reconcileNodeAccess(ctx context.Context, nodes []string)
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
if _, err := o.ssh(ctx, node, "sudo -n /usr/bin/systemctl --version"); err != nil {
|
||||
errCh <- fmt.Errorf("%s: missing sudo access to /usr/bin/systemctl (--version): %w", node, err)
|
||||
if err := o.preflightHostPrivilege(ctx, node); err != nil {
|
||||
errCh <- fmt.Errorf("%s: host privilege preflight failed: %w", node, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -49,13 +49,16 @@ func (o *Orchestrator) reconcileNodeAccess(ctx context.Context, nodes []string)
|
||||
return nil
|
||||
}
|
||||
samples := []string{}
|
||||
errCount := 0
|
||||
for err := range errCh {
|
||||
errCount++
|
||||
samples = append(samples, err.Error())
|
||||
if len(samples) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("access validation had %d errors (first: %s)", len(errCh), strings.Join(samples, " | "))
|
||||
errCount += len(errCh)
|
||||
return fmt.Errorf("access validation had %d errors (first: %s)", errCount, strings.Join(samples, " | "))
|
||||
}
|
||||
|
||||
// waitForNodeSSHAuth runs one orchestration or CLI step.
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -17,6 +18,7 @@ type nodeReadyItem struct {
|
||||
Metadata struct {
|
||||
Name string `json:"name"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
} `json:"metadata"`
|
||||
Spec struct {
|
||||
Unschedulable bool `json:"unschedulable"`
|
||||
@ -75,6 +77,14 @@ func (o *Orchestrator) postStartAutoHeal(ctx context.Context) error {
|
||||
errs = append(errs, fmt.Sprintf("required node labels: %v", err))
|
||||
}
|
||||
|
||||
longhornRepairs, err := o.reconcileLonghornKubernetesReadiness(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("longhorn/kubernetes readiness drift: %v", err))
|
||||
} else if longhornRepairs > 0 {
|
||||
requestReconcile = true
|
||||
o.log.Printf("post-start auto-heal repaired %d Longhorn/Kubernetes readiness drift item(s)", longhornRepairs)
|
||||
}
|
||||
|
||||
releasedCordons, err := o.enforceRecoveryCordonLeases(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("recovery cordon lease check: %v", err))
|
||||
@ -99,6 +109,15 @@ func (o *Orchestrator) postStartAutoHeal(ctx context.Context) error {
|
||||
requestReconcile = true
|
||||
}
|
||||
|
||||
serviceRepairs, err := o.healUnreadyConfiguredServiceBackends(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("configured service backend repair: %v", err))
|
||||
} else if len(serviceRepairs) > 0 {
|
||||
requestReconcile = true
|
||||
sort.Strings(serviceRepairs)
|
||||
o.log.Printf("post-start auto-heal repaired configured service backend(s): %s", joinLimited(serviceRepairs, 8))
|
||||
}
|
||||
|
||||
repairedProxies, err := o.repairBrokenKubeletProxies(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("kubelet proxy auto-repair: %v", err))
|
||||
@ -265,38 +284,10 @@ func (o *Orchestrator) repairBrokenKubeletProxies(ctx context.Context) (int, err
|
||||
continue
|
||||
}
|
||||
|
||||
if !node.Unschedulable {
|
||||
if err := o.cordonNodeWithLease(ctx, node.Name, cordonReasonKubeletProxy, "broken kubelet proxy before k3s-agent restart"); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s cordon before kubelet restart: %v", node.Name, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
o.log.Printf("warning: detected broken kubelet proxy on Ready node %s; restarting k3s-agent", node.Name)
|
||||
if _, err := o.sshWithTimeout(ctx, node.Name, "sudo -n systemctl restart k3s-agent", 90*time.Second); err != nil {
|
||||
if !node.Unschedulable {
|
||||
o.bestEffort("uncordon node after failed kubelet proxy repair", func() error {
|
||||
return o.uncordonAndClearCordonLease(ctx, node.Name, cordonReasonKubeletProxy)
|
||||
})
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("%s restart k3s-agent: %v", node.Name, err))
|
||||
if err := o.recoverManagedNodeRuntime(ctx, node.Name, node.Unschedulable, "broken kubelet proxy before k3s-agent restart"); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s %v", node.Name, err))
|
||||
continue
|
||||
}
|
||||
if _, err := o.kubectl(ctx, 140*time.Second, "wait", "node/"+node.Name, "--for=condition=Ready", "--timeout=120s"); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s wait Ready after k3s-agent restart: %v", node.Name, err))
|
||||
continue
|
||||
}
|
||||
healthy, checkErr = o.kubeletProxyHealthy(ctx, node.Name)
|
||||
if !healthy {
|
||||
errs = append(errs, fmt.Sprintf("%s proxy still broken after k3s-agent restart: %v", node.Name, checkErr))
|
||||
continue
|
||||
}
|
||||
if !node.Unschedulable {
|
||||
if err := o.uncordonAndClearCordonLease(ctx, node.Name, cordonReasonKubeletProxy); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s uncordon after kubelet proxy repair: %v", node.Name, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
repaired++
|
||||
}
|
||||
|
||||
|
||||
@ -41,9 +41,11 @@ func TestRepairBrokenKubeletProxiesRestartsSchedulableNode(t *testing.T) {
|
||||
case name == "kubectl" && strings.Contains(joined, "cordon titan-07"):
|
||||
cordoned = true
|
||||
return "", nil
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl restart k3s-agent"):
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl --no-block restart k3s-agent"):
|
||||
restarted = true
|
||||
return "", nil
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl is-active k3s-agent"):
|
||||
return "active", nil
|
||||
case name == "kubectl" && strings.Contains(joined, "wait node/titan-07 --for=condition=Ready --timeout=120s"):
|
||||
waited = true
|
||||
return "", nil
|
||||
@ -100,9 +102,11 @@ func TestRepairBrokenKubeletProxiesPreservesExistingCordon(t *testing.T) {
|
||||
case name == "kubectl" && (strings.Contains(joined, "cordon titan-18") || strings.Contains(joined, "uncordon titan-18")):
|
||||
cordonTouched = true
|
||||
return "", nil
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl restart k3s-agent"):
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl --no-block restart k3s-agent"):
|
||||
restarted = true
|
||||
return "", nil
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl is-active k3s-agent"):
|
||||
return "active", nil
|
||||
case name == "kubectl" && strings.Contains(joined, "wait node/titan-18 --for=condition=Ready --timeout=120s"):
|
||||
return "", nil
|
||||
default:
|
||||
@ -243,6 +247,7 @@ func TestRepairBrokenKubeletProxiesFailureBranches(t *testing.T) {
|
||||
{cmd: "restart"},
|
||||
{cmd: "wait"},
|
||||
{cmd: "health", out: "ok"},
|
||||
{cmd: "active", out: "active"},
|
||||
{cmd: "uncordon", err: errors.New("uncordon denied")},
|
||||
})
|
||||
repaired, err := orch.repairBrokenKubeletProxies(context.Background())
|
||||
@ -280,8 +285,10 @@ func kubeletProxyRepairStub(t *testing.T, cfg config.Config, unschedulable bool,
|
||||
actual = "uncordon"
|
||||
case name == "kubectl" && strings.Contains(joined, "cordon titan-07"):
|
||||
actual = "cordon"
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl restart k3s-agent"):
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl --no-block restart k3s-agent"):
|
||||
actual = "restart"
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n systemctl is-active k3s-agent"):
|
||||
actual = "active"
|
||||
case name == "kubectl" && strings.Contains(joined, "wait node/titan-07 --for=condition=Ready --timeout=120s"):
|
||||
actual = "wait"
|
||||
default:
|
||||
|
||||
64
internal/cluster/orchestrator_autorepair_service_test.go
Normal file
64
internal/cluster/orchestrator_autorepair_service_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scm.bstein.dev/bstein/ananke/internal/config"
|
||||
)
|
||||
|
||||
// TestConfiguredServiceBackendRepairContinuesPastNoopEndpoint runs one orchestration or CLI step.
|
||||
// Signature: TestConfiguredServiceBackendRepairContinuesPastNoopEndpoint(t *testing.T).
|
||||
// Why: one unhealthy endpoint without a matching workload must not prevent later
|
||||
// critical services, especially Mailu, from getting their backend repair pass.
|
||||
func TestConfiguredServiceBackendRepairContinuesPastNoopEndpoint(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Startup: config.Startup{
|
||||
CriticalServiceEndpoints: []string{
|
||||
"orphan/missing-service",
|
||||
"mailu-mailserver/mailu-admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
orch := buildOrchestratorWithStubs(t, cfg, nil)
|
||||
|
||||
scaledMail := false
|
||||
rolledOutMail := false
|
||||
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
|
||||
if name != "kubectl" {
|
||||
return "", nil
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, "-n orphan get endpoints missing-service"):
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n orphan scale deployment missing-service --replicas=1"):
|
||||
return "", errors.New("not found")
|
||||
case strings.Contains(joined, "-n orphan scale statefulset missing-service --replicas=1"):
|
||||
return "", errors.New("not found")
|
||||
case strings.Contains(joined, "-n mailu-mailserver get endpoints mailu-admin"):
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver scale deployment mailu-admin --replicas=1"):
|
||||
scaledMail = true
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver rollout status deployment/mailu-admin"):
|
||||
rolledOutMail = true
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver scale statefulset mailu-admin --replicas=1"):
|
||||
return "", errors.New("not found")
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
orch.SetCommandOverrides(dispatch, dispatch)
|
||||
|
||||
if _, err := orch.healUnreadyConfiguredServiceBackends(context.Background()); err != nil {
|
||||
t.Fatalf("healUnreadyConfiguredServiceBackends failed: %v", err)
|
||||
}
|
||||
if !scaledMail || !rolledOutMail {
|
||||
t.Fatalf("expected later mail endpoint to be repaired, scaled=%v rolledOut=%v", scaledMail, rolledOutMail)
|
||||
}
|
||||
}
|
||||
@ -177,6 +177,77 @@ func TestPostStartAutoHealSkipsWhenClusterIsHealthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostStartAutoHealRepairsConfiguredMailBackends runs one orchestration or CLI step.
|
||||
// Signature: TestPostStartAutoHealRepairsConfiguredMailBackends(t *testing.T).
|
||||
// Why: Mailu readiness problems should be handled by Ananke's daemon repair
|
||||
// loop after startup, not only by a fresh bootstrap run.
|
||||
func TestPostStartAutoHealRepairsConfiguredMailBackends(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Startup: config.Startup{
|
||||
DeadNodeCleanupGraceSeconds: 300,
|
||||
CriticalServiceEndpoints: []string{"mailu-mailserver/mailu-admin"},
|
||||
NodeRuntimeRestartWaitSeconds: 1,
|
||||
NodeRuntimeRebootWaitSeconds: 1,
|
||||
HostPrivilegedCommandTimeoutSec: 1,
|
||||
},
|
||||
}
|
||||
orch := buildOrchestratorWithStubs(t, cfg, nil)
|
||||
|
||||
scaled := false
|
||||
rolledOut := false
|
||||
reconciled := false
|
||||
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
|
||||
if name != "kubectl" {
|
||||
return "", nil
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, "-n longhorn-system get nodes.longhorn.io -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case strings.Contains(joined, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
|
||||
return "", nil
|
||||
case strings.Contains(joined, "get nodes -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case strings.Contains(joined, "get pods -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case strings.Contains(joined, "get events -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case strings.Contains(joined, "get pvc -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver get endpoints mailu-admin"):
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver scale deployment mailu-admin --replicas=1"):
|
||||
scaled = true
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver rollout status deployment/mailu-admin"):
|
||||
rolledOut = true
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n mailu-mailserver scale statefulset mailu-admin --replicas=1"):
|
||||
return "", errors.New("not found")
|
||||
case strings.Contains(joined, "-n flux-system annotate gitrepository flux-system reconcile.fluxcd.io/requestedAt="):
|
||||
reconciled = true
|
||||
return "", nil
|
||||
case strings.Contains(joined, "-n flux-system annotate kustomizations.kustomize.toolkit.fluxcd.io --all reconcile.fluxcd.io/requestedAt="):
|
||||
return "", nil
|
||||
case strings.Contains(joined, "annotate --all-namespaces helmreleases.helm.toolkit.fluxcd.io --all reconcile.fluxcd.io/requestedAt="):
|
||||
return "", nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
orch.SetCommandOverrides(dispatch, dispatch)
|
||||
|
||||
if err := orch.postStartAutoHeal(context.Background()); err != nil {
|
||||
t.Fatalf("postStartAutoHeal failed: %v", err)
|
||||
}
|
||||
if !scaled || !rolledOut {
|
||||
t.Fatalf("expected mailu-admin deployment to be scaled and rolled out, scaled=%v rolledOut=%v", scaled, rolledOut)
|
||||
}
|
||||
if !reconciled {
|
||||
t.Fatalf("expected flux reconcile after mail backend repair")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPostStartAutoHealDryRun runs one orchestration or CLI step.
|
||||
// Signature: TestRunPostStartAutoHealDryRun(t *testing.T).
|
||||
// Why: covers the exported wrapper and the top-level dry-run guard so daemon
|
||||
|
||||
@ -20,6 +20,7 @@ type Orchestrator struct {
|
||||
log *log.Logger
|
||||
runOverride func(timeoutCtx context.Context, timeout time.Duration, name string, args ...string) (string, error)
|
||||
runSensitiveOverride func(timeoutCtx context.Context, timeout time.Duration, name string, args ...string) (string, error)
|
||||
sshInputOverride func(timeoutCtx context.Context, timeout time.Duration, node string, command string, input string) (string, error)
|
||||
startupReportMu sync.Mutex
|
||||
activeStartupReport *startupReport
|
||||
}
|
||||
@ -122,3 +123,11 @@ func (o *Orchestrator) SetCommandOverrides(run commandOverrideFunc, runSensitive
|
||||
o.runOverride = run
|
||||
o.runSensitiveOverride = runSensitive
|
||||
}
|
||||
|
||||
// SetSSHInputOverride runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) SetSSHInputOverride(run func(context.Context, time.Duration, string, string, string) (string, error)).
|
||||
// Why: password-backed host-repair tests need to assert stdin behavior without
|
||||
// exposing secrets through ordinary command-argument stubs.
|
||||
func (o *Orchestrator) SetSSHInputOverride(run func(context.Context, time.Duration, string, string, string) (string, error)) {
|
||||
o.sshInputOverride = run
|
||||
}
|
||||
|
||||
@ -2,12 +2,22 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type endpointResource struct {
|
||||
Subsets []struct {
|
||||
Addresses []struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"addresses"`
|
||||
} `json:"subsets"`
|
||||
}
|
||||
|
||||
// waitForCriticalServiceEndpoints runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) waitForCriticalServiceEndpoints(ctx context.Context) error.
|
||||
// Why: some externally-healthy services (like Grafana) still require backend
|
||||
@ -25,8 +35,10 @@ func (o *Orchestrator) waitForCriticalServiceEndpoints(ctx context.Context) erro
|
||||
lastFailure := "unknown"
|
||||
lastLogged := time.Time{}
|
||||
lastHealAttempt := time.Time{}
|
||||
lastRecycleAttempt := time.Time{}
|
||||
|
||||
for {
|
||||
o.maybeAutoRecycleStuckPods(ctx, &lastRecycleAttempt)
|
||||
ready, detail, failedNamespace, failedService, err := o.criticalServiceEndpointsReady(ctx)
|
||||
if err != nil {
|
||||
lastFailure = err.Error()
|
||||
@ -44,6 +56,16 @@ func (o *Orchestrator) waitForCriticalServiceEndpoints(ctx context.Context) erro
|
||||
lastHealAttempt = now
|
||||
healed, healErr := o.maybeHealCriticalEndpointBackends(ctx, failedNamespace, failedService)
|
||||
if healErr != nil {
|
||||
probeRepairs, probeErr := o.maybeRepairCriticalBackendStartupProbes(ctx, failedNamespace, failedService)
|
||||
if probeErr != nil {
|
||||
o.log.Printf("warning: critical endpoint startup-probe repair failed for %s/%s: %v", failedNamespace, failedService, probeErr)
|
||||
}
|
||||
if len(probeRepairs) > 0 {
|
||||
sort.Strings(probeRepairs)
|
||||
repairDetail := fmt.Sprintf("repaired critical endpoint startup probes: %s", joinLimited(probeRepairs, 8))
|
||||
o.log.Printf("%s", repairDetail)
|
||||
o.noteStartupAutoHeal(repairDetail)
|
||||
}
|
||||
o.log.Printf("warning: critical endpoint backend auto-heal failed for %s/%s: %v", failedNamespace, failedService, healErr)
|
||||
}
|
||||
if len(healed) > 0 {
|
||||
@ -120,7 +142,7 @@ func (o *Orchestrator) maybeHealCriticalEndpointBackends(ctx context.Context, na
|
||||
}
|
||||
return healed, fmt.Errorf("scale %s/%s/%s to 1: %w", namespace, kind, service, err)
|
||||
}
|
||||
if err := o.waitWorkloadReady(ctx, workload); err != nil {
|
||||
if err := o.workloadRolloutReadyOnce(ctx, workload); err != nil {
|
||||
if !isNotFoundErr(err) {
|
||||
return healed, err
|
||||
}
|
||||
@ -131,6 +153,83 @@ func (o *Orchestrator) maybeHealCriticalEndpointBackends(ctx context.Context, na
|
||||
return healed, nil
|
||||
}
|
||||
|
||||
// workloadRolloutReadyOnce runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) workloadRolloutReadyOnce(ctx context.Context, w startupWorkload) error.
|
||||
// Why: endpoint backend healing should issue one bounded readiness nudge and
|
||||
// let the outer endpoint wait enforce the configured convergence deadline.
|
||||
func (o *Orchestrator) workloadRolloutReadyOnce(ctx context.Context, w startupWorkload) error {
|
||||
_, err := o.kubectl(
|
||||
ctx,
|
||||
45*time.Second,
|
||||
"-n",
|
||||
w.Namespace,
|
||||
"rollout",
|
||||
"status",
|
||||
fmt.Sprintf("%s/%s", w.Kind, w.Name),
|
||||
"--timeout=30s",
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// healUnreadyConfiguredServiceBackends runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) healUnreadyConfiguredServiceBackends(ctx context.Context) ([]string, error).
|
||||
// Why: the daemon's post-start loop should be able to repair configured service
|
||||
// backends, including Mailu, without rerunning the whole startup workflow.
|
||||
func (o *Orchestrator) healUnreadyConfiguredServiceBackends(ctx context.Context) ([]string, error) {
|
||||
healed := []string{}
|
||||
if len(o.cfg.Startup.CriticalServiceEndpoints) > 0 {
|
||||
errs := []string{}
|
||||
for _, entry := range o.cfg.Startup.CriticalServiceEndpoints {
|
||||
namespace, service, err := parseCriticalServiceEndpoint(entry)
|
||||
if err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
count, err := o.endpointAddressCount(ctx, namespace, service)
|
||||
if err != nil && !isNotFoundErr(err) {
|
||||
errs = append(errs, fmt.Sprintf("query endpoints %s/%s: %v", namespace, service, err))
|
||||
continue
|
||||
}
|
||||
if err == nil && count > 0 {
|
||||
continue
|
||||
}
|
||||
detail := fmt.Sprintf("%s/%s endpoints=0", namespace, service)
|
||||
if err != nil {
|
||||
detail = fmt.Sprintf("%s/%s not found", namespace, service)
|
||||
}
|
||||
o.log.Printf("warning: configured critical endpoint unhealthy (%s); attempting backend repair", detail)
|
||||
items, healErr := o.maybeHealCriticalEndpointBackends(ctx, namespace, service)
|
||||
healed = append(healed, items...)
|
||||
if healErr != nil {
|
||||
probeItems, probeErr := o.maybeRepairCriticalBackendStartupProbes(ctx, namespace, service)
|
||||
healed = append(healed, probeItems...)
|
||||
if probeErr != nil {
|
||||
errs = append(errs, fmt.Sprintf("heal critical endpoint %s/%s (%s): %v; startup-probe repair: %v", namespace, service, detail, healErr, probeErr))
|
||||
continue
|
||||
}
|
||||
if o.cfg.Startup.AutoRecycleStuckPods {
|
||||
o.bestEffort("recycle stuck pods after configured service backend repair failure", func() error {
|
||||
return o.recycleStuckControllerPods(ctx)
|
||||
})
|
||||
}
|
||||
if len(probeItems) == 0 {
|
||||
errs = append(errs, fmt.Sprintf("heal critical endpoint %s/%s (%s): %v", namespace, service, detail, healErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return healed, errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
tcpHealed, err := o.healFailedTCPServiceBackends(ctx)
|
||||
healed = append(healed, tcpHealed...)
|
||||
if err != nil {
|
||||
return healed, err
|
||||
}
|
||||
return healed, nil
|
||||
}
|
||||
|
||||
// endpointAddressCount runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) endpointAddressCount(ctx context.Context, namespace string, service string) (int, error).
|
||||
// Why: endpoint address counts provide an objective service-backend readiness
|
||||
@ -145,12 +244,43 @@ func (o *Orchestrator) endpointAddressCount(ctx context.Context, namespace strin
|
||||
"endpoints",
|
||||
service,
|
||||
"-o",
|
||||
`jsonpath={range .subsets[*].addresses[*]}{.ip}{"\n"}{end}`,
|
||||
"json",
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(lines(out)), nil
|
||||
cleaned := stripKubectlWarnings(out)
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
var endpoint endpointResource
|
||||
if err := json.Unmarshal([]byte(cleaned), &endpoint); err != nil {
|
||||
return 0, fmt.Errorf("decode endpoints %s/%s: %w", namespace, service, err)
|
||||
}
|
||||
count := 0
|
||||
for _, subset := range endpoint.Subsets {
|
||||
for _, address := range subset.Addresses {
|
||||
if strings.TrimSpace(address.IP) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
return len(lines(cleaned)), nil
|
||||
}
|
||||
|
||||
// stripKubectlWarnings runs one orchestration or CLI step.
|
||||
// Signature: stripKubectlWarnings(out string) string.
|
||||
// Why: kubectl writes deprecation warnings to stderr; command execution combines
|
||||
// streams, so readiness parsers must not mistake warning lines for health data.
|
||||
func stripKubectlWarnings(out string) string {
|
||||
kept := []string{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "Warning:") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
// parseCriticalServiceEndpoint runs one orchestration or CLI step.
|
||||
|
||||
144
internal/cluster/orchestrator_critical_probe_repair.go
Normal file
144
internal/cluster/orchestrator_critical_probe_repair.go
Normal file
@ -0,0 +1,144 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type criticalBackendWorkload struct {
|
||||
Spec struct {
|
||||
Template struct {
|
||||
Spec struct {
|
||||
Containers []criticalBackendContainer `json:"containers"`
|
||||
} `json:"spec"`
|
||||
} `json:"template"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
type criticalBackendContainer struct {
|
||||
Name string `json:"name"`
|
||||
LivenessProbe map[string]any `json:"livenessProbe"`
|
||||
StartupProbe map[string]any `json:"startupProbe"`
|
||||
}
|
||||
|
||||
// maybeRepairCriticalBackendStartupProbes runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) maybeRepairCriticalBackendStartupProbes(ctx context.Context, namespace string, service string) ([]string, error).
|
||||
// Why: critical backends that are slow after outage recovery can be killed by
|
||||
// liveness probes before they ever become ready; adding a startup probe lets
|
||||
// Kubernetes distinguish slow boot from dead process without app-specific code.
|
||||
func (o *Orchestrator) maybeRepairCriticalBackendStartupProbes(ctx context.Context, namespace string, service string) ([]string, error) {
|
||||
if !o.cfg.Startup.CriticalServiceStartupProbeRepair {
|
||||
return nil, nil
|
||||
}
|
||||
namespace = strings.TrimSpace(namespace)
|
||||
service = strings.TrimSpace(service)
|
||||
if namespace == "" || service == "" {
|
||||
return nil, nil
|
||||
}
|
||||
repaired := []string{}
|
||||
errs := []string{}
|
||||
for _, kind := range []string{"deployment", "statefulset"} {
|
||||
items, err := o.repairWorkloadStartupProbes(ctx, namespace, kind, service)
|
||||
if err != nil {
|
||||
if isNotFoundErr(err) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
repaired = append(repaired, items...)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return repaired, errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
return repaired, nil
|
||||
}
|
||||
|
||||
// repairWorkloadStartupProbes runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) repairWorkloadStartupProbes(ctx context.Context, namespace string, kind string, name string) ([]string, error).
|
||||
// Why: the startup-probe patch is deliberately limited to an existing
|
||||
// deployment/statefulset with a liveness probe and no startup probe.
|
||||
func (o *Orchestrator) repairWorkloadStartupProbes(ctx context.Context, namespace string, kind string, name string) ([]string, error) {
|
||||
out, err := o.kubectl(ctx, 20*time.Second, "-n", namespace, "get", kind, name, "-o", "json")
|
||||
if err != nil {
|
||||
if strings.TrimSpace(out) != "" {
|
||||
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var workload criticalBackendWorkload
|
||||
if err := json.Unmarshal([]byte(out), &workload); err != nil {
|
||||
return nil, fmt.Errorf("decode %s/%s/%s for startup probe repair: %w", namespace, kind, name, err)
|
||||
}
|
||||
|
||||
containers := []map[string]any{}
|
||||
threshold := o.cfg.Startup.CriticalServiceStartupProbeThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = 30
|
||||
}
|
||||
for _, container := range workload.Spec.Template.Spec.Containers {
|
||||
containerName := strings.TrimSpace(container.Name)
|
||||
if containerName == "" || len(container.LivenessProbe) == 0 || len(container.StartupProbe) > 0 {
|
||||
continue
|
||||
}
|
||||
startupProbe := cloneMap(container.LivenessProbe)
|
||||
startupProbe["failureThreshold"] = threshold
|
||||
if _, ok := startupProbe["successThreshold"]; !ok {
|
||||
startupProbe["successThreshold"] = 1
|
||||
}
|
||||
containers = append(containers, map[string]any{
|
||||
"name": containerName,
|
||||
"startupProbe": startupProbe,
|
||||
})
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
patch := map[string]any{
|
||||
"spec": map[string]any{
|
||||
"template": map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"annotations": map[string]string{
|
||||
"ananke.bstein.dev/startup-probe-repair-at": now,
|
||||
"ananke.bstein.dev/startup-probe-repair-reason": "critical-service-backend-unready",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"containers": containers,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rawPatch, err := json.Marshal(patch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode startup probe repair patch: %w", err)
|
||||
}
|
||||
if _, err := o.kubectl(ctx, 25*time.Second, "-n", namespace, "patch", kind, name, "--type=strategic", "-p", string(rawPatch)); err != nil {
|
||||
return nil, fmt.Errorf("patch startup probes on %s/%s/%s: %w", namespace, kind, name, err)
|
||||
}
|
||||
o.log.Printf("repaired startup probes on critical backend %s/%s/%s containers=%d", namespace, kind, name, len(containers))
|
||||
o.noteStartupAutoHeal(fmt.Sprintf("repaired startup probes on %s/%s/%s", namespace, kind, name))
|
||||
return []string{namespace + "/" + kind + "/" + name}, nil
|
||||
}
|
||||
|
||||
// cloneMap runs one orchestration or CLI step.
|
||||
// Signature: cloneMap(in map[string]any) map[string]any.
|
||||
// Why: Kubernetes probe objects are patch payloads; cloning avoids mutating the
|
||||
// decoded workload fixture while changing the generated startup probe threshold.
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range in {
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
out[key] = cloneMap(nested)
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -178,6 +178,9 @@ func (o *Orchestrator) waitWorkloadReady(ctx context.Context, w startupWorkload)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isNotFoundErr(err) {
|
||||
return err
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
select {
|
||||
@ -318,28 +321,6 @@ func (o *Orchestrator) ensureVaultUnsealed(ctx context.Context) error {
|
||||
return fmt.Errorf("vault remained sealed after 5 auto-unseal attempts")
|
||||
}
|
||||
|
||||
// vaultSealed runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error).
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
func (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error) {
|
||||
out, err := o.kubectl(
|
||||
ctx,
|
||||
25*time.Second,
|
||||
"-n", "vault",
|
||||
"exec", "vault-0", "--",
|
||||
"sh", "-lc",
|
||||
"VAULT_ADDR=http://127.0.0.1:8200 vault status -format=json 2>/dev/null || true",
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("vault status check failed: %w", err)
|
||||
}
|
||||
sealed, err := parseVaultSealed(out)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse vault status: %w", err)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// parseVaultSealed runs one orchestration or CLI step.
|
||||
// Signature: parseVaultSealed(raw string) (bool, error).
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
|
||||
455
internal/cluster/orchestrator_hardening_test.go
Normal file
455
internal/cluster/orchestrator_hardening_test.go
Normal file
@ -0,0 +1,455 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scm.bstein.dev/bstein/ananke/internal/config"
|
||||
)
|
||||
|
||||
// TestHostPrivilegeUsesVaultBackedSudoWithoutLeakingSecret runs one orchestration or CLI step.
|
||||
// Signature: TestHostPrivilegeUsesVaultBackedSudoWithoutLeakingSecret(t *testing.T).
|
||||
// Why: host repair must recover from password-required sudo while keeping the
|
||||
// sudo password out of command arguments and logs.
|
||||
func TestHostPrivilegeUsesVaultBackedSudoWithoutLeakingSecret(t *testing.T) {
|
||||
secret := "correct horse battery staple"
|
||||
var logs bytes.Buffer
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{
|
||||
Startup: config.Startup{
|
||||
HostSudoSecretNamespace: "ops",
|
||||
HostSudoSecretNameTemplate: "sudo-{node}",
|
||||
HostSudoSecretPasswordKey: "password",
|
||||
},
|
||||
}, nil)
|
||||
orch.log = log.New(&logs, "", 0)
|
||||
|
||||
orch.SetCommandOverrides(func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "ssh" && strings.Contains(joined, "sudo -n /usr/bin/systemctl --version"):
|
||||
return "sudo: a password is required", errors.New("exit status 1")
|
||||
case name == "kubectl" && strings.Contains(joined, "-n ops get secret sudo-titan-05 -o json"):
|
||||
return `{"data":{"password":"` + base64.StdEncoding.EncodeToString([]byte(secret)) + `"}}`, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}, nil)
|
||||
|
||||
inputSeen := ""
|
||||
commandSeen := ""
|
||||
orch.SetSSHInputOverride(func(_ context.Context, _ time.Duration, node string, command string, input string) (string, error) {
|
||||
if node != "titan-05" {
|
||||
t.Fatalf("unexpected node %q", node)
|
||||
}
|
||||
commandSeen = command
|
||||
inputSeen = input
|
||||
return "systemd 255", nil
|
||||
})
|
||||
|
||||
if err := orch.preflightHostPrivilege(context.Background(), "titan-05"); err != nil {
|
||||
t.Fatalf("preflightHostPrivilege failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(commandSeen, "sudo -S -p '' /usr/bin/systemctl --version") {
|
||||
t.Fatalf("expected sudo stdin command, got %q", commandSeen)
|
||||
}
|
||||
if inputSeen != secret+"\n" {
|
||||
t.Fatalf("expected sudo password on stdin only")
|
||||
}
|
||||
if strings.Contains(logs.String(), secret) || strings.Contains(commandSeen, secret) {
|
||||
t.Fatalf("sudo password leaked through logs or command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostPrivilegeMissingSecretReportsUnavailable runs one orchestration or CLI step.
|
||||
// Signature: TestHostPrivilegeMissingSecretReportsUnavailable(t *testing.T).
|
||||
// Why: missing Vault/Kubernetes sudo material should be a classified blocker,
|
||||
// not an opaque loop of failed host commands.
|
||||
func TestHostPrivilegeMissingSecretReportsUnavailable(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{
|
||||
match: matchContains("ssh", "sudo -n /usr/bin/systemctl --version"),
|
||||
out: "sudo: a password is required",
|
||||
err: errors.New("exit status 1"),
|
||||
},
|
||||
})
|
||||
err := orch.preflightHostPrivilege(context.Background(), "titan-05")
|
||||
if err == nil || !strings.Contains(err.Error(), "host-privilege-unavailable") {
|
||||
t.Fatalf("expected host-privilege-unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileLonghornKubernetesReadinessRestoresManagerSelectorLabel runs one orchestration or CLI step.
|
||||
// Signature: TestReconcileLonghornKubernetesReadinessRestoresManagerSelectorLabel(t *testing.T).
|
||||
// Why: a Kubernetes Ready node with a Longhorn Node CR should regain missing
|
||||
// manager selector labels instead of leaving PVC workloads blocked.
|
||||
func TestReconcileLonghornKubernetesReadinessRestoresManagerSelectorLabel(t *testing.T) {
|
||||
labeled := false
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "longhorn-system", "get", "nodes.longhorn.io", "-o", "json"),
|
||||
out: `{"items":[{"metadata":{"name":"titan-22"},"status":{"conditions":[{"type":"Ready","status":"False","reason":"ManagerPodMissing"}]}}]}`,
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "longhorn-system", "get", "daemonset", "longhorn-manager", "-o", "json"),
|
||||
out: `{"spec":{"selector":{"matchLabels":{"longhorn-host":"true"}}}}`,
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "get", "nodes", "-o", "json"),
|
||||
out: `{"items":[{"metadata":{"name":"titan-22","labels":{"kubernetes.io/hostname":"titan-22"}},"status":{"conditions":[{"type":"Ready","status":"True"}]}}]}`,
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "longhorn-system", "get", "pods", "-o", "json", "-l", "longhorn-host=true"),
|
||||
out: `{"items":[]}`,
|
||||
},
|
||||
{
|
||||
match: func(name string, args []string) bool {
|
||||
if !matchContains("kubectl", "label", "node", "titan-22", "--overwrite", "longhorn-host=true")(name, args) {
|
||||
return false
|
||||
}
|
||||
labeled = true
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
repaired, err := orch.reconcileLonghornKubernetesReadiness(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileLonghornKubernetesReadiness failed: %v", err)
|
||||
}
|
||||
if repaired != 1 || !labeled {
|
||||
t.Fatalf("expected one Longhorn label repair, repaired=%d labeled=%v", repaired, labeled)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaleRWOPVCOwnerDecisionsDistinguishSidecarAndLiveWriter runs one orchestration or CLI step.
|
||||
// Signature: TestStaleRWOPVCOwnerDecisionsDistinguishSidecarAndLiveWriter(t *testing.T).
|
||||
// Why: stale RWO cleanup may force-delete sidecar-only owners but must not clear
|
||||
// an API object while a live application container still mounts the PVC.
|
||||
func TestStaleRWOPVCOwnerDecisionsDistinguishSidecarAndLiveWriter(t *testing.T) {
|
||||
old := time.Now().Add(-30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
pvcJSON := `{"items":[{"metadata":{"namespace":"finance","name":"firefly-storage"},"spec":{"accessModes":["ReadWriteOnce"],"volumeName":"pvc-1"}}]}`
|
||||
eventsJSON := `{"items":[{"involvedObject":{"kind":"Pod","namespace":"finance","name":"firefly-new"},"type":"Warning","reason":"FailedAttachVolume","message":"Multi-Attach error for volume pvc-1: Volume is already used by pod(s) firefly-old"}]}`
|
||||
|
||||
t.Run("sidecar-only force delete", func(t *testing.T) {
|
||||
podsJSON := `{"items":[` +
|
||||
staleRWOPodJSON("firefly-old", old, "titan-04", true, false) + `,` +
|
||||
replacementRWOPodJSON("firefly-new", "titan-06") +
|
||||
`]}`
|
||||
orch := staleRWOOrchestrator(t, podsJSON, pvcJSON, eventsJSON)
|
||||
|
||||
var pods podList
|
||||
if err := jsonUnmarshal(podsJSON, &pods); err != nil {
|
||||
t.Fatalf("decode pods: %v", err)
|
||||
}
|
||||
decisions, err := orch.staleRWOPVCOwnerDecisions(context.Background(), pods, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("staleRWOPVCOwnerDecisions failed: %v", err)
|
||||
}
|
||||
decision := decisions["finance/firefly-old"]
|
||||
if !decision.ForceDelete || decision.Unsafe {
|
||||
t.Fatalf("expected sidecar-only force delete decision, got %#v", decision)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("live writer unsafe", func(t *testing.T) {
|
||||
podsJSON := `{"items":[` +
|
||||
staleRWOPodJSON("firefly-old", old, "titan-04", true, true) + `,` +
|
||||
replacementRWOPodJSON("firefly-new", "titan-06") +
|
||||
`]}`
|
||||
orch := staleRWOOrchestrator(t, podsJSON, pvcJSON, eventsJSON)
|
||||
|
||||
var pods podList
|
||||
if err := jsonUnmarshal(podsJSON, &pods); err != nil {
|
||||
t.Fatalf("decode pods: %v", err)
|
||||
}
|
||||
decisions, err := orch.staleRWOPVCOwnerDecisions(context.Background(), pods, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("staleRWOPVCOwnerDecisions failed: %v", err)
|
||||
}
|
||||
decision := decisions["finance/firefly-old"]
|
||||
if !decision.Unsafe || decision.ForceDelete {
|
||||
t.Fatalf("expected live writer unsafe decision, got %#v", decision)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestImagePullDNSBlockerReasonsClassifiesDockerHubDNS runs one orchestration or CLI step.
|
||||
// Signature: TestImagePullDNSBlockerReasonsClassifiesDockerHubDNS(t *testing.T).
|
||||
// Why: DNS-caused image pulls should be reported as registry/DNS blockers rather
|
||||
// than recycled as if deleting the pod could fix name resolution.
|
||||
func TestImagePullDNSBlockerReasonsClassifiesDockerHubDNS(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "get", "events", "-A", "-o", "json"),
|
||||
out: `{"items":[{"involvedObject":{"kind":"Pod","namespace":"logging","name":"oauth2"},"type":"Warning","reason":"Failed","message":"Failed to pull image: lookup registry-1.docker.io: Try again"}]}`,
|
||||
},
|
||||
})
|
||||
reasons, err := orch.imagePullDNSBlockerReasons(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("imagePullDNSBlockerReasons failed: %v", err)
|
||||
}
|
||||
if got := reasons["logging/oauth2"]; got != "ImagePullDNSBlocker:registry-1.docker.io" {
|
||||
t.Fatalf("unexpected image pull DNS reason %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultSealedFallsBackToHTTPHealthWhenExecKilled runs one orchestration or CLI step.
|
||||
// Signature: TestVaultSealedFallsBackToHTTPHealthWhenExecKilled(t *testing.T).
|
||||
// Why: a transient killed kubectl exec should not block startup when Vault HTTP
|
||||
// health proves the pod is initialized and unsealed.
|
||||
func TestVaultSealedFallsBackToHTTPHealthWhenExecKilled(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "vault", "exec", "vault-0"),
|
||||
err: errors.New("signal: killed"),
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "vault", "get", "--raw", "/api/v1/namespaces/vault/pods/vault-0:8200/proxy/v1/sys/health"),
|
||||
out: `{"initialized":true,"sealed":false,"standby":false}`,
|
||||
},
|
||||
})
|
||||
sealed, err := orch.vaultSealed(context.Background())
|
||||
if err != nil || sealed {
|
||||
t.Fatalf("expected HTTP fallback to report unsealed, sealed=%v err=%v", sealed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTCPServiceChecklistReadyChecksMailProtocolBanner runs one orchestration or CLI step.
|
||||
// Signature: TestTCPServiceChecklistReadyChecksMailProtocolBanner(t *testing.T).
|
||||
// Why: Mailu is not covered by HTTP ingress checks, so Ananke needs direct
|
||||
// protocol probes that validate a TCP greeting and command response.
|
||||
func TestTCPServiceChecklistReadyChecksMailProtocolBanner(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte("220 mail.bstein.dev ESMTP ready\r\n"))
|
||||
_, _ = bufio.NewReader(conn).ReadString('\n')
|
||||
_, _ = conn.Write([]byte("221 2.0.0 Bye\r\n"))
|
||||
}()
|
||||
|
||||
host, port, err := net.SplitHostPort(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("split listener address: %v", err)
|
||||
}
|
||||
check := config.TCPServiceChecklistCheck{
|
||||
Name: "mail-smtp",
|
||||
Host: host,
|
||||
Port: atoiForTest(t, port),
|
||||
Send: "QUIT\r\n",
|
||||
ExpectContains: "ESMTP",
|
||||
TimeoutSeconds: 2,
|
||||
}
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, nil)
|
||||
ok, detail := orch.tcpServiceCheckReady(context.Background(), check)
|
||||
if !ok {
|
||||
t.Fatalf("expected TCP mail check to pass, detail=%s", detail)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestServiceChecklistReadyIncludesTCPFailures runs one orchestration or CLI step.
|
||||
// Signature: TestServiceChecklistReadyIncludesTCPFailures(t *testing.T).
|
||||
// Why: startup should fail clearly when a configured mail protocol endpoint is
|
||||
// down even if there are no HTTP checks in that config.
|
||||
func TestServiceChecklistReadyIncludesTCPFailures(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{
|
||||
Startup: config.Startup{
|
||||
TCPServiceChecklist: []config.TCPServiceChecklistCheck{
|
||||
{
|
||||
Name: "mail-smtp",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1,
|
||||
ExpectContains: "ESMTP",
|
||||
TimeoutSeconds: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
ok, detail := orch.serviceChecklistReady(context.Background())
|
||||
if ok {
|
||||
t.Fatalf("expected TCP-only checklist to fail")
|
||||
}
|
||||
if !strings.Contains(detail, "tcp mail-smtp") {
|
||||
t.Fatalf("expected TCP failure detail, got %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndpointAddressCountIgnoresKubectlWarnings runs one orchestration or CLI step.
|
||||
// Signature: TestEndpointAddressCountIgnoresKubectlWarnings(t *testing.T).
|
||||
// Why: Kubernetes endpoint deprecation warnings must not make an empty service
|
||||
// look ready by contributing a fake output line.
|
||||
func TestEndpointAddressCountIgnoresKubectlWarnings(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "mailu-mailserver", "get", "endpoints", "mailu-admin", "-o", "json"),
|
||||
out: "Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice\n{\"subsets\":[]}",
|
||||
},
|
||||
})
|
||||
count, err := orch.endpointAddressCount(context.Background(), "mailu-mailserver", "mailu-admin")
|
||||
if err != nil {
|
||||
t.Fatalf("endpointAddressCount failed: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected empty endpoint count after warning strip, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCriticalBackendStartupProbeRepairCopiesLivenessProbe runs one orchestration or CLI step.
|
||||
// Signature: TestCriticalBackendStartupProbeRepairCopiesLivenessProbe(t *testing.T).
|
||||
// Why: slow critical backends should get a bounded startup grace without
|
||||
// changing app-specific liveness semantics or requiring a Mailu special case.
|
||||
func TestCriticalBackendStartupProbeRepairCopiesLivenessProbe(t *testing.T) {
|
||||
var patchPayload string
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{
|
||||
Startup: config.Startup{
|
||||
CriticalServiceStartupProbeRepair: true,
|
||||
CriticalServiceStartupProbeThreshold: 24,
|
||||
},
|
||||
}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "mailu-mailserver", "get", "deployment", "mailu-admin", "-o", "json"),
|
||||
out: `{"spec":{"template":{"spec":{"containers":[{"name":"admin","livenessProbe":{"httpGet":{"path":"/ping","port":"http"},"periodSeconds":10,"failureThreshold":3}},` +
|
||||
`{"name":"sidecar"}]}}}}`,
|
||||
},
|
||||
{
|
||||
match: func(name string, args []string) bool {
|
||||
if !matchContains("kubectl", "-n", "mailu-mailserver", "patch", "deployment", "mailu-admin", "--type=strategic", "-p")(name, args) {
|
||||
return false
|
||||
}
|
||||
for i, arg := range args {
|
||||
if arg == "-p" && i+1 < len(args) {
|
||||
patchPayload = args[i+1]
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "mailu-mailserver", "get", "statefulset", "mailu-admin", "-o", "json"),
|
||||
err: errors.New("not found"),
|
||||
},
|
||||
})
|
||||
|
||||
repaired, err := orch.maybeRepairCriticalBackendStartupProbes(context.Background(), "mailu-mailserver", "mailu-admin")
|
||||
if err != nil {
|
||||
t.Fatalf("maybeRepairCriticalBackendStartupProbes failed: %v", err)
|
||||
}
|
||||
if len(repaired) != 1 || repaired[0] != "mailu-mailserver/deployment/mailu-admin" {
|
||||
t.Fatalf("unexpected repaired list: %v", repaired)
|
||||
}
|
||||
if patchPayload == "" {
|
||||
t.Fatalf("expected strategic merge patch payload")
|
||||
}
|
||||
if !strings.Contains(patchPayload, `"startupProbe"`) || !strings.Contains(patchPayload, `"failureThreshold":24`) || !strings.Contains(patchPayload, `"path":"/ping"`) {
|
||||
t.Fatalf("unexpected startup-probe patch payload: %s", patchPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCriticalBackendStartupProbeRepairIgnoresMissingStatefulSet runs one orchestration or CLI step.
|
||||
// Signature: TestCriticalBackendStartupProbeRepairIgnoresMissingStatefulSet(t *testing.T).
|
||||
// Why: Mailu backends are Deployments; a kubectl NotFound message on the
|
||||
// StatefulSet probe path must not turn a successful/no-op Deployment check into
|
||||
// an auto-heal failure.
|
||||
func TestCriticalBackendStartupProbeRepairIgnoresMissingStatefulSet(t *testing.T) {
|
||||
orch := buildOrchestratorWithStubs(t, config.Config{
|
||||
Startup: config.Startup{
|
||||
CriticalServiceStartupProbeRepair: true,
|
||||
CriticalServiceStartupProbeThreshold: 24,
|
||||
},
|
||||
}, []commandStub{
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "mailu-mailserver", "get", "deployment", "mailu-rspamd", "-o", "json"),
|
||||
out: `{"spec":{"template":{"spec":{"containers":[{"name":"rspamd","livenessProbe":{"httpGet":{"path":"/","port":"http"}},"startupProbe":{"httpGet":{"path":"/","port":"http"}}}]}}}}`,
|
||||
},
|
||||
{
|
||||
match: matchContains("kubectl", "-n", "mailu-mailserver", "get", "statefulset", "mailu-rspamd", "-o", "json"),
|
||||
out: `Error from server (NotFound): statefulsets.apps "mailu-rspamd" not found`,
|
||||
err: errors.New("exit status 1"),
|
||||
},
|
||||
})
|
||||
repaired, err := orch.maybeRepairCriticalBackendStartupProbes(context.Background(), "mailu-mailserver", "mailu-rspamd")
|
||||
if err != nil {
|
||||
t.Fatalf("expected missing statefulset to be ignored, got %v", err)
|
||||
}
|
||||
if len(repaired) != 0 {
|
||||
t.Fatalf("expected no repair for existing startup probe, got %v", repaired)
|
||||
}
|
||||
}
|
||||
|
||||
// atoiForTest runs one orchestration or CLI step.
|
||||
// Signature: atoiForTest(t *testing.T, raw string) int.
|
||||
// Why: TCP listener fixtures need the kernel-selected port as a typed config
|
||||
// value without obscuring test failures.
|
||||
func atoiForTest(t *testing.T, raw string) int {
|
||||
t.Helper()
|
||||
port, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse port %q: %v", raw, err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// staleRWOOrchestrator runs one orchestration or CLI step.
|
||||
// Signature: staleRWOOrchestrator(t *testing.T, podsJSON string, pvcJSON string, eventsJSON string) *Orchestrator.
|
||||
// Why: stale RWO tests need a tiny fake API surface for pods, PVCs, and events
|
||||
// without depending on a live Kubernetes cluster.
|
||||
func staleRWOOrchestrator(t *testing.T, podsJSON string, pvcJSON string, eventsJSON string) *Orchestrator {
|
||||
t.Helper()
|
||||
return buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
|
||||
{match: matchContains("kubectl", "get", "events", "-A", "-o", "json"), out: eventsJSON},
|
||||
{match: matchContains("kubectl", "get", "pvc", "-A", "-o", "json"), out: pvcJSON},
|
||||
{match: matchContains("kubectl", "get", "pods", "-A", "-o", "json"), out: podsJSON},
|
||||
})
|
||||
}
|
||||
|
||||
// staleRWOPodJSON runs one orchestration or CLI step.
|
||||
// Signature: staleRWOPodJSON(name string, deletedAt string, node string, sidecarRunning bool, appRunning bool) string.
|
||||
// Why: the sidecar-only and live-writer fixtures differ only in container
|
||||
// status, so one builder keeps the safety distinction obvious.
|
||||
func staleRWOPodJSON(name string, deletedAt string, node string, sidecarRunning bool, appRunning bool) string {
|
||||
statuses := []string{}
|
||||
if sidecarRunning {
|
||||
statuses = append(statuses, `{"name":"vault-agent","state":{"running":{"startedAt":"`+deletedAt+`"}}}`)
|
||||
}
|
||||
if appRunning {
|
||||
statuses = append(statuses, `{"name":"firefly","state":{"running":{"startedAt":"`+deletedAt+`"}}}`)
|
||||
}
|
||||
return `{"metadata":{"namespace":"finance","name":"` + name + `","creationTimestamp":"` + deletedAt + `","deletionTimestamp":"` + deletedAt + `","ownerReferences":[{"kind":"ReplicaSet","name":"firefly"}]},"spec":{"nodeName":"` + node + `","volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"firefly-storage"}},{"name":"vault-secret"}],"containers":[{"name":"firefly","volumeMounts":[{"name":"data","mountPath":"/var/www/html/storage"}]},{"name":"vault-agent","volumeMounts":[{"name":"vault-secret","mountPath":"/vault/secrets"}]}]},"status":{"phase":"Running","containerStatuses":[` + strings.Join(statuses, ",") + `]}}`
|
||||
}
|
||||
|
||||
// replacementRWOPodJSON runs one orchestration or CLI step.
|
||||
// Signature: replacementRWOPodJSON(name string, node string) string.
|
||||
// Why: stale-owner tests need a controller sibling blocked in Pending on a
|
||||
// different node while sharing the same RWO claim.
|
||||
func replacementRWOPodJSON(name string, node string) string {
|
||||
return `{"metadata":{"namespace":"finance","name":"` + name + `","creationTimestamp":"` + time.Now().Add(-10*time.Minute).UTC().Format(time.RFC3339) + `","ownerReferences":[{"kind":"ReplicaSet","name":"firefly"}]},"spec":{"nodeName":"` + node + `","volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"firefly-storage"}}],"containers":[{"name":"firefly","volumeMounts":[{"name":"data","mountPath":"/var/www/html/storage"}]}]},"status":{"phase":"Pending","containerStatuses":[{"name":"firefly","state":{"waiting":{"reason":"ContainerCreating"}}}]}}`
|
||||
}
|
||||
|
||||
// jsonUnmarshal runs one orchestration or CLI step.
|
||||
// Signature: jsonUnmarshal(raw string, target any) error.
|
||||
// Why: local JSON fixtures should use the same decoder semantics as Kubernetes
|
||||
// command output parsing.
|
||||
func jsonUnmarshal(raw string, target any) error {
|
||||
return json.Unmarshal([]byte(raw), target)
|
||||
}
|
||||
331
internal/cluster/orchestrator_host_privilege.go
Normal file
331
internal/cluster/orchestrator_host_privilege.go
Normal file
@ -0,0 +1,331 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hostPrivilegedAction string
|
||||
|
||||
const (
|
||||
hostActionSudoPreflight hostPrivilegedAction = "sudo-preflight"
|
||||
hostActionK3sAgentShow hostPrivilegedAction = "k3s-agent-show"
|
||||
hostActionK3sAgentIsActive hostPrivilegedAction = "k3s-agent-is-active"
|
||||
hostActionK3sAgentRestart hostPrivilegedAction = "k3s-agent-restart-no-block"
|
||||
hostActionHostReboot hostPrivilegedAction = "host-reboot"
|
||||
hostActionInstallCryptsetup hostPrivilegedAction = "install-cryptsetup-bin"
|
||||
hostActionModprobeDMCrypt hostPrivilegedAction = "modprobe-dm-crypt"
|
||||
hostActionCrictlPods hostPrivilegedAction = "crictl-pods"
|
||||
hostActionCrictlPs hostPrivilegedAction = "crictl-ps"
|
||||
hostActionCrictlInspect hostPrivilegedAction = "crictl-inspect"
|
||||
hostActionCrictlStop hostPrivilegedAction = "crictl-stop"
|
||||
)
|
||||
|
||||
type hostPrivilegeError struct {
|
||||
Class string
|
||||
Node string
|
||||
Action hostPrivilegedAction
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Error runs one orchestration or CLI step.
|
||||
// Signature: (e hostPrivilegeError) Error() string.
|
||||
// Why: host repair failures need stable operator-facing classes without
|
||||
// embedding secret material or raw command input.
|
||||
func (e hostPrivilegeError) Error() string {
|
||||
parts := []string{strings.TrimSpace(e.Class)}
|
||||
if strings.TrimSpace(e.Node) != "" {
|
||||
parts = append(parts, "node="+strings.TrimSpace(e.Node))
|
||||
}
|
||||
if e.Action != "" {
|
||||
parts = append(parts, "action="+string(e.Action))
|
||||
}
|
||||
if strings.TrimSpace(e.Detail) != "" {
|
||||
parts = append(parts, strings.TrimSpace(e.Detail))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
type hostSudoSecret struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// preflightHostPrivilege verifies that Ananke can run harmless sudo on a node.
|
||||
// Signature: (o *Orchestrator) preflightHostPrivilege(ctx context.Context, node string) error.
|
||||
// Why: host repair should fail with a clear privilege class before an outage
|
||||
// path needs a package install, k3s-agent restart, runtime inspection, or reboot.
|
||||
func (o *Orchestrator) preflightHostPrivilege(ctx context.Context, node string) error {
|
||||
_, err := o.runHostPrivilegedAction(ctx, node, hostActionSudoPreflight, 12*time.Second)
|
||||
return err
|
||||
}
|
||||
|
||||
// runHostPrivilegedAction executes one allowlisted sudo action on a managed host.
|
||||
// Signature: (o *Orchestrator) runHostPrivilegedAction(ctx context.Context, node string, action hostPrivilegedAction, timeout time.Duration, args ...string) (string, error).
|
||||
// Why: recovery must not turn Vault-backed sudo into arbitrary remote root
|
||||
// execution; every privileged operation is classified, bounded, and auditable.
|
||||
func (o *Orchestrator) runHostPrivilegedAction(ctx context.Context, node string, action hostPrivilegedAction, timeout time.Duration, args ...string) (string, error) {
|
||||
node = strings.TrimSpace(node)
|
||||
if node == "" {
|
||||
return "", hostPrivilegeError{Class: "host-privilege-unavailable", Action: action, Detail: "node is empty"}
|
||||
}
|
||||
if !o.sshManaged(node) {
|
||||
return "", hostPrivilegeError{Class: "host-privilege-unavailable", Node: node, Action: action, Detail: "node is not SSH-managed"}
|
||||
}
|
||||
commandArgs, err := hostPrivilegedCommandArgs(action, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = o.hostPrivilegedCommandTimeout()
|
||||
}
|
||||
|
||||
passwordless := sudoRemoteCommand(false, commandArgs)
|
||||
out, err := o.sshWithTimeout(ctx, node, passwordless, timeout)
|
||||
if err == nil {
|
||||
o.logHostPrivilegedAction(node, action, "passwordless-sudo")
|
||||
return out, nil
|
||||
}
|
||||
if !sudoMayNeedPassword(out, err) {
|
||||
return out, classifyHostPrivilegeFailure(node, action, err, out)
|
||||
}
|
||||
|
||||
password, secretClass, secretErr := o.hostSudoPassword(ctx, node)
|
||||
if secretErr != nil {
|
||||
return "", hostPrivilegeError{
|
||||
Class: "host-privilege-unavailable",
|
||||
Node: node,
|
||||
Action: action,
|
||||
Detail: fmt.Sprintf("%s: %v", secretClass, secretErr),
|
||||
}
|
||||
}
|
||||
passwordBacked := sudoRemoteCommand(true, commandArgs)
|
||||
out, err = o.sshWithInput(ctx, node, passwordBacked, password+"\n", timeout)
|
||||
if err != nil {
|
||||
return out, classifyHostPrivilegeFailure(node, action, err, out)
|
||||
}
|
||||
o.logHostPrivilegedAction(node, action, "vault-backed-sudo")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hostPrivilegedCommandTimeout runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) hostPrivilegedCommandTimeout() time.Duration.
|
||||
// Why: every privileged host command must have a bounded default timeout even
|
||||
// when a focused unit test builds a partial config.
|
||||
func (o *Orchestrator) hostPrivilegedCommandTimeout() time.Duration {
|
||||
seconds := o.cfg.Startup.HostPrivilegedCommandTimeoutSec
|
||||
if seconds <= 0 {
|
||||
seconds = 90
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// logHostPrivilegedAction runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) logHostPrivilegedAction(node string, action hostPrivilegedAction, mode string).
|
||||
// Why: audit logs should record the action class and sudo mode while omitting
|
||||
// command stdin, passwords, and secret contents.
|
||||
func (o *Orchestrator) logHostPrivilegedAction(node string, action hostPrivilegedAction, mode string) {
|
||||
o.log.Printf("host privileged action completed node=%s action=%s mode=%s", node, action, mode)
|
||||
}
|
||||
|
||||
// hostPrivilegedCommandArgs runs one orchestration or CLI step.
|
||||
// Signature: hostPrivilegedCommandArgs(action hostPrivilegedAction, args ...string) ([]string, error).
|
||||
// Why: the Vault-backed sudo path must stay on a strict allowlist instead of
|
||||
// accepting arbitrary remote root commands.
|
||||
func hostPrivilegedCommandArgs(action hostPrivilegedAction, args ...string) ([]string, error) {
|
||||
switch action {
|
||||
case hostActionSudoPreflight:
|
||||
return []string{"/usr/bin/systemctl", "--version"}, nil
|
||||
case hostActionK3sAgentShow:
|
||||
return []string{"systemctl", "show", "k3s-agent", "--property=ActiveState,SubState,Result,MainPID", "--no-pager"}, nil
|
||||
case hostActionK3sAgentIsActive:
|
||||
return []string{"systemctl", "is-active", "k3s-agent"}, nil
|
||||
case hostActionK3sAgentRestart:
|
||||
return []string{"systemctl", "--no-block", "restart", "k3s-agent"}, nil
|
||||
case hostActionHostReboot:
|
||||
return []string{"systemctl", "reboot"}, nil
|
||||
case hostActionInstallCryptsetup:
|
||||
return []string{
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"sh",
|
||||
"-lc",
|
||||
"apt-get update && apt-get install -y --no-install-recommends cryptsetup-bin && (command -v cryptsetup >/dev/null 2>&1 || test -x /usr/sbin/cryptsetup || test -x /usr/bin/cryptsetup) && echo __ANANKE_CRYPTSETUP_INSTALLED__",
|
||||
}, nil
|
||||
case hostActionModprobeDMCrypt:
|
||||
return []string{"modprobe", "dm_crypt"}, nil
|
||||
case hostActionCrictlPods:
|
||||
return []string{"crictl", "pods", "-o", "json"}, nil
|
||||
case hostActionCrictlPs:
|
||||
return []string{"crictl", "ps", "-a", "-o", "json"}, nil
|
||||
case hostActionCrictlInspect:
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" || strings.ContainsAny(args[0], " \t\r\n;&|`$<>") {
|
||||
return nil, hostPrivilegeError{Class: "host-command-not-allowed", Action: action, Detail: "crictl inspect requires one safe container id"}
|
||||
}
|
||||
return []string{"crictl", "inspect", strings.TrimSpace(args[0])}, nil
|
||||
case hostActionCrictlStop:
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" || strings.ContainsAny(args[0], " \t\r\n;&|`$<>") {
|
||||
return nil, hostPrivilegeError{Class: "host-command-not-allowed", Action: action, Detail: "crictl stop requires one safe container id"}
|
||||
}
|
||||
return []string{"crictl", "stop", strings.TrimSpace(args[0])}, nil
|
||||
default:
|
||||
return nil, hostPrivilegeError{Class: "host-command-not-allowed", Action: action, Detail: "action is not allowlisted"}
|
||||
}
|
||||
}
|
||||
|
||||
// sudoRemoteCommand runs one orchestration or CLI step.
|
||||
// Signature: sudoRemoteCommand(passwordBacked bool, commandArgs []string) string.
|
||||
// Why: passwordless and password-backed sudo share one command builder so
|
||||
// quoting and prompt suppression stay consistent.
|
||||
func sudoRemoteCommand(passwordBacked bool, commandArgs []string) string {
|
||||
parts := []string{"sudo"}
|
||||
if passwordBacked {
|
||||
parts = append(parts, "-S", "-p", "")
|
||||
} else {
|
||||
parts = append(parts, "-n")
|
||||
}
|
||||
parts = append(parts, commandArgs...)
|
||||
return shellJoin(parts)
|
||||
}
|
||||
|
||||
// shellJoin runs one orchestration or CLI step.
|
||||
// Signature: shellJoin(parts []string) string.
|
||||
// Why: remote SSH commands need readable simple words and safe quoting for
|
||||
// shell-sensitive fragments such as package-manager scripts.
|
||||
func shellJoin(parts []string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, shellQuoteIfNeeded(part))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// shellQuoteIfNeeded runs one orchestration or CLI step.
|
||||
// Signature: shellQuoteIfNeeded(part string) string.
|
||||
// Why: audit-friendly commands should avoid unnecessary quotes while still
|
||||
// protecting whitespace, quotes, and metacharacters.
|
||||
func shellQuoteIfNeeded(part string) string {
|
||||
if part == "" {
|
||||
return shellQuote(part)
|
||||
}
|
||||
for _, r := range part {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '_', '-', '.', '/', '=', ':':
|
||||
continue
|
||||
default:
|
||||
return shellQuote(part)
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
// sudoMayNeedPassword runs one orchestration or CLI step.
|
||||
// Signature: sudoMayNeedPassword(out string, err error) bool.
|
||||
// Why: Ananke should try Vault-backed sudo only for sudo privilege failures, not
|
||||
// for unrelated SSH or command errors.
|
||||
func sudoMayNeedPassword(out string, err error) bool {
|
||||
full := strings.ToLower(strings.TrimSpace(out + " " + fmt.Sprint(err)))
|
||||
needles := []string{
|
||||
"a password is required",
|
||||
"password is required",
|
||||
"sudo: a terminal is required",
|
||||
"no tty present",
|
||||
"may not run sudo",
|
||||
"sudo denied",
|
||||
"is not in the sudoers file",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(full, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyHostPrivilegeFailure runs one orchestration or CLI step.
|
||||
// Signature: classifyHostPrivilegeFailure(node string, action hostPrivilegedAction, err error, out string) error.
|
||||
// Why: host repair needs distinct timeout, auth, SSH, and command-failure
|
||||
// classes so status output tells the operator what kind of help is needed.
|
||||
func classifyHostPrivilegeFailure(node string, action hostPrivilegedAction, err error, out string) error {
|
||||
full := strings.ToLower(strings.TrimSpace(out + " " + fmt.Sprint(err)))
|
||||
class := "host-command-failed"
|
||||
switch {
|
||||
case strings.Contains(full, "context deadline exceeded"), strings.Contains(full, "timed out"), strings.Contains(full, "timeout"):
|
||||
class = "host-command-timeout"
|
||||
case strings.Contains(full, "authentication failure"), strings.Contains(full, "incorrect password"), strings.Contains(full, "try again"):
|
||||
class = "host-privilege-auth-failed"
|
||||
case strings.Contains(full, "permission denied"), strings.Contains(full, "publickey"):
|
||||
class = "host-ssh-unavailable"
|
||||
}
|
||||
return hostPrivilegeError{Class: class, Node: node, Action: action, Detail: scrubHostPrivilegeDetail(out, err)}
|
||||
}
|
||||
|
||||
// scrubHostPrivilegeDetail runs one orchestration or CLI step.
|
||||
// Signature: scrubHostPrivilegeDetail(out string, err error) string.
|
||||
// Why: host command details should be compact enough for annotations and avoid
|
||||
// accidental multi-line or control-character log noise.
|
||||
func scrubHostPrivilegeDetail(out string, err error) string {
|
||||
detail := strings.TrimSpace(fmt.Sprint(err))
|
||||
if trimmed := strings.TrimSpace(out); trimmed != "" {
|
||||
if detail != "" {
|
||||
detail += ": "
|
||||
}
|
||||
detail += trimmed
|
||||
}
|
||||
return sanitizeCordonAnnotationValue(detail)
|
||||
}
|
||||
|
||||
// hostSudoPassword runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) hostSudoPassword(ctx context.Context, node string) (string, string, error).
|
||||
// Why: sudo material should come from a configured Vault-synced Kubernetes
|
||||
// Secret and never be logged or passed through ordinary command arguments.
|
||||
func (o *Orchestrator) hostSudoPassword(ctx context.Context, node string) (string, string, error) {
|
||||
namespace := strings.TrimSpace(o.cfg.Startup.HostSudoSecretNamespace)
|
||||
template := strings.TrimSpace(o.cfg.Startup.HostSudoSecretNameTemplate)
|
||||
key := strings.TrimSpace(o.cfg.Startup.HostSudoSecretPasswordKey)
|
||||
if key == "" {
|
||||
key = "password"
|
||||
}
|
||||
if namespace == "" || template == "" {
|
||||
return "", "secret-lookup-unconfigured", fmt.Errorf("host sudo secret namespace/template is not configured")
|
||||
}
|
||||
secretName := strings.ReplaceAll(template, "{node}", node)
|
||||
secretName = strings.TrimSpace(secretName)
|
||||
if secretName == "" {
|
||||
return "", "secret-lookup-invalid", fmt.Errorf("host sudo secret name resolved empty")
|
||||
}
|
||||
|
||||
out, err := o.kubectl(ctx, 15*time.Second, "-n", namespace, "get", "secret", secretName, "-o", "json")
|
||||
if err != nil {
|
||||
return "", "secret-lookup-failed", err
|
||||
}
|
||||
var secret hostSudoSecret
|
||||
if err := json.Unmarshal([]byte(out), &secret); err != nil {
|
||||
return "", "secret-decode-failed", err
|
||||
}
|
||||
encoded := strings.TrimSpace(secret.Data[key])
|
||||
if encoded == "" {
|
||||
keys := make([]string, 0, len(secret.Data))
|
||||
for k := range secret.Data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return "", "secret-key-missing", fmt.Errorf("password key %q missing (available=%s)", key, joinLimited(keys, 4))
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "secret-decode-failed", err
|
||||
}
|
||||
password := strings.TrimRight(string(decoded), "\r\n")
|
||||
if password == "" {
|
||||
return "", "secret-empty", fmt.Errorf("password key %q is empty", key)
|
||||
}
|
||||
return password, "secret-lookup-ok", nil
|
||||
}
|
||||
88
internal/cluster/orchestrator_image_pull_dns.go
Normal file
88
internal/cluster/orchestrator_image_pull_dns.go
Normal file
@ -0,0 +1,88 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// imagePullDNSBlockerReasons classifies image pull failures caused by DNS.
|
||||
// Signature: (o *Orchestrator) imagePullDNSBlockerReasons(ctx context.Context) (map[string]string, error).
|
||||
// Why: deleting an unchanged ImagePullBackOff pod does not repair registry DNS;
|
||||
// Ananke should report the blocker and wait for DNS/registry health to change.
|
||||
func (o *Orchestrator) imagePullDNSBlockerReasons(ctx context.Context) (map[string]string, error) {
|
||||
eventsOut, err := o.kubectl(ctx, 30*time.Second, "get", "events", "-A", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query events for image-pull DNS scan: %w", err)
|
||||
}
|
||||
reasons := map[string]string{}
|
||||
if strings.TrimSpace(eventsOut) == "" {
|
||||
return reasons, nil
|
||||
}
|
||||
var events eventList
|
||||
if err := json.Unmarshal([]byte(eventsOut), &events); err != nil {
|
||||
return nil, fmt.Errorf("decode events for image-pull DNS scan: %w", err)
|
||||
}
|
||||
for _, event := range events.Items {
|
||||
if !strings.EqualFold(strings.TrimSpace(event.Type), "Warning") {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(event.InvolvedObject.Kind), "Pod") {
|
||||
continue
|
||||
}
|
||||
reason := strings.TrimSpace(event.Reason)
|
||||
if reason != "Failed" && reason != "FailedPull" && reason != "ErrImagePull" && reason != "ImagePullBackOff" {
|
||||
continue
|
||||
}
|
||||
message := strings.TrimSpace(event.Message)
|
||||
if !imagePullMessageHasDNSFailure(message) {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(event.InvolvedObject.Namespace) + "/" + strings.TrimSpace(event.InvolvedObject.Name)
|
||||
if key == "/" {
|
||||
continue
|
||||
}
|
||||
reasons[key] = "ImagePullDNSBlocker:" + imagePullRegistryHost(message)
|
||||
}
|
||||
return reasons, nil
|
||||
}
|
||||
|
||||
// imagePullMessageHasDNSFailure runs one orchestration or CLI step.
|
||||
// Signature: imagePullMessageHasDNSFailure(message string) bool.
|
||||
// Why: image pull recycling should be suppressed only for resolver failures,
|
||||
// leaving ordinary pull/auth errors on their existing paths.
|
||||
func imagePullMessageHasDNSFailure(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
if !strings.Contains(lower, "lookup ") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(lower, "try again") ||
|
||||
strings.Contains(lower, "no such host") ||
|
||||
strings.Contains(lower, "server misbehaving") ||
|
||||
strings.Contains(lower, "temporary failure") ||
|
||||
strings.Contains(lower, "i/o timeout")
|
||||
}
|
||||
|
||||
// imagePullRegistryHost runs one orchestration or CLI step.
|
||||
// Signature: imagePullRegistryHost(message string) string.
|
||||
// Why: operator status should group DNS blockers by registry host instead of
|
||||
// repeating full kubelet event text for every pod.
|
||||
func imagePullRegistryHost(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
idx := strings.Index(lower, "lookup ")
|
||||
if idx < 0 {
|
||||
return "unknown-registry"
|
||||
}
|
||||
rest := strings.TrimSpace(message[idx+len("lookup "):])
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
return "unknown-registry"
|
||||
}
|
||||
host := strings.Trim(fields[0], `"'[]():,`)
|
||||
if host == "" {
|
||||
return "unknown-registry"
|
||||
}
|
||||
return host
|
||||
}
|
||||
265
internal/cluster/orchestrator_longhorn_reconcile.go
Normal file
265
internal/cluster/orchestrator_longhorn_reconcile.go
Normal file
@ -0,0 +1,265 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type longhornNodeList struct {
|
||||
Items []longhornNode `json:"items"`
|
||||
}
|
||||
|
||||
type longhornNode struct {
|
||||
Metadata struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
Spec struct {
|
||||
AllowScheduling *bool `json:"allowScheduling"`
|
||||
} `json:"spec"`
|
||||
Status struct {
|
||||
Conditions []longhornCondition `json:"conditions"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type longhornCondition struct {
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type daemonSetResource struct {
|
||||
Metadata struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
Spec struct {
|
||||
Selector struct {
|
||||
MatchLabels map[string]string `json:"matchLabels"`
|
||||
} `json:"selector"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
// reconcileLonghornKubernetesReadiness repairs safe Kubernetes/Longhorn drift.
|
||||
// Signature: (o *Orchestrator) reconcileLonghornKubernetesReadiness(ctx context.Context) (int, error).
|
||||
// Why: Kubernetes Ready=True is not enough for PVC scheduling when Longhorn's
|
||||
// manager, labels, and node conditions disagree with the Kubernetes node model.
|
||||
func (o *Orchestrator) reconcileLonghornKubernetesReadiness(ctx context.Context) (int, error) {
|
||||
longhornNodes, err := o.queryLonghornNodes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(longhornNodes.Items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
managerDS, err := o.queryLonghornManagerDaemonSet(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
selector := normalizeSelectorLabels(managerDS.Spec.Selector.MatchLabels)
|
||||
if len(selector) == 0 {
|
||||
return 0, fmt.Errorf("longhorn manager daemonset has no matchLabels selector")
|
||||
}
|
||||
nodes, err := o.queryReadyNodes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
kubeNodes := map[string]nodeReadyItem{}
|
||||
for _, node := range nodes.Items {
|
||||
name := strings.TrimSpace(node.Metadata.Name)
|
||||
if name != "" {
|
||||
kubeNodes[name] = node
|
||||
}
|
||||
}
|
||||
managerPods, err := o.longhornManagerPodsByNode(ctx, selector)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
repaired := 0
|
||||
errs := []string{}
|
||||
for _, lhNode := range longhornNodes.Items {
|
||||
name := strings.TrimSpace(lhNode.Metadata.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
kubeNode, ok := kubeNodes[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, hasManager := managerPods[name]; hasManager {
|
||||
continue
|
||||
}
|
||||
readyCond := longhornConditionByType(lhNode, "Ready")
|
||||
if readyCond == nil || strings.EqualFold(strings.TrimSpace(readyCond.Status), "True") {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(readyCond.Reason), "ManagerPodMissing") {
|
||||
continue
|
||||
}
|
||||
missing := missingSelectorLabels(kubeNode.Metadata.Labels, selector)
|
||||
if len(missing) == 0 {
|
||||
o.log.Printf("warning: Longhorn manager pod missing on node=%s but node already matches selector; leaving for DaemonSet scheduler reconciliation", name)
|
||||
continue
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(missing))
|
||||
for key, value := range missing {
|
||||
labels = append(labels, key+"="+value)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
args := append([]string{"label", "node", name, "--overwrite"}, labels...)
|
||||
o.log.Printf("warning: restoring Longhorn manager selector labels on node=%s labels=%s reason=LonghornNodeExistsAndManagerPodMissing", name, strings.Join(labels, ","))
|
||||
if _, err := o.kubectl(ctx, 25*time.Second, args...); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s restore Longhorn selector labels: %v", name, err))
|
||||
continue
|
||||
}
|
||||
repaired++
|
||||
o.noteStartupAutoHeal(fmt.Sprintf("restored Longhorn manager selector labels on %s: %s", name, strings.Join(labels, ",")))
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return repaired, errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
return repaired, nil
|
||||
}
|
||||
|
||||
// queryLonghornNodes runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) queryLonghornNodes(ctx context.Context) (longhornNodeList, error).
|
||||
// Why: Longhorn/Kubernetes drift reconciliation starts from Longhorn's own node
|
||||
// model rather than assuming Kubernetes Ready is sufficient for storage.
|
||||
func (o *Orchestrator) queryLonghornNodes(ctx context.Context) (longhornNodeList, error) {
|
||||
out, err := o.kubectl(ctx, 30*time.Second, "-n", "longhorn-system", "get", "nodes.longhorn.io", "-o", "json")
|
||||
if err != nil {
|
||||
if isNotFoundErr(err) {
|
||||
return longhornNodeList{}, nil
|
||||
}
|
||||
return longhornNodeList{}, fmt.Errorf("query longhorn nodes: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return longhornNodeList{}, nil
|
||||
}
|
||||
var nodes longhornNodeList
|
||||
if err := json.Unmarshal([]byte(out), &nodes); err != nil {
|
||||
return longhornNodeList{}, fmt.Errorf("decode longhorn nodes: %w", err)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// queryLonghornManagerDaemonSet runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) queryLonghornManagerDaemonSet(ctx context.Context) (daemonSetResource, error).
|
||||
// Why: manager selector labels are installation policy, so repair must read
|
||||
// the live DaemonSet selector instead of hard-coding a Titan label.
|
||||
func (o *Orchestrator) queryLonghornManagerDaemonSet(ctx context.Context) (daemonSetResource, error) {
|
||||
out, err := o.kubectl(ctx, 20*time.Second, "-n", "longhorn-system", "get", "daemonset", "longhorn-manager", "-o", "json")
|
||||
if err != nil {
|
||||
return daemonSetResource{}, fmt.Errorf("query longhorn manager daemonset: %w", err)
|
||||
}
|
||||
var ds daemonSetResource
|
||||
if err := json.Unmarshal([]byte(out), &ds); err != nil {
|
||||
return daemonSetResource{}, fmt.Errorf("decode longhorn manager daemonset: %w", err)
|
||||
}
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// longhornManagerPodsByNode runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) longhornManagerPodsByNode(ctx context.Context, selector map[string]string) (map[string]struct{}, error).
|
||||
// Why: manager-pod presence is the postcondition for selector repair and
|
||||
// distinguishes label drift from a healthy Longhorn node.
|
||||
func (o *Orchestrator) longhornManagerPodsByNode(ctx context.Context, selector map[string]string) (map[string]struct{}, error) {
|
||||
args := []string{"-n", "longhorn-system", "get", "pods", "-o", "json"}
|
||||
if len(selector) > 0 {
|
||||
args = append(args, "-l", selectorLabelString(selector))
|
||||
}
|
||||
out, err := o.kubectl(ctx, 20*time.Second, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query longhorn manager pods: %w", err)
|
||||
}
|
||||
var pods podList
|
||||
if err := json.Unmarshal([]byte(out), &pods); err != nil {
|
||||
return nil, fmt.Errorf("decode longhorn manager pods: %w", err)
|
||||
}
|
||||
byNode := map[string]struct{}{}
|
||||
for _, pod := range pods.Items {
|
||||
node := strings.TrimSpace(pod.Spec.NodeName)
|
||||
if node == "" {
|
||||
continue
|
||||
}
|
||||
if podMatchesLabels(pod.Metadata.Labels, selector) {
|
||||
byNode[node] = struct{}{}
|
||||
}
|
||||
}
|
||||
return byNode, nil
|
||||
}
|
||||
|
||||
// normalizeSelectorLabels runs one orchestration or CLI step.
|
||||
// Signature: normalizeSelectorLabels(labels map[string]string) map[string]string.
|
||||
// Why: empty selector fragments should not produce accidental node label writes.
|
||||
func normalizeSelectorLabels(labels map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, value := range labels {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectorLabelString runs one orchestration or CLI step.
|
||||
// Signature: selectorLabelString(labels map[string]string) string.
|
||||
// Why: kubectl label selectors need stable ordering for deterministic tests and
|
||||
// readable logs.
|
||||
func selectorLabelString(labels map[string]string) string {
|
||||
parts := make([]string, 0, len(labels))
|
||||
for key, value := range labels {
|
||||
parts = append(parts, key+"="+value)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// podMatchesLabels runs one orchestration or CLI step.
|
||||
// Signature: podMatchesLabels(labels map[string]string, selector map[string]string) bool.
|
||||
// Why: Longhorn manager pod checks should use the DaemonSet selector rather
|
||||
// than assuming a specific app label.
|
||||
func podMatchesLabels(labels map[string]string, selector map[string]string) bool {
|
||||
for key, value := range selector {
|
||||
if labels == nil || strings.TrimSpace(labels[key]) != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// missingSelectorLabels runs one orchestration or CLI step.
|
||||
// Signature: missingSelectorLabels(labels map[string]string, selector map[string]string) map[string]string.
|
||||
// Why: label-drift repair should write exactly the manager selector labels that
|
||||
// are absent or wrong on the node.
|
||||
func missingSelectorLabels(labels map[string]string, selector map[string]string) map[string]string {
|
||||
missing := map[string]string{}
|
||||
for key, value := range selector {
|
||||
if labels == nil || strings.TrimSpace(labels[key]) != value {
|
||||
missing[key] = value
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// longhornConditionByType runs one orchestration or CLI step.
|
||||
// Signature: longhornConditionByType(node longhornNode, condType string) *longhornCondition.
|
||||
// Why: Longhorn node condition parsing is shared by readiness and schedulability
|
||||
// checks and should remain case-insensitive.
|
||||
func longhornConditionByType(node longhornNode, condType string) *longhornCondition {
|
||||
for i := range node.Status.Conditions {
|
||||
if strings.EqualFold(strings.TrimSpace(node.Status.Conditions[i].Type), condType) {
|
||||
return &node.Status.Conditions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -104,25 +104,37 @@ func (o *Orchestrator) repairEncryptedVolumeMountPrereqs(ctx context.Context, po
|
||||
// Why: kubelet's encrypted Longhorn mount helper shells into the host namespace,
|
||||
// so the package must exist on the node host, not merely inside a workload pod.
|
||||
func (o *Orchestrator) ensureHostCryptsetup(ctx context.Context, node string) error {
|
||||
command := strings.Join([]string{
|
||||
checkCommand := strings.Join([]string{
|
||||
"set -eu",
|
||||
"if command -v cryptsetup >/dev/null 2>&1 || [ -x /usr/sbin/cryptsetup ] || [ -x /usr/bin/cryptsetup ]; then echo __ANANKE_CRYPTSETUP_PRESENT__; exit 0; fi",
|
||||
"if ! command -v apt-get >/dev/null 2>&1; then echo __ANANKE_CRYPTSETUP_NO_APT__; exit 42; fi",
|
||||
"sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update",
|
||||
"sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cryptsetup-bin",
|
||||
"if command -v cryptsetup >/dev/null 2>&1 || [ -x /usr/sbin/cryptsetup ] || [ -x /usr/bin/cryptsetup ]; then echo __ANANKE_CRYPTSETUP_INSTALLED__; exit 0; fi",
|
||||
"echo __ANANKE_CRYPTSETUP_INSTALL_FAILED__",
|
||||
"exit 43",
|
||||
"echo __ANANKE_CRYPTSETUP_MISSING__",
|
||||
"exit 41",
|
||||
}, "; ")
|
||||
out, err := o.sshWithTimeout(ctx, node, command, 5*time.Minute)
|
||||
out, err := o.sshWithTimeout(ctx, node, checkCommand, 25*time.Second)
|
||||
if err == nil && strings.Contains(out, "__ANANKE_CRYPTSETUP_PRESENT__") {
|
||||
o.log.Printf("ensured cryptsetup prerequisite on %s: __ANANKE_CRYPTSETUP_PRESENT__", node)
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(out, "__ANANKE_CRYPTSETUP_NO_APT__") {
|
||||
return fmt.Errorf("install cryptsetup-bin: apt-get is unavailable on host")
|
||||
}
|
||||
|
||||
out, err = o.runHostPrivilegedAction(ctx, node, hostActionInstallCryptsetup, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("install cryptsetup-bin: %w (output=%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
trimmed := strings.TrimSpace(out)
|
||||
o.log.Printf("ensured cryptsetup prerequisite on %s: %s", node, trimmed)
|
||||
if strings.Contains(trimmed, "__ANANKE_CRYPTSETUP_INSTALLED__") {
|
||||
if strings.Contains(out, "__ANANKE_CRYPTSETUP_INSTALLED__") {
|
||||
o.log.Printf("ensured cryptsetup prerequisite on %s: __ANANKE_CRYPTSETUP_INSTALLED__", node)
|
||||
o.noteStartupAutoHeal(fmt.Sprintf("installed cryptsetup on %s", node))
|
||||
return nil
|
||||
}
|
||||
verifyOut, verifyErr := o.sshWithTimeout(ctx, node, checkCommand, 25*time.Second)
|
||||
if verifyErr != nil || !strings.Contains(verifyOut, "__ANANKE_CRYPTSETUP_PRESENT__") {
|
||||
return fmt.Errorf("cryptsetup-bin install completed but verification failed: %w (output=%s)", verifyErr, strings.TrimSpace(verifyOut))
|
||||
}
|
||||
o.log.Printf("ensured cryptsetup prerequisite on %s: __ANANKE_CRYPTSETUP_INSTALLED__", node)
|
||||
o.noteStartupAutoHeal(fmt.Sprintf("installed cryptsetup on %s", node))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
162
internal/cluster/orchestrator_node_runtime_recovery.go
Normal file
162
internal/cluster/orchestrator_node_runtime_recovery.go
Normal file
@ -0,0 +1,162 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recoverManagedNodeRuntime runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) recoverManagedNodeRuntime(ctx context.Context, node string, alreadyUnschedulable bool, reason string) error.
|
||||
// Why: kubelet/containerd wedges need one bounded escalation ladder: cordon,
|
||||
// non-blocking k3s-agent restart, health proof, and optional controlled reboot.
|
||||
func (o *Orchestrator) recoverManagedNodeRuntime(ctx context.Context, node string, alreadyUnschedulable bool, reason string) error {
|
||||
node = strings.TrimSpace(node)
|
||||
if node == "" {
|
||||
return fmt.Errorf("node is empty")
|
||||
}
|
||||
if !alreadyUnschedulable {
|
||||
if err := o.cordonNodeWithLease(ctx, node, cordonReasonKubeletProxy, reason); err != nil {
|
||||
return fmt.Errorf("cordon before kubelet restart: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
o.log.Printf("warning: detected node runtime repair signal on %s; issuing non-blocking k3s-agent restart reason=%s", node, sanitizeCordonAnnotationValue(reason))
|
||||
if _, err := o.runHostPrivilegedAction(ctx, node, hostActionK3sAgentRestart, 30*time.Second); err != nil {
|
||||
if !alreadyUnschedulable {
|
||||
o.bestEffort("uncordon node after failed kubelet proxy repair", func() error {
|
||||
return o.uncordonAndClearCordonLease(ctx, node, cordonReasonKubeletProxy)
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("restart k3s-agent: %w", err)
|
||||
}
|
||||
|
||||
readyErr := o.waitForManagedNodeRuntimeReady(ctx, node, o.nodeRuntimeRestartWait())
|
||||
if readyErr == nil {
|
||||
return o.releaseRuntimeRecoveryCordon(ctx, node, alreadyUnschedulable)
|
||||
}
|
||||
|
||||
showOut, showErr := o.runHostPrivilegedAction(ctx, node, hostActionK3sAgentShow, 20*time.Second)
|
||||
if showErr != nil {
|
||||
return fmt.Errorf("%v; k3s-agent state check failed: %w", readyErr, showErr)
|
||||
}
|
||||
if !k3sAgentStateRequiresReboot(showOut) {
|
||||
return readyErr
|
||||
}
|
||||
if !o.cfg.Startup.HostRepairAllowReboot {
|
||||
return fmt.Errorf("node runtime requires controlled reboot but host_repair_allow_reboot is false; k3s-agent state=%s", summarizeSystemctlShow(showOut))
|
||||
}
|
||||
|
||||
o.log.Printf("warning: escalating node runtime recovery to controlled reboot node=%s state=%s", node, summarizeSystemctlShow(showOut))
|
||||
if _, err := o.runHostPrivilegedAction(ctx, node, hostActionHostReboot, 30*time.Second); err != nil {
|
||||
return fmt.Errorf("controlled reboot command failed: %w", err)
|
||||
}
|
||||
if err := o.waitForManagedNodeRuntimeReady(ctx, node, o.nodeRuntimeRebootWait()); err != nil {
|
||||
return fmt.Errorf("node did not recover after controlled reboot: %w", err)
|
||||
}
|
||||
return o.releaseRuntimeRecoveryCordon(ctx, node, alreadyUnschedulable)
|
||||
}
|
||||
|
||||
// releaseRuntimeRecoveryCordon runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) releaseRuntimeRecoveryCordon(ctx context.Context, node string, alreadyUnschedulable bool) error.
|
||||
// Why: nodes Ananke cordoned for repair should return to service after proof of
|
||||
// recovery, while pre-existing cordons remain under their original owner.
|
||||
func (o *Orchestrator) releaseRuntimeRecoveryCordon(ctx context.Context, node string, alreadyUnschedulable bool) error {
|
||||
if alreadyUnschedulable {
|
||||
return nil
|
||||
}
|
||||
if err := o.uncordonAndClearCordonLease(ctx, node, cordonReasonKubeletProxy); err != nil {
|
||||
return fmt.Errorf("uncordon after kubelet proxy repair: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeRuntimeRestartWait runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) nodeRuntimeRestartWait() time.Duration.
|
||||
// Why: k3s-agent restart recovery needs a bounded wait even when tests build a
|
||||
// partial startup config.
|
||||
func (o *Orchestrator) nodeRuntimeRestartWait() time.Duration {
|
||||
seconds := o.cfg.Startup.NodeRuntimeRestartWaitSeconds
|
||||
if seconds <= 0 {
|
||||
seconds = 140
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// nodeRuntimeRebootWait runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) nodeRuntimeRebootWait() time.Duration.
|
||||
// Why: controlled reboot recovery has a longer but still finite postcondition
|
||||
// window for SSH, systemd, kubelet, and Kubernetes readiness.
|
||||
func (o *Orchestrator) nodeRuntimeRebootWait() time.Duration {
|
||||
seconds := o.cfg.Startup.NodeRuntimeRebootWaitSeconds
|
||||
if seconds <= 0 {
|
||||
seconds = 420
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// waitForManagedNodeRuntimeReady runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) waitForManagedNodeRuntimeReady(ctx context.Context, node string, wait time.Duration) error.
|
||||
// Why: runtime repair is not complete until Kubernetes Ready, kubelet proxy, and
|
||||
// k3s-agent service state all agree the node is usable.
|
||||
func (o *Orchestrator) waitForManagedNodeRuntimeReady(ctx context.Context, node string, wait time.Duration) error {
|
||||
if wait <= 0 {
|
||||
wait = 140 * time.Second
|
||||
}
|
||||
kubeWait := wait - 20*time.Second
|
||||
if kubeWait < 10*time.Second {
|
||||
kubeWait = 10 * time.Second
|
||||
}
|
||||
if _, err := o.kubectl(ctx, wait, "wait", "node/"+node, "--for=condition=Ready", "--timeout="+fmt.Sprintf("%.0fs", kubeWait.Seconds())); err != nil {
|
||||
return fmt.Errorf("wait Ready after k3s-agent restart: %w", err)
|
||||
}
|
||||
healthy, err := o.kubeletProxyHealthy(ctx, node)
|
||||
if !healthy {
|
||||
return fmt.Errorf("proxy still broken after k3s-agent restart: %v", err)
|
||||
}
|
||||
out, err := o.runHostPrivilegedAction(ctx, node, hostActionK3sAgentIsActive, 12*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("k3s-agent active check: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "active" {
|
||||
return fmt.Errorf("k3s-agent is-active returned %q", strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// k3sAgentStateRequiresReboot runs one orchestration or CLI step.
|
||||
// Signature: k3sAgentStateRequiresReboot(out string) bool.
|
||||
// Why: only the stuck systemd states observed during hard runtime wedges should
|
||||
// escalate from non-blocking restart to controlled reboot.
|
||||
func k3sAgentStateRequiresReboot(out string) bool {
|
||||
lower := strings.ToLower(out)
|
||||
needles := []string{
|
||||
"activestate=deactivating",
|
||||
"activestate=activating",
|
||||
"substate=stop-sigterm",
|
||||
"substate=stop-sigkill",
|
||||
"substate=final-sigterm",
|
||||
"substate=final-sigkill",
|
||||
"substate=start",
|
||||
"result=timeout",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// summarizeSystemctlShow runs one orchestration or CLI step.
|
||||
// Signature: summarizeSystemctlShow(out string) string.
|
||||
// Why: operator-facing runtime incidents need compact systemd state summaries
|
||||
// rather than full multi-line command output.
|
||||
func summarizeSystemctlShow(out string) string {
|
||||
lines := lines(out)
|
||||
if len(lines) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
return sanitizeCordonAnnotationValue(joinLimited(lines, 4))
|
||||
}
|
||||
@ -187,6 +187,81 @@ func (o *Orchestrator) ssh(ctx context.Context, node string, command string) (st
|
||||
// Signature: (o *Orchestrator) sshWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error).
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
func (o *Orchestrator) sshWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error) {
|
||||
attempts, attemptNames, knownHostsFiles, repairHosts := o.sshCommandAttempts(node, command)
|
||||
|
||||
var lastOut string
|
||||
var lastErr error
|
||||
for i, args := range attempts {
|
||||
out, err := o.run(ctx, timeout, "ssh", args...)
|
||||
if err == nil {
|
||||
if i > 0 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s, using %s path", attemptNames[i-1], node, attemptNames[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if sshutil.ShouldAttemptKnownHostsRepair(out, err) {
|
||||
o.log.Printf("warning: ssh failure on %s via %s path may be host-key related; repairing known_hosts and retrying once", node, attemptNames[i])
|
||||
sshutil.RepairKnownHosts(ctx, o.log, knownHostsFiles, repairHosts, o.cfg.SSHPort)
|
||||
retryOut, retryErr := o.run(ctx, timeout, "ssh", args...)
|
||||
if retryErr == nil {
|
||||
return retryOut, nil
|
||||
}
|
||||
out = retryOut
|
||||
err = retryErr
|
||||
}
|
||||
lastOut = out
|
||||
lastErr = err
|
||||
if i < len(attempts)-1 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s: %v; trying %s path", attemptNames[i], node, err, attemptNames[i+1])
|
||||
}
|
||||
}
|
||||
return lastOut, lastErr
|
||||
}
|
||||
|
||||
// sshWithInput runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) sshWithInput(ctx context.Context, node string, command string, input string, timeout time.Duration) (string, error).
|
||||
// Why: Vault-backed sudo must send passwords over stdin, not in the SSH command
|
||||
// line or logs, while still using Ananke's configured node route.
|
||||
func (o *Orchestrator) sshWithInput(ctx context.Context, node string, command string, input string, timeout time.Duration) (string, error) {
|
||||
if o.sshInputOverride != nil {
|
||||
return o.sshInputOverride(ctx, timeout, node, command, input)
|
||||
}
|
||||
attempts, attemptNames, knownHostsFiles, repairHosts := o.sshCommandAttempts(node, command)
|
||||
|
||||
var lastOut string
|
||||
var lastErr error
|
||||
for i, args := range attempts {
|
||||
out, err := o.runSensitiveWithInput(ctx, timeout, input, "ssh", args...)
|
||||
if err == nil {
|
||||
if i > 0 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s, using %s path", attemptNames[i-1], node, attemptNames[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if sshutil.ShouldAttemptKnownHostsRepair(out, err) {
|
||||
o.log.Printf("warning: ssh failure on %s via %s path may be host-key related; repairing known_hosts and retrying once", node, attemptNames[i])
|
||||
sshutil.RepairKnownHosts(ctx, o.log, knownHostsFiles, repairHosts, o.cfg.SSHPort)
|
||||
retryOut, retryErr := o.runSensitiveWithInput(ctx, timeout, input, "ssh", args...)
|
||||
if retryErr == nil {
|
||||
return retryOut, nil
|
||||
}
|
||||
out = retryOut
|
||||
err = retryErr
|
||||
}
|
||||
lastOut = out
|
||||
lastErr = err
|
||||
if i < len(attempts)-1 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s: %v; trying %s path", attemptNames[i], node, err, attemptNames[i+1])
|
||||
}
|
||||
}
|
||||
return lastOut, lastErr
|
||||
}
|
||||
|
||||
// sshCommandAttempts runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) sshCommandAttempts(node string, command string) ([][]string, []string, []string, []string).
|
||||
// Why: ordinary SSH and stdin-backed SSH must use identical host, identity,
|
||||
// port, jump-host, and known-host repair behavior.
|
||||
func (o *Orchestrator) sshCommandAttempts(node string, command string) ([][]string, []string, []string, []string) {
|
||||
host := node
|
||||
if mapped, ok := o.cfg.SSHNodeHosts[node]; ok && strings.TrimSpace(mapped) != "" {
|
||||
host = strings.TrimSpace(mapped)
|
||||
@ -240,34 +315,7 @@ func (o *Orchestrator) sshWithTimeout(ctx context.Context, node string, command
|
||||
direct = append(direct, target, command)
|
||||
attempts = append(attempts, direct)
|
||||
attemptNames = append(attemptNames, "direct")
|
||||
|
||||
var lastOut string
|
||||
var lastErr error
|
||||
for i, args := range attempts {
|
||||
out, err := o.run(ctx, timeout, "ssh", args...)
|
||||
if err == nil {
|
||||
if i > 0 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s, using %s path", attemptNames[i-1], node, attemptNames[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if sshutil.ShouldAttemptKnownHostsRepair(out, err) {
|
||||
o.log.Printf("warning: ssh failure on %s via %s path may be host-key related; repairing known_hosts and retrying once", node, attemptNames[i])
|
||||
sshutil.RepairKnownHosts(ctx, o.log, knownHostsFiles, repairHosts, o.cfg.SSHPort)
|
||||
retryOut, retryErr := o.run(ctx, timeout, "ssh", args...)
|
||||
if retryErr == nil {
|
||||
return retryOut, nil
|
||||
}
|
||||
out = retryOut
|
||||
err = retryErr
|
||||
}
|
||||
lastOut = out
|
||||
lastErr = err
|
||||
if i < len(attempts)-1 {
|
||||
o.log.Printf("warning: ssh %s path failed for %s: %v; trying %s path", attemptNames[i], node, err, attemptNames[i+1])
|
||||
}
|
||||
}
|
||||
return lastOut, lastErr
|
||||
return attempts, attemptNames, knownHostsFiles, repairHosts
|
||||
}
|
||||
|
||||
// run runs one orchestration or CLI step.
|
||||
@ -308,6 +356,31 @@ func (o *Orchestrator) runSensitive(ctx context.Context, timeout time.Duration,
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// runSensitiveWithInput runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) runSensitiveWithInput(ctx context.Context, timeout time.Duration, input string, name string, args ...string) (string, error).
|
||||
// Why: sudo passwords and similar secret stdin must not be passed through
|
||||
// ordinary command arguments or dry-run logging.
|
||||
func (o *Orchestrator) runSensitiveWithInput(ctx context.Context, timeout time.Duration, input string, name string, args ...string) (string, error) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, name, args...)
|
||||
cmd.Env = os.Environ()
|
||||
if o.runner.Kubeconfig != "" {
|
||||
cmd.Env = append(cmd.Env, "KUBECONFIG="+o.runner.Kubeconfig)
|
||||
}
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
out, err := cmd.CombinedOutput()
|
||||
trimmed := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%s failed: %w", name, err)
|
||||
}
|
||||
return trimmed, fmt.Errorf("%s failed: %w", name, err)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// lines runs one orchestration or CLI step.
|
||||
// Signature: lines(in string) []string.
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
|
||||
@ -99,6 +99,7 @@ func (o *Orchestrator) waitForServiceChecklist(ctx context.Context) error {
|
||||
lastRecycleAttempt := time.Time{}
|
||||
lastReplicaHeal := time.Time{}
|
||||
lastIngressHeal := time.Time{}
|
||||
lastTCPHeal := time.Time{}
|
||||
for {
|
||||
o.maybeAutoRecycleStuckPods(ctx, &lastRecycleAttempt)
|
||||
o.maybeAutoHealCriticalWorkloadReplicas(ctx, &lastReplicaHeal)
|
||||
@ -110,6 +111,7 @@ func (o *Orchestrator) waitForServiceChecklist(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
o.maybeAutoHealIngressHostBackends(ctx, &lastIngressHeal, lastFailure)
|
||||
o.maybeAutoHealTCPServiceBackends(ctx, &lastTCPHeal)
|
||||
if lastFailure != prevFailure || time.Since(lastLogged) >= 30*time.Second {
|
||||
remaining := time.Until(deadline).Round(time.Second)
|
||||
if remaining < 0 {
|
||||
@ -134,7 +136,7 @@ func (o *Orchestrator) waitForServiceChecklist(ctx context.Context) error {
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
func (o *Orchestrator) serviceChecklistReady(ctx context.Context) (bool, string) {
|
||||
checks := o.cfg.Startup.ServiceChecklist
|
||||
if len(checks) == 0 {
|
||||
if len(checks) == 0 && len(o.cfg.Startup.TCPServiceChecklist) == 0 {
|
||||
return true, "no checklist items configured"
|
||||
}
|
||||
for _, check := range checks {
|
||||
@ -147,7 +149,10 @@ func (o *Orchestrator) serviceChecklistReady(ctx context.Context) (bool, string)
|
||||
return false, fmt.Sprintf("%s: %s", name, detail)
|
||||
}
|
||||
}
|
||||
return true, fmt.Sprintf("checks=%d", len(checks))
|
||||
if ok, detail := o.tcpServiceChecklistReady(ctx); !ok {
|
||||
return false, "tcp " + detail
|
||||
}
|
||||
return true, fmt.Sprintf("checks=%d tcp-checks=%d", len(checks), len(o.cfg.Startup.TCPServiceChecklist))
|
||||
}
|
||||
|
||||
// serviceCheckReady runs one orchestration or CLI step.
|
||||
|
||||
319
internal/cluster/orchestrator_stale_rwo_owner.go
Normal file
319
internal/cluster/orchestrator_stale_rwo_owner.go
Normal file
@ -0,0 +1,319 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type staleRWOOwnerDecision struct {
|
||||
Reason string
|
||||
ForceDelete bool
|
||||
Unsafe bool
|
||||
}
|
||||
|
||||
type persistentVolumeClaimList struct {
|
||||
Items []persistentVolumeClaimResource `json:"items"`
|
||||
}
|
||||
|
||||
type persistentVolumeClaimResource struct {
|
||||
Metadata struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
Spec struct {
|
||||
AccessModes []string `json:"accessModes"`
|
||||
VolumeName string `json:"volumeName"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
// staleRWOPVCOwnerDecisions finds stale terminating single-writer pods safely.
|
||||
// Signature: (o *Orchestrator) staleRWOPVCOwnerDecisions(ctx context.Context, pods podList, grace time.Duration) (map[string]staleRWOOwnerDecision, error).
|
||||
// Why: RWO volume handoff needs container/mount-level safety, not blind repeated
|
||||
// pod deletion, so sidecar-only stale owners can clear while live writers block.
|
||||
func (o *Orchestrator) staleRWOPVCOwnerDecisions(ctx context.Context, pods podList, grace time.Duration) (map[string]staleRWOOwnerDecision, error) {
|
||||
eventsOut, err := o.kubectl(ctx, 30*time.Second, "get", "events", "-A", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query events for stale RWO owner scan: %w", err)
|
||||
}
|
||||
var events eventList
|
||||
if strings.TrimSpace(eventsOut) != "" {
|
||||
if err := json.Unmarshal([]byte(eventsOut), &events); err != nil {
|
||||
return nil, fmt.Errorf("decode events for stale RWO owner scan: %w", err)
|
||||
}
|
||||
}
|
||||
pvcs, err := o.queryPVCs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
podsByController := map[string][]podResource{}
|
||||
for _, pod := range pods.Items {
|
||||
controllerKey := podControllerKey(pod)
|
||||
if controllerKey == "" {
|
||||
continue
|
||||
}
|
||||
podsByController[controllerKey] = append(podsByController[controllerKey], pod)
|
||||
}
|
||||
|
||||
decisions := map[string]staleRWOOwnerDecision{}
|
||||
for _, oldPod := range pods.Items {
|
||||
oldKey := podKey(oldPod)
|
||||
if oldKey == "" || oldPod.Metadata.DeletionTimestamp == nil || !podControllerOwned(oldPod) {
|
||||
continue
|
||||
}
|
||||
if now.Sub(*oldPod.Metadata.DeletionTimestamp) < grace {
|
||||
continue
|
||||
}
|
||||
oldNode := strings.TrimSpace(oldPod.Spec.NodeName)
|
||||
if oldNode == "" {
|
||||
continue
|
||||
}
|
||||
blockedClaims := podRWOPVCVolumeNames(oldPod, pvcs)
|
||||
if len(blockedClaims) == 0 {
|
||||
continue
|
||||
}
|
||||
controllerKey := podControllerKey(oldPod)
|
||||
replacements := podsByController[controllerKey]
|
||||
replacementKey := ""
|
||||
for _, replacement := range replacements {
|
||||
if podKey(replacement) == oldKey {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(replacement.Spec.NodeName) == "" || strings.TrimSpace(replacement.Spec.NodeName) == oldNode {
|
||||
continue
|
||||
}
|
||||
if !replacementPodPendingForAttach(replacement) {
|
||||
continue
|
||||
}
|
||||
if !podHasBlockedAttachEvent(replacement, events) {
|
||||
continue
|
||||
}
|
||||
if !podsShareAnyClaim(oldPod, replacement, blockedClaims) {
|
||||
continue
|
||||
}
|
||||
replacementKey = podKey(replacement)
|
||||
break
|
||||
}
|
||||
if replacementKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
unsafe := runningContainersMountAnyVolume(oldPod, blockedClaims)
|
||||
claims := make([]string, 0, len(blockedClaims))
|
||||
for _, claim := range blockedClaims {
|
||||
claims = append(claims, claim)
|
||||
}
|
||||
sort.Strings(claims)
|
||||
if unsafe {
|
||||
decisions[oldKey] = staleRWOOwnerDecision{
|
||||
Reason: fmt.Sprintf("UnsafeStaleRWOPVCOwner:%s:%s", oldNode, strings.Join(claims, ",")),
|
||||
Unsafe: true,
|
||||
}
|
||||
continue
|
||||
}
|
||||
decisions[oldKey] = staleRWOOwnerDecision{
|
||||
Reason: fmt.Sprintf("SidecarOnlyStaleRWOPVCOwner:%s:%s->%s", oldNode, strings.Join(claims, ","), replacementKey),
|
||||
ForceDelete: true,
|
||||
}
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// queryPVCs runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) queryPVCs(ctx context.Context) (map[string]persistentVolumeClaimResource, error).
|
||||
// Why: stale-owner recovery must confirm single-writer PVC semantics before it
|
||||
// considers force-deleting a terminating pod object.
|
||||
func (o *Orchestrator) queryPVCs(ctx context.Context) (map[string]persistentVolumeClaimResource, error) {
|
||||
out, err := o.kubectl(ctx, 30*time.Second, "get", "pvc", "-A", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query pvcs for stale RWO owner scan: %w", err)
|
||||
}
|
||||
pvcs := map[string]persistentVolumeClaimResource{}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return pvcs, nil
|
||||
}
|
||||
var list persistentVolumeClaimList
|
||||
if err := json.Unmarshal([]byte(out), &list); err != nil {
|
||||
return nil, fmt.Errorf("decode pvcs for stale RWO owner scan: %w", err)
|
||||
}
|
||||
for _, pvc := range list.Items {
|
||||
key := strings.TrimSpace(pvc.Metadata.Namespace) + "/" + strings.TrimSpace(pvc.Metadata.Name)
|
||||
if strings.TrimSpace(pvc.Metadata.Namespace) != "" && strings.TrimSpace(pvc.Metadata.Name) != "" {
|
||||
pvcs[key] = pvc
|
||||
}
|
||||
}
|
||||
return pvcs, nil
|
||||
}
|
||||
|
||||
// podRWOPVCVolumeNames runs one orchestration or CLI step.
|
||||
// Signature: podRWOPVCVolumeNames(pod podResource, pvcs map[string]persistentVolumeClaimResource) map[string]string.
|
||||
// Why: container mount safety checks need the pod volume names that correspond
|
||||
// to ReadWriteOnce PVC claims.
|
||||
func podRWOPVCVolumeNames(pod podResource, pvcs map[string]persistentVolumeClaimResource) map[string]string {
|
||||
volumes := map[string]string{}
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.PersistentVolumeClaim == nil {
|
||||
continue
|
||||
}
|
||||
claim := strings.TrimSpace(volume.PersistentVolumeClaim.ClaimName)
|
||||
if claim == "" {
|
||||
continue
|
||||
}
|
||||
pvc, ok := pvcs[strings.TrimSpace(pod.Metadata.Namespace)+"/"+claim]
|
||||
if !ok || !pvcSingleWriter(pvc) {
|
||||
continue
|
||||
}
|
||||
volumes[strings.TrimSpace(volume.Name)] = claim
|
||||
}
|
||||
return volumes
|
||||
}
|
||||
|
||||
// pvcSingleWriter runs one orchestration or CLI step.
|
||||
// Signature: pvcSingleWriter(pvc persistentVolumeClaimResource) bool.
|
||||
// Why: the stale-owner path is limited to exclusive-writer PVC modes and should
|
||||
// leave shared volumes to normal Kubernetes cleanup.
|
||||
func pvcSingleWriter(pvc persistentVolumeClaimResource) bool {
|
||||
for _, mode := range pvc.Spec.AccessModes {
|
||||
normalized := strings.ToLower(strings.TrimSpace(mode))
|
||||
if normalized == "readwriteonce" || normalized == "readwriteoncepod" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// podControllerKey runs one orchestration or CLI step.
|
||||
// Signature: podControllerKey(pod podResource) string.
|
||||
// Why: stale owner and replacement pods should be grouped by controller without
|
||||
// hard-coding ReplicaSet, StatefulSet, or application names.
|
||||
func podControllerKey(pod podResource) string {
|
||||
ns := strings.TrimSpace(pod.Metadata.Namespace)
|
||||
for _, owner := range pod.Metadata.OwnerReferences {
|
||||
kind := strings.TrimSpace(owner.Kind)
|
||||
name := strings.TrimSpace(owner.Name)
|
||||
if ns != "" && kind != "" && name != "" {
|
||||
return ns + "/" + kind + "/" + name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// podKey runs one orchestration or CLI step.
|
||||
// Signature: podKey(pod podResource) string.
|
||||
// Why: recovery incidents need stable namespace/name keys for pod maps and
|
||||
// operator summaries.
|
||||
func podKey(pod podResource) string {
|
||||
ns := strings.TrimSpace(pod.Metadata.Namespace)
|
||||
name := strings.TrimSpace(pod.Metadata.Name)
|
||||
if ns == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
return ns + "/" + name
|
||||
}
|
||||
|
||||
// replacementPodPendingForAttach runs one orchestration or CLI step.
|
||||
// Signature: replacementPodPendingForAttach(pod podResource) bool.
|
||||
// Why: stale RWO recovery should only act when a replacement is actually blocked
|
||||
// during scheduling or initialization.
|
||||
func replacementPodPendingForAttach(pod podResource) bool {
|
||||
phase := strings.TrimSpace(pod.Status.Phase)
|
||||
if strings.EqualFold(phase, "Pending") {
|
||||
return true
|
||||
}
|
||||
for _, st := range append(append([]podContainerStatus{}, pod.Status.InitContainerStatuses...), pod.Status.ContainerStatuses...) {
|
||||
if st.State.Waiting == nil {
|
||||
continue
|
||||
}
|
||||
reason := strings.TrimSpace(st.State.Waiting.Reason)
|
||||
if strings.EqualFold(reason, "ContainerCreating") || strings.EqualFold(reason, "PodInitializing") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// podHasBlockedAttachEvent runs one orchestration or CLI step.
|
||||
// Signature: podHasBlockedAttachEvent(pod podResource, events eventList) bool.
|
||||
// Why: force deletion should be tied to concrete Multi-Attach or exclusive-use
|
||||
// evidence rather than any old terminating PVC pod.
|
||||
func podHasBlockedAttachEvent(pod podResource, events eventList) bool {
|
||||
key := podKey(pod)
|
||||
for _, event := range events.Items {
|
||||
if !strings.EqualFold(strings.TrimSpace(event.InvolvedObject.Kind), "Pod") {
|
||||
continue
|
||||
}
|
||||
eventKey := strings.TrimSpace(event.InvolvedObject.Namespace) + "/" + strings.TrimSpace(event.InvolvedObject.Name)
|
||||
if eventKey != key {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(event.Type), "Warning") {
|
||||
continue
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(event.Message))
|
||||
reason := strings.ToLower(strings.TrimSpace(event.Reason))
|
||||
if reason == "failedattachvolume" ||
|
||||
strings.Contains(message, "multi-attach") ||
|
||||
strings.Contains(message, "volume is already used by pod") ||
|
||||
strings.Contains(message, "already exclusively attached") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// podsShareAnyClaim runs one orchestration or CLI step.
|
||||
// Signature: podsShareAnyClaim(oldPod podResource, replacement podResource, oldClaims map[string]string) bool.
|
||||
// Why: the old stale pod and replacement must be competing for the same PVC
|
||||
// before Ananke treats them as one storage handoff incident.
|
||||
func podsShareAnyClaim(oldPod podResource, replacement podResource, oldClaims map[string]string) bool {
|
||||
replacementClaims := map[string]struct{}{}
|
||||
for _, volume := range replacement.Spec.Volumes {
|
||||
if volume.PersistentVolumeClaim == nil {
|
||||
continue
|
||||
}
|
||||
claim := strings.TrimSpace(volume.PersistentVolumeClaim.ClaimName)
|
||||
if claim != "" {
|
||||
replacementClaims[claim] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, claim := range oldClaims {
|
||||
if _, ok := replacementClaims[claim]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runningContainersMountAnyVolume runs one orchestration or CLI step.
|
||||
// Signature: runningContainersMountAnyVolume(pod podResource, volumeNames map[string]string) bool.
|
||||
// Why: a live app container mounting the blocked PVC is unsafe to clear, while
|
||||
// sidecars without that mount can be handled as stale API ownership.
|
||||
func runningContainersMountAnyVolume(pod podResource, volumeNames map[string]string) bool {
|
||||
if len(volumeNames) == 0 {
|
||||
return false
|
||||
}
|
||||
specByName := map[string]podContainer{}
|
||||
for _, c := range pod.Spec.Containers {
|
||||
specByName[strings.TrimSpace(c.Name)] = c
|
||||
}
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
if status.State.Running == nil {
|
||||
continue
|
||||
}
|
||||
spec, ok := specByName[strings.TrimSpace(status.Name)]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for _, mount := range spec.VolumeMounts {
|
||||
if _, ok := volumeNames[strings.TrimSpace(mount.Name)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -244,6 +244,7 @@ type podResource struct {
|
||||
Metadata struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
CreationTimestamp time.Time `json:"creationTimestamp"`
|
||||
DeletionTimestamp *time.Time `json:"deletionTimestamp"`
|
||||
@ -284,9 +285,11 @@ type podContainerRunningState struct {
|
||||
}
|
||||
|
||||
type podSpec struct {
|
||||
NodeSelector map[string]string `json:"nodeSelector"`
|
||||
Affinity *podAffinity `json:"affinity"`
|
||||
Volumes []podVolume `json:"volumes"`
|
||||
NodeSelector map[string]string `json:"nodeSelector"`
|
||||
Affinity *podAffinity `json:"affinity"`
|
||||
Volumes []podVolume `json:"volumes"`
|
||||
Containers []podContainer `json:"containers"`
|
||||
InitContainers []podContainer `json:"initContainers"`
|
||||
}
|
||||
|
||||
type podVolume struct {
|
||||
@ -294,6 +297,17 @@ type podVolume struct {
|
||||
PersistentVolumeClaim *podPersistentVolumeClaim `json:"persistentVolumeClaim"`
|
||||
}
|
||||
|
||||
type podContainer struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
VolumeMounts []podVolumeMount `json:"volumeMounts"`
|
||||
}
|
||||
|
||||
type podVolumeMount struct {
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mountPath"`
|
||||
}
|
||||
|
||||
type podPersistentVolumeClaim struct {
|
||||
ClaimName string `json:"claimName"`
|
||||
}
|
||||
|
||||
164
internal/cluster/orchestrator_tcp_service_check.go
Normal file
164
internal/cluster/orchestrator_tcp_service_check.go
Normal file
@ -0,0 +1,164 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"scm.bstein.dev/bstein/ananke/internal/config"
|
||||
)
|
||||
|
||||
// tcpServiceChecklistReady runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) tcpServiceChecklistReady(ctx context.Context) (bool, string).
|
||||
// Why: SMTP, IMAP, and other non-HTTP services need protocol checks alongside
|
||||
// HTTP ingress checks before startup can call the cluster healthy.
|
||||
func (o *Orchestrator) tcpServiceChecklistReady(ctx context.Context) (bool, string) {
|
||||
checks := o.cfg.Startup.TCPServiceChecklist
|
||||
if len(checks) == 0 {
|
||||
return true, "no tcp checklist items configured"
|
||||
}
|
||||
for _, check := range checks {
|
||||
ok, detail := o.tcpServiceCheckReady(ctx, check)
|
||||
if !ok {
|
||||
name := strings.TrimSpace(check.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s:%d", strings.TrimSpace(check.Host), check.Port)
|
||||
}
|
||||
return false, fmt.Sprintf("%s: %s", name, detail)
|
||||
}
|
||||
}
|
||||
return true, fmt.Sprintf("tcp-checks=%d", len(checks))
|
||||
}
|
||||
|
||||
// tcpServiceCheckReady runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) tcpServiceCheckReady(ctx context.Context, check config.TCPServiceChecklistCheck) (bool, string).
|
||||
// Why: a single mail protocol endpoint should report whether connect, TLS, and
|
||||
// banner expectations passed without exposing any configured secret material.
|
||||
func (o *Orchestrator) tcpServiceCheckReady(ctx context.Context, check config.TCPServiceChecklistCheck) (bool, string) {
|
||||
banner, err := tcpServiceProbe(ctx, check)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
expected := strings.TrimSpace(check.ExpectContains)
|
||||
if expected != "" && !checklistContains(banner, expected) {
|
||||
return false, fmt.Sprintf("banner missing expected marker %q", expected)
|
||||
}
|
||||
return true, "connected"
|
||||
}
|
||||
|
||||
// tcpServiceProbe runs one orchestration or CLI step.
|
||||
// Signature: tcpServiceProbe(ctx context.Context, check config.TCPServiceChecklistCheck) (string, error).
|
||||
// Why: protocol probes need a small direct TCP/TLS implementation so Ananke can
|
||||
// validate Mailu without shelling out to nc or openssl.
|
||||
func tcpServiceProbe(ctx context.Context, check config.TCPServiceChecklistCheck) (string, error) {
|
||||
timeout := time.Duration(check.TimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
address := net.JoinHostPort(strings.TrimSpace(check.Host), fmt.Sprintf("%d", check.Port))
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect %s failed: %w", address, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
deadline := time.Now().Add(timeout)
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
if check.TLS {
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: strings.TrimSpace(check.Host),
|
||||
InsecureSkipVerify: check.InsecureSkipTLS,
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return "", fmt.Errorf("tls handshake %s failed: %w", address, err)
|
||||
}
|
||||
conn = tlsConn
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
bannerParts := []string{}
|
||||
line, _ := reader.ReadString('\n')
|
||||
if strings.TrimSpace(line) != "" {
|
||||
bannerParts = append(bannerParts, strings.TrimSpace(line))
|
||||
}
|
||||
if send := check.Send; send != "" {
|
||||
if _, err := conn.Write([]byte(send)); err != nil {
|
||||
return strings.Join(bannerParts, "\n"), fmt.Errorf("write probe command failed: %w", err)
|
||||
}
|
||||
line, _ = reader.ReadString('\n')
|
||||
if strings.TrimSpace(line) != "" {
|
||||
bannerParts = append(bannerParts, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return strings.Join(bannerParts, "\n"), nil
|
||||
}
|
||||
|
||||
// maybeAutoHealTCPServiceBackends runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) maybeAutoHealTCPServiceBackends(ctx context.Context, lastAttempt *time.Time).
|
||||
// Why: failed Mailu protocol probes should drive the same backend repair loop as
|
||||
// HTTP services when a Kubernetes service hint is configured.
|
||||
func (o *Orchestrator) maybeAutoHealTCPServiceBackends(ctx context.Context, lastAttempt *time.Time) {
|
||||
if o.runner.DryRun || len(o.cfg.Startup.TCPServiceChecklist) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if lastAttempt != nil && !lastAttempt.IsZero() && now.Sub(*lastAttempt) < 45*time.Second {
|
||||
return
|
||||
}
|
||||
if lastAttempt != nil {
|
||||
*lastAttempt = now
|
||||
}
|
||||
healed, err := o.healFailedTCPServiceBackends(ctx)
|
||||
if err != nil {
|
||||
o.log.Printf("warning: tcp service backend auto-heal failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(healed) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(healed)
|
||||
detail := fmt.Sprintf("restored tcp service backends: %s", joinLimited(healed, 8))
|
||||
o.log.Printf("%s", detail)
|
||||
o.noteStartupAutoHeal(detail)
|
||||
}
|
||||
|
||||
// healFailedTCPServiceBackends runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) healFailedTCPServiceBackends(ctx context.Context) ([]string, error).
|
||||
// Why: failed TCP protocol checks need a direct backend-heal primitive that can
|
||||
// be reused by startup waits and post-start daemon repair.
|
||||
func (o *Orchestrator) healFailedTCPServiceBackends(ctx context.Context) ([]string, error) {
|
||||
if o.runner.DryRun || len(o.cfg.Startup.TCPServiceChecklist) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
healed := []string{}
|
||||
attempted := map[string]struct{}{}
|
||||
for _, check := range o.cfg.Startup.TCPServiceChecklist {
|
||||
namespace := strings.TrimSpace(check.Namespace)
|
||||
service := strings.TrimSpace(check.Service)
|
||||
if namespace == "" || service == "" {
|
||||
continue
|
||||
}
|
||||
key := namespace + "/" + service
|
||||
if _, ok := attempted[key]; ok {
|
||||
continue
|
||||
}
|
||||
ok, _ := o.tcpServiceCheckReady(ctx, check)
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
attempted[key] = struct{}{}
|
||||
items, err := o.maybeHealCriticalEndpointBackends(ctx, namespace, service)
|
||||
if err != nil {
|
||||
return healed, fmt.Errorf("%s/%s: %w", namespace, service, err)
|
||||
}
|
||||
healed = append(healed, items...)
|
||||
}
|
||||
return healed, nil
|
||||
}
|
||||
74
internal/cluster/orchestrator_vault_health.go
Normal file
74
internal/cluster/orchestrator_vault_health.go
Normal file
@ -0,0 +1,74 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// vaultSealed runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error).
|
||||
// Why: treats exec and HTTP health as independent Vault signals so one flaky
|
||||
// kubectl exec does not block startup when HTTP health proves Vault is usable.
|
||||
func (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error) {
|
||||
sealed, err := o.vaultSealedViaExec(ctx)
|
||||
if err == nil {
|
||||
return sealed, nil
|
||||
}
|
||||
httpSealed, httpErr := o.vaultSealedViaHTTP(ctx)
|
||||
if httpErr == nil {
|
||||
o.log.Printf("warning: vault exec status probe failed but HTTP health succeeded; continuing with HTTP health result: %v", err)
|
||||
return httpSealed, nil
|
||||
}
|
||||
return false, fmt.Errorf("vault status check failed: exec=%v http=%v", err, httpErr)
|
||||
}
|
||||
|
||||
// vaultSealedViaExec runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) vaultSealedViaExec(ctx context.Context) (bool, error).
|
||||
// Why: preserves the existing direct Vault CLI probe as the primary status
|
||||
// signal when kubectl exec is healthy.
|
||||
func (o *Orchestrator) vaultSealedViaExec(ctx context.Context) (bool, error) {
|
||||
out, err := o.kubectl(
|
||||
ctx,
|
||||
25*time.Second,
|
||||
"-n", "vault",
|
||||
"exec", "vault-0", "--",
|
||||
"sh", "-lc",
|
||||
"VAULT_ADDR=http://127.0.0.1:8200 vault status -format=json 2>/dev/null || true",
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("vault status check failed: %w", err)
|
||||
}
|
||||
sealed, err := parseVaultSealed(out)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse vault status: %w", err)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// vaultSealedViaHTTP runs one orchestration or CLI step.
|
||||
// Signature: (o *Orchestrator) vaultSealedViaHTTP(ctx context.Context) (bool, error).
|
||||
// Why: gives startup a second authoritative Vault health path when exec is
|
||||
// transiently killed or the apiserver exec tunnel is degraded.
|
||||
func (o *Orchestrator) vaultSealedViaHTTP(ctx context.Context) (bool, error) {
|
||||
out, err := o.kubectl(
|
||||
ctx,
|
||||
15*time.Second,
|
||||
"-n", "vault",
|
||||
"get",
|
||||
"--raw",
|
||||
"/api/v1/namespaces/vault/pods/vault-0:8200/proxy/v1/sys/health",
|
||||
)
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
return false, fmt.Errorf("vault HTTP health check failed: %w", err)
|
||||
}
|
||||
sealed, parseErr := parseVaultSealed(out)
|
||||
if parseErr != nil {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("vault HTTP health check failed: %w; parse: %v", err, parseErr)
|
||||
}
|
||||
return false, fmt.Errorf("parse vault HTTP health: %w", parseErr)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
@ -188,6 +188,18 @@ func (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error {
|
||||
} else {
|
||||
stalePhaseReasons = reasons
|
||||
}
|
||||
staleRWODecisions := map[string]staleRWOOwnerDecision{}
|
||||
if decisions, scanErr := o.staleRWOPVCOwnerDecisions(ctx, list, grace); scanErr != nil {
|
||||
o.log.Printf("warning: stale RWO PVC owner scan failed: %v", scanErr)
|
||||
} else {
|
||||
staleRWODecisions = decisions
|
||||
}
|
||||
imagePullDNSReasons := map[string]string{}
|
||||
if reasons, scanErr := o.imagePullDNSBlockerReasons(ctx); scanErr != nil {
|
||||
o.log.Printf("warning: image-pull DNS blocker scan failed: %v", scanErr)
|
||||
} else {
|
||||
imagePullDNSReasons = reasons
|
||||
}
|
||||
containerRuntimeWedgeReasons := map[string]string{}
|
||||
if reasons, scanErr := o.containerRuntimeWedgePodReasons(ctx, list, grace); scanErr != nil {
|
||||
o.log.Printf("warning: container runtime wedge scan failed: %v", scanErr)
|
||||
@ -219,6 +231,10 @@ func (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
reason := stuckContainerReason(pod, stuckReasons)
|
||||
if (reason == "ImagePullBackOff" || reason == "ErrImagePull") && imagePullDNSReasons[ns+"/"+name] != "" {
|
||||
o.log.Printf("warning: not recycling pod %s/%s because image pull is blocked by DNS/registry lookup: %s", ns, name, imagePullDNSReasons[ns+"/"+name])
|
||||
continue
|
||||
}
|
||||
if reason == "" {
|
||||
reason = stuckVaultInitReason(pod, grace)
|
||||
}
|
||||
@ -231,6 +247,15 @@ func (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error {
|
||||
if reason == "" {
|
||||
reason = stalePhaseReasons[ns+"/"+name]
|
||||
}
|
||||
if decision, ok := staleRWODecisions[ns+"/"+name]; ok && decision.Unsafe {
|
||||
o.log.Printf("warning: stale RWO PVC owner is unsafe to force delete pod=%s/%s reason=%s", ns, name, decision.Reason)
|
||||
continue
|
||||
}
|
||||
if reason == "" {
|
||||
if decision, ok := staleRWODecisions[ns+"/"+name]; ok && decision.ForceDelete {
|
||||
reason = decision.Reason
|
||||
}
|
||||
}
|
||||
if runtimeReason := containerRuntimeWedgeReasons[ns+"/"+name]; runtimeReason != "" {
|
||||
reason = runtimeReason
|
||||
}
|
||||
@ -242,6 +267,9 @@ func (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error {
|
||||
}
|
||||
deleteArgs := []string{"-n", ns, "delete", "pod", name, "--wait=false"}
|
||||
forceDelete := staleControllerPodForceDeleteSafe(pod, grace)
|
||||
if decision, ok := staleRWODecisions[ns+"/"+name]; ok && decision.ForceDelete {
|
||||
forceDelete = true
|
||||
}
|
||||
if forceDelete {
|
||||
deleteArgs = append(deleteArgs, "--grace-period=0", "--force")
|
||||
}
|
||||
|
||||
@ -129,11 +129,21 @@ func (c *Config) applyDefaults() {
|
||||
} else {
|
||||
c.Startup.ServiceChecklist = mergeServiceChecklistDefaults(c.Startup.ServiceChecklist, defaultServiceChecklist())
|
||||
}
|
||||
if c.Startup.TCPServiceChecklistExplicitOnly {
|
||||
c.Startup.TCPServiceChecklist = mergeTCPServiceChecklistDefaults(c.Startup.TCPServiceChecklist, []TCPServiceChecklistCheck{})
|
||||
} else {
|
||||
c.Startup.TCPServiceChecklist = mergeTCPServiceChecklistDefaults(c.Startup.TCPServiceChecklist, defaultTCPServiceChecklist())
|
||||
}
|
||||
for i := range c.Startup.ServiceChecklist {
|
||||
if c.Startup.ServiceChecklist[i].TimeoutSeconds <= 0 {
|
||||
c.Startup.ServiceChecklist[i].TimeoutSeconds = 12
|
||||
}
|
||||
}
|
||||
for i := range c.Startup.TCPServiceChecklist {
|
||||
if c.Startup.TCPServiceChecklist[i].TimeoutSeconds <= 0 {
|
||||
c.Startup.TCPServiceChecklist[i].TimeoutSeconds = 8
|
||||
}
|
||||
}
|
||||
if c.Startup.CriticalServiceEndpointWaitSec <= 0 {
|
||||
c.Startup.CriticalServiceEndpointWaitSec = 420
|
||||
}
|
||||
@ -141,6 +151,9 @@ func (c *Config) applyDefaults() {
|
||||
c.Startup.CriticalServiceEndpointPollSec = 5
|
||||
}
|
||||
c.Startup.CriticalServiceEndpoints = mergeStringDefaults(c.Startup.CriticalServiceEndpoints, defaultCriticalServiceEndpoints())
|
||||
if c.Startup.CriticalServiceStartupProbeThreshold <= 0 {
|
||||
c.Startup.CriticalServiceStartupProbeThreshold = 30
|
||||
}
|
||||
if c.Startup.IngressChecklistWaitSeconds <= 0 {
|
||||
c.Startup.IngressChecklistWaitSeconds = 420
|
||||
}
|
||||
@ -207,6 +220,18 @@ func (c *Config) applyDefaults() {
|
||||
if c.Startup.DeadNodeCleanupGraceSeconds <= 0 {
|
||||
c.Startup.DeadNodeCleanupGraceSeconds = 300
|
||||
}
|
||||
if strings.TrimSpace(c.Startup.HostSudoSecretPasswordKey) == "" {
|
||||
c.Startup.HostSudoSecretPasswordKey = "password"
|
||||
}
|
||||
if c.Startup.HostPrivilegedCommandTimeoutSec <= 0 {
|
||||
c.Startup.HostPrivilegedCommandTimeoutSec = 90
|
||||
}
|
||||
if c.Startup.NodeRuntimeRestartWaitSeconds <= 0 {
|
||||
c.Startup.NodeRuntimeRestartWaitSeconds = 140
|
||||
}
|
||||
if c.Startup.NodeRuntimeRebootWaitSeconds <= 0 {
|
||||
c.Startup.NodeRuntimeRebootWaitSeconds = 420
|
||||
}
|
||||
if strings.TrimSpace(c.Startup.VaultUnsealKeyFile) == "" {
|
||||
c.Startup.VaultUnsealKeyFile = "/var/lib/ananke/vault-unseal.key"
|
||||
}
|
||||
|
||||
@ -132,6 +132,71 @@ state:
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDefaultsCriticalStartupProbeRepairWhenOmitted runs one orchestration or CLI step.
|
||||
// Signature: TestLoadDefaultsCriticalStartupProbeRepairWhenOmitted(t *testing.T).
|
||||
// Why: existing configs with a startup block should inherit the Mailu hardening
|
||||
// repair instead of silently decoding the new boolean as false.
|
||||
func TestLoadDefaultsCriticalStartupProbeRepairWhenOmitted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "ananke.yaml")
|
||||
raw := `
|
||||
control_planes: [titan-0a, titan-0b, titan-0c]
|
||||
expected_flux_branch: main
|
||||
iac_repo_path: /opt/titan-iac
|
||||
startup:
|
||||
api_wait_seconds: 900
|
||||
ups:
|
||||
enabled: false
|
||||
state:
|
||||
run_history_path: /tmp/runs.json
|
||||
lock_path: /tmp/ananke.lock
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(strings.TrimSpace(raw)), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
cfg, err := Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if !cfg.Startup.CriticalServiceStartupProbeRepair {
|
||||
t.Fatalf("expected omitted startup-probe repair setting to default true")
|
||||
}
|
||||
if cfg.Startup.CriticalServiceStartupProbeThreshold != 30 {
|
||||
t.Fatalf("expected startup-probe threshold default 30, got %d", cfg.Startup.CriticalServiceStartupProbeThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadHonorsExplicitCriticalStartupProbeRepairFalse runs one orchestration or CLI step.
|
||||
// Signature: TestLoadHonorsExplicitCriticalStartupProbeRepairFalse(t *testing.T).
|
||||
// Why: operators should still be able to disable the repair explicitly even
|
||||
// though omitted values inherit the hardening default.
|
||||
func TestLoadHonorsExplicitCriticalStartupProbeRepairFalse(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "ananke.yaml")
|
||||
raw := `
|
||||
control_planes: [titan-0a, titan-0b, titan-0c]
|
||||
expected_flux_branch: main
|
||||
iac_repo_path: /opt/titan-iac
|
||||
startup:
|
||||
critical_service_startup_probe_repair: false
|
||||
ups:
|
||||
enabled: false
|
||||
state:
|
||||
run_history_path: /tmp/runs.json
|
||||
lock_path: /tmp/ananke.lock
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(strings.TrimSpace(raw)), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
cfg, err := Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.Startup.CriticalServiceStartupProbeRepair {
|
||||
t.Fatalf("expected explicit startup-probe repair false to be honored")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRejectsInvalidStartupShutdownCooldown runs one orchestration or CLI step.
|
||||
// Signature: TestValidateRejectsInvalidStartupShutdownCooldown(t *testing.T).
|
||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
||||
|
||||
@ -94,10 +94,13 @@ func defaults() Config {
|
||||
AdminSecretPasswordKey: "password",
|
||||
},
|
||||
ServiceChecklist: defaultServiceChecklist(),
|
||||
TCPServiceChecklist: defaultTCPServiceChecklist(),
|
||||
RequireCriticalServiceEndpoints: true,
|
||||
CriticalServiceEndpointWaitSec: 420,
|
||||
CriticalServiceEndpointPollSec: 5,
|
||||
CriticalServiceEndpoints: defaultCriticalServiceEndpoints(),
|
||||
CriticalServiceStartupProbeRepair: true,
|
||||
CriticalServiceStartupProbeThreshold: 30,
|
||||
RequireIngressChecklist: true,
|
||||
IngressChecklistWaitSeconds: 420,
|
||||
IngressChecklistPollSeconds: 5,
|
||||
@ -122,6 +125,10 @@ func defaults() Config {
|
||||
AutoRecycleStuckPods: true,
|
||||
StuckPodGraceSeconds: 180,
|
||||
RecoveryCordonMaxSeconds: 3600,
|
||||
HostSudoSecretPasswordKey: "password",
|
||||
HostPrivilegedCommandTimeoutSec: 90,
|
||||
NodeRuntimeRestartWaitSeconds: 140,
|
||||
NodeRuntimeRebootWaitSeconds: 420,
|
||||
VaultUnsealKeyFile: "/var/lib/ananke/vault-unseal.key",
|
||||
VaultUnsealBreakglassTimeout: 15,
|
||||
},
|
||||
|
||||
@ -17,13 +17,52 @@ func Load(path string) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
startupProbeRepairConfigured, err := yamlPathExists(b, "startup", "critical_service_startup_probe_repair")
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("decode config %s: %w", path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("decode config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
if !startupProbeRepairConfigured {
|
||||
cfg.Startup.CriticalServiceStartupProbeRepair = defaults().Startup.CriticalServiceStartupProbeRepair
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// yamlPathExists runs one orchestration or CLI step.
|
||||
// Signature: yamlPathExists(raw []byte, path ...string) (bool, error).
|
||||
// Why: selected boolean defaults need to distinguish an omitted YAML key from an
|
||||
// explicit false value after nested startup mappings are decoded.
|
||||
func yamlPathExists(raw []byte, path ...string) (bool, error) {
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(raw, &root); err != nil {
|
||||
return false, err
|
||||
}
|
||||
node := &root
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
for _, key := range path {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return false, nil
|
||||
}
|
||||
found := false
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
node = node.Content[i+1]
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@ -251,6 +251,57 @@ func defaultServiceChecklist() []ServiceChecklistCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTCPServiceChecklist runs one orchestration or CLI step.
|
||||
// Signature: defaultTCPServiceChecklist() []TCPServiceChecklistCheck.
|
||||
// Why: mail and other non-HTTP edge services need protocol-level startup checks
|
||||
// so Ananke does not declare success while SMTP/IMAP ports are broken.
|
||||
func defaultTCPServiceChecklist() []TCPServiceChecklistCheck {
|
||||
return []TCPServiceChecklistCheck{
|
||||
{
|
||||
Name: "mail-smtp",
|
||||
Host: "mail.bstein.dev",
|
||||
Port: 25,
|
||||
Send: "QUIT\r\n",
|
||||
ExpectContains: "ESMTP",
|
||||
TimeoutSeconds: 8,
|
||||
Namespace: "mailu-mailserver",
|
||||
Service: "mailu-front",
|
||||
},
|
||||
{
|
||||
Name: "mail-smtps",
|
||||
Host: "mail.bstein.dev",
|
||||
Port: 465,
|
||||
TLS: true,
|
||||
Send: "QUIT\r\n",
|
||||
ExpectContains: "ready",
|
||||
TimeoutSeconds: 8,
|
||||
Namespace: "mailu-mailserver",
|
||||
Service: "mailu-front",
|
||||
},
|
||||
{
|
||||
Name: "mail-submission",
|
||||
Host: "mail.bstein.dev",
|
||||
Port: 587,
|
||||
Send: "QUIT\r\n",
|
||||
ExpectContains: "ready",
|
||||
TimeoutSeconds: 8,
|
||||
Namespace: "mailu-mailserver",
|
||||
Service: "mailu-front",
|
||||
},
|
||||
{
|
||||
Name: "mail-imaps",
|
||||
Host: "mail.bstein.dev",
|
||||
Port: 993,
|
||||
TLS: true,
|
||||
Send: "a001 LOGOUT\r\n",
|
||||
ExpectContains: "OK",
|
||||
TimeoutSeconds: 8,
|
||||
Namespace: "mailu-mailserver",
|
||||
Service: "mailu-front",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultCriticalServiceEndpoints runs one orchestration or CLI step.
|
||||
// Signature: defaultCriticalServiceEndpoints() []string.
|
||||
// Why: service edge checks are insufficient for protected stacks; endpoint
|
||||
@ -263,6 +314,11 @@ func defaultCriticalServiceEndpoints() []string {
|
||||
"logging/oauth2-proxy-logs",
|
||||
"logging/opensearch-dashboards",
|
||||
"logging/opensearch-master",
|
||||
"mailu-mailserver/mailu-admin",
|
||||
"mailu-mailserver/mailu-dovecot",
|
||||
"mailu-mailserver/mailu-front",
|
||||
"mailu-mailserver/mailu-postfix",
|
||||
"mailu-mailserver/mailu-rspamd",
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,6 +357,41 @@ func mergeServiceChecklistDefaults(existing, defaults []ServiceChecklistCheck) [
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeTCPServiceChecklistDefaults runs one orchestration or CLI step.
|
||||
// Signature: mergeTCPServiceChecklistDefaults(existing, defaults []TCPServiceChecklistCheck) []TCPServiceChecklistCheck.
|
||||
// Why: host configs should inherit new mail/TCP health checks while preserving
|
||||
// site-specific protocol probes.
|
||||
func mergeTCPServiceChecklistDefaults(existing, defaults []TCPServiceChecklistCheck) []TCPServiceChecklistCheck {
|
||||
if len(existing) == 0 {
|
||||
out := make([]TCPServiceChecklistCheck, 0, len(defaults))
|
||||
out = append(out, defaults...)
|
||||
return out
|
||||
}
|
||||
|
||||
defaultByName := map[string]struct{}{}
|
||||
for _, check := range defaults {
|
||||
name := strings.TrimSpace(check.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
defaultByName[name] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]TCPServiceChecklistCheck, 0, len(defaults)+len(existing))
|
||||
out = append(out, defaults...)
|
||||
for _, check := range existing {
|
||||
name := strings.TrimSpace(check.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := defaultByName[name]; exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, check)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeStringDefaults runs one orchestration or CLI step.
|
||||
// Signature: mergeStringDefaults(existing, defaults []string) []string.
|
||||
// Why: keeps baseline startup guards applied while preserving site-specific
|
||||
|
||||
@ -18,6 +18,15 @@ func TestHookDefaultCriticalServiceEndpoints() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// TestHookDefaultTCPServiceChecklist runs one orchestration or CLI step.
|
||||
// Signature: TestHookDefaultTCPServiceChecklist() []TCPServiceChecklistCheck.
|
||||
// Why: exposes default TCP/mail service checks to top-level tests.
|
||||
func TestHookDefaultTCPServiceChecklist() []TCPServiceChecklistCheck {
|
||||
out := make([]TCPServiceChecklistCheck, 0, len(defaultTCPServiceChecklist()))
|
||||
out = append(out, defaultTCPServiceChecklist()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestHookMergeServiceChecklistDefaults runs one orchestration or CLI step.
|
||||
// Signature: TestHookMergeServiceChecklistDefaults(existing, defaults []ServiceChecklistCheck) []ServiceChecklistCheck.
|
||||
// Why: exposes checklist merge helper to top-level tests.
|
||||
@ -31,3 +40,10 @@ func TestHookMergeServiceChecklistDefaults(existing, defaults []ServiceChecklist
|
||||
func TestHookMergeStringDefaults(existing, defaults []string) []string {
|
||||
return mergeStringDefaults(existing, defaults)
|
||||
}
|
||||
|
||||
// TestHookMergeTCPServiceChecklistDefaults runs one orchestration or CLI step.
|
||||
// Signature: TestHookMergeTCPServiceChecklistDefaults(existing, defaults []TCPServiceChecklistCheck) []TCPServiceChecklistCheck.
|
||||
// Why: exposes TCP checklist merge helper to top-level tests.
|
||||
func TestHookMergeTCPServiceChecklistDefaults(existing, defaults []TCPServiceChecklistCheck) []TCPServiceChecklistCheck {
|
||||
return mergeTCPServiceChecklistDefaults(existing, defaults)
|
||||
}
|
||||
|
||||
@ -62,10 +62,14 @@ type Startup struct {
|
||||
ServiceChecklistAuth ServiceChecklistAuthSettings `yaml:"service_checklist_auth"`
|
||||
ServiceChecklistExplicitOnly bool `yaml:"service_checklist_explicit_only"`
|
||||
ServiceChecklist []ServiceChecklistCheck `yaml:"service_checklist"`
|
||||
TCPServiceChecklistExplicitOnly bool `yaml:"tcp_service_checklist_explicit_only"`
|
||||
TCPServiceChecklist []TCPServiceChecklistCheck `yaml:"tcp_service_checklist"`
|
||||
RequireCriticalServiceEndpoints bool `yaml:"require_critical_service_endpoints"`
|
||||
CriticalServiceEndpointWaitSec int `yaml:"critical_service_endpoint_wait_seconds"`
|
||||
CriticalServiceEndpointPollSec int `yaml:"critical_service_endpoint_poll_seconds"`
|
||||
CriticalServiceEndpoints []string `yaml:"critical_service_endpoints"`
|
||||
CriticalServiceStartupProbeRepair bool `yaml:"critical_service_startup_probe_repair"`
|
||||
CriticalServiceStartupProbeThreshold int `yaml:"critical_service_startup_probe_failure_threshold"`
|
||||
RequireIngressChecklist bool `yaml:"require_ingress_checklist"`
|
||||
IngressChecklistWaitSeconds int `yaml:"ingress_checklist_wait_seconds"`
|
||||
IngressChecklistPollSeconds int `yaml:"ingress_checklist_poll_seconds"`
|
||||
@ -96,6 +100,13 @@ type Startup struct {
|
||||
PostStartAutoHealSeconds int `yaml:"post_start_auto_heal_seconds"`
|
||||
RecoveryCordonMaxSeconds int `yaml:"recovery_cordon_max_seconds"`
|
||||
DeadNodeCleanupGraceSeconds int `yaml:"dead_node_cleanup_grace_seconds"`
|
||||
HostSudoSecretNamespace string `yaml:"host_sudo_secret_namespace"`
|
||||
HostSudoSecretNameTemplate string `yaml:"host_sudo_secret_name_template"`
|
||||
HostSudoSecretPasswordKey string `yaml:"host_sudo_secret_password_key"`
|
||||
HostRepairAllowReboot bool `yaml:"host_repair_allow_reboot"`
|
||||
HostPrivilegedCommandTimeoutSec int `yaml:"host_privileged_command_timeout_seconds"`
|
||||
NodeRuntimeRestartWaitSeconds int `yaml:"node_runtime_restart_wait_seconds"`
|
||||
NodeRuntimeRebootWaitSeconds int `yaml:"node_runtime_reboot_wait_seconds"`
|
||||
VaultUnsealKeyFile string `yaml:"vault_unseal_key_file"`
|
||||
VaultUnsealBreakglassCommand string `yaml:"vault_unseal_breakglass_command"`
|
||||
VaultUnsealBreakglassTimeout int `yaml:"vault_unseal_breakglass_timeout_seconds"`
|
||||
@ -117,6 +128,19 @@ type ServiceChecklistCheck struct {
|
||||
InsecureSkipTLS bool `yaml:"insecure_skip_tls"`
|
||||
}
|
||||
|
||||
type TCPServiceChecklistCheck struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
TLS bool `yaml:"tls"`
|
||||
Send string `yaml:"send"`
|
||||
ExpectContains string `yaml:"expect_contains"`
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
InsecureSkipTLS bool `yaml:"insecure_skip_tls"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
Service string `yaml:"service"`
|
||||
}
|
||||
|
||||
type ServiceChecklistAuthSettings struct {
|
||||
Mode string `yaml:"mode"`
|
||||
KeycloakBaseURL string `yaml:"keycloak_base_url"`
|
||||
|
||||
@ -203,6 +203,28 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, check := range c.Startup.TCPServiceChecklist {
|
||||
if strings.TrimSpace(check.Name) == "" {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].name must not be empty", i)
|
||||
}
|
||||
if strings.TrimSpace(check.Host) == "" {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].host must not be empty", i)
|
||||
}
|
||||
if strings.ContainsAny(check.Host, " \t\r\n/") {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].host is invalid: %q", i, check.Host)
|
||||
}
|
||||
if check.Port <= 0 || check.Port > 65535 {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].port must be in range 1-65535", i)
|
||||
}
|
||||
if check.TimeoutSeconds <= 0 {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].timeout_seconds must be > 0", i)
|
||||
}
|
||||
if strings.TrimSpace(check.Namespace) != "" || strings.TrimSpace(check.Service) != "" {
|
||||
if strings.TrimSpace(check.Namespace) == "" || strings.TrimSpace(check.Service) == "" {
|
||||
return fmt.Errorf("config.startup.tcp_service_checklist[%d].namespace and service must be set together", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Startup.CriticalServiceEndpointWaitSec <= 0 {
|
||||
return fmt.Errorf("config.startup.critical_service_endpoint_wait_seconds must be > 0")
|
||||
}
|
||||
@ -221,6 +243,9 @@ func (c Config) Validate() error {
|
||||
return fmt.Errorf("config.startup.critical_service_endpoints entries must be namespace/service, got %q", entry)
|
||||
}
|
||||
}
|
||||
if c.Startup.CriticalServiceStartupProbeRepair && c.Startup.CriticalServiceStartupProbeThreshold <= 0 {
|
||||
return fmt.Errorf("config.startup.critical_service_startup_probe_failure_threshold must be > 0 when critical_service_startup_probe_repair is true")
|
||||
}
|
||||
if c.Startup.IngressChecklistWaitSeconds <= 0 {
|
||||
return fmt.Errorf("config.startup.ingress_checklist_wait_seconds must be > 0")
|
||||
}
|
||||
@ -286,6 +311,26 @@ func (c Config) Validate() error {
|
||||
if c.Startup.DeadNodeCleanupGraceSeconds <= 0 {
|
||||
return fmt.Errorf("config.startup.dead_node_cleanup_grace_seconds must be > 0")
|
||||
}
|
||||
if strings.TrimSpace(c.Startup.HostSudoSecretNamespace) != "" || strings.TrimSpace(c.Startup.HostSudoSecretNameTemplate) != "" {
|
||||
if strings.TrimSpace(c.Startup.HostSudoSecretNamespace) == "" {
|
||||
return fmt.Errorf("config.startup.host_sudo_secret_namespace must not be empty when host sudo secret lookup is configured")
|
||||
}
|
||||
if strings.TrimSpace(c.Startup.HostSudoSecretNameTemplate) == "" {
|
||||
return fmt.Errorf("config.startup.host_sudo_secret_name_template must not be empty when host sudo secret lookup is configured")
|
||||
}
|
||||
if strings.TrimSpace(c.Startup.HostSudoSecretPasswordKey) == "" {
|
||||
return fmt.Errorf("config.startup.host_sudo_secret_password_key must not be empty when host sudo secret lookup is configured")
|
||||
}
|
||||
}
|
||||
if c.Startup.HostPrivilegedCommandTimeoutSec <= 0 {
|
||||
return fmt.Errorf("config.startup.host_privileged_command_timeout_seconds must be > 0")
|
||||
}
|
||||
if c.Startup.NodeRuntimeRestartWaitSeconds <= 0 {
|
||||
return fmt.Errorf("config.startup.node_runtime_restart_wait_seconds must be > 0")
|
||||
}
|
||||
if c.Startup.NodeRuntimeRebootWaitSeconds <= 0 {
|
||||
return fmt.Errorf("config.startup.node_runtime_reboot_wait_seconds must be > 0")
|
||||
}
|
||||
for _, probe := range c.Startup.PostStartProbes {
|
||||
if strings.TrimSpace(probe) == "" {
|
||||
return fmt.Errorf("config.startup.post_start_probes entries must not be empty")
|
||||
|
||||
@ -52,6 +52,21 @@ func TestValidateRejectsInvalidFieldsMatrix(t *testing.T) {
|
||||
{"bad_service_checklist_http_code", func(c *Config) {
|
||||
c.Startup.ServiceChecklist = []ServiceChecklistCheck{{Name: "x", URL: "https://ok", TimeoutSeconds: 5, AcceptedStatuses: []int{999}}}
|
||||
}},
|
||||
{"bad_tcp_service_checklist_name", func(c *Config) {
|
||||
c.Startup.TCPServiceChecklist = []TCPServiceChecklistCheck{{Host: "mail.bstein.dev", Port: 25, TimeoutSeconds: 5}}
|
||||
}},
|
||||
{"bad_tcp_service_checklist_host", func(c *Config) {
|
||||
c.Startup.TCPServiceChecklist = []TCPServiceChecklistCheck{{Name: "mail", Host: "mail/bad", Port: 25, TimeoutSeconds: 5}}
|
||||
}},
|
||||
{"bad_tcp_service_checklist_port", func(c *Config) {
|
||||
c.Startup.TCPServiceChecklist = []TCPServiceChecklistCheck{{Name: "mail", Host: "mail.bstein.dev", Port: 70000, TimeoutSeconds: 5}}
|
||||
}},
|
||||
{"bad_tcp_service_checklist_timeout", func(c *Config) {
|
||||
c.Startup.TCPServiceChecklist = []TCPServiceChecklistCheck{{Name: "mail", Host: "mail.bstein.dev", Port: 25}}
|
||||
}},
|
||||
{"bad_tcp_service_checklist_partial_backend", func(c *Config) {
|
||||
c.Startup.TCPServiceChecklist = []TCPServiceChecklistCheck{{Name: "mail", Host: "mail.bstein.dev", Port: 25, TimeoutSeconds: 5, Namespace: "mailu-mailserver"}}
|
||||
}},
|
||||
{"bad_critical_endpoint_wait", func(c *Config) { c.Startup.CriticalServiceEndpointWaitSec = 0 }},
|
||||
{"bad_critical_endpoint_poll", func(c *Config) { c.Startup.CriticalServiceEndpointPollSec = 0 }},
|
||||
{"bad_empty_critical_endpoints_when_required", func(c *Config) {
|
||||
@ -159,6 +174,9 @@ func TestApplyDefaultsPopulatesZeroConfig(t *testing.T) {
|
||||
if len(cfg.Startup.CriticalServiceEndpoints) == 0 {
|
||||
t.Fatalf("expected critical service endpoint defaults to be set")
|
||||
}
|
||||
if len(cfg.Startup.TCPServiceChecklist) == 0 {
|
||||
t.Fatalf("expected TCP service checklist defaults to be set")
|
||||
}
|
||||
if cfg.Shutdown.SSHParallelism <= 0 || cfg.Shutdown.ScaleParallelism <= 0 || cfg.Shutdown.DrainParallelism <= 0 {
|
||||
t.Fatalf("expected shutdown parallelism defaults to be set")
|
||||
}
|
||||
|
||||
@ -51,15 +51,33 @@ func TestHookServiceCatalogAndMergeContracts(t *testing.T) {
|
||||
t.Fatalf("expected critical endpoint defaults")
|
||||
}
|
||||
foundMonitoring := false
|
||||
foundMailAdmin := false
|
||||
for _, entry := range critical {
|
||||
if entry == "monitoring/grafana" {
|
||||
foundMonitoring = true
|
||||
break
|
||||
}
|
||||
if entry == "mailu-mailserver/mailu-admin" {
|
||||
foundMailAdmin = true
|
||||
}
|
||||
}
|
||||
if !foundMonitoring {
|
||||
t.Fatalf("expected monitoring/grafana critical endpoint default")
|
||||
}
|
||||
if !foundMailAdmin {
|
||||
t.Fatalf("expected mailu-mailserver/mailu-admin critical endpoint default")
|
||||
}
|
||||
|
||||
tcpChecks := icfg.TestHookDefaultTCPServiceChecklist()
|
||||
seenTCP := map[string]icfg.TCPServiceChecklistCheck{}
|
||||
for _, check := range tcpChecks {
|
||||
seenTCP[strings.TrimSpace(check.Name)] = check
|
||||
}
|
||||
if seenTCP["mail-smtp"].Port != 25 || seenTCP["mail-smtp"].Service != "mailu-front" {
|
||||
t.Fatalf("expected mail-smtp TCP default to target Mailu front")
|
||||
}
|
||||
if !seenTCP["mail-imaps"].TLS || seenTCP["mail-imaps"].Port != 993 {
|
||||
t.Fatalf("expected mail-imaps TCP default with TLS on 993")
|
||||
}
|
||||
|
||||
mergedChecks := icfg.TestHookMergeServiceChecklistDefaults(
|
||||
[]icfg.ServiceChecklistCheck{
|
||||
@ -75,6 +93,20 @@ func TestHookServiceCatalogAndMergeContracts(t *testing.T) {
|
||||
t.Fatalf("expected 3 merged checks with dedupe, got %d", len(mergedChecks))
|
||||
}
|
||||
|
||||
mergedTCP := icfg.TestHookMergeTCPServiceChecklistDefaults(
|
||||
[]icfg.TCPServiceChecklistCheck{
|
||||
{Name: "custom-tcp", Host: "custom.bstein.dev", Port: 1234, TimeoutSeconds: 5},
|
||||
{Name: "mail-smtp", Host: "override.invalid", Port: 25, TimeoutSeconds: 5},
|
||||
},
|
||||
[]icfg.TCPServiceChecklistCheck{
|
||||
{Name: "mail-smtp", Host: "mail.bstein.dev", Port: 25, TimeoutSeconds: 5},
|
||||
{Name: "mail-imaps", Host: "mail.bstein.dev", Port: 993, TimeoutSeconds: 5},
|
||||
},
|
||||
)
|
||||
if len(mergedTCP) != 3 {
|
||||
t.Fatalf("expected 3 merged TCP checks with dedupe, got %d", len(mergedTCP))
|
||||
}
|
||||
|
||||
mergedStrings := icfg.TestHookMergeStringDefaults(
|
||||
[]string{" one ", "one", "", "two"},
|
||||
[]string{"two", "three", " "},
|
||||
@ -84,6 +116,33 @@ func TestHookServiceCatalogAndMergeContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitanCoordinatorConfigInheritsMailHardeningDefaults runs one orchestration or CLI step.
|
||||
// Signature: TestTitanCoordinatorConfigInheritsMailHardeningDefaults(t *testing.T).
|
||||
// Why: the deployed coordinator config has a startup block, so new Mailu recovery
|
||||
// defaults must still be present after YAML decoding and default merging.
|
||||
func TestTitanCoordinatorConfigInheritsMailHardeningDefaults(t *testing.T) {
|
||||
cfg, err := icfg.Load(filepath.Join("..", "..", "configs", "ananke.titan-db.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("load titan coordinator config: %v", err)
|
||||
}
|
||||
if !cfg.Startup.CriticalServiceStartupProbeRepair {
|
||||
t.Fatalf("expected titan config to inherit critical startup-probe repair")
|
||||
}
|
||||
seenMailAdmin := false
|
||||
seenMailRspamd := false
|
||||
for _, entry := range cfg.Startup.CriticalServiceEndpoints {
|
||||
if entry == "mailu-mailserver/mailu-admin" {
|
||||
seenMailAdmin = true
|
||||
}
|
||||
if entry == "mailu-mailserver/mailu-rspamd" {
|
||||
seenMailRspamd = true
|
||||
}
|
||||
}
|
||||
if !seenMailAdmin || !seenMailRspamd {
|
||||
t.Fatalf("expected titan config to include Mailu critical endpoints, admin=%v rspamd=%v", seenMailAdmin, seenMailRspamd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateServiceChecklistAuthContracts runs one orchestration or CLI step.
|
||||
// Signature: TestValidateServiceChecklistAuthContracts(t *testing.T).
|
||||
// Why: covers service-checklist auth and final-url validation branches that are
|
||||
|
||||
@ -4,6 +4,7 @@ cmd/ananke/bootstrap_handoff_additional_test.go
|
||||
cmd/ananke/bootstrap_handoff_test.go
|
||||
cmd/ananke/builder_test.go
|
||||
cmd/ananke/command_additional_test.go
|
||||
cmd/ananke/command_handlers_autoheal_test.go
|
||||
cmd/ananke/command_handlers_injection_test.go
|
||||
cmd/ananke/command_handlers_status_error_test.go
|
||||
cmd/ananke/command_handlers_test.go
|
||||
@ -16,9 +17,11 @@ internal/cluster/orchestrator_report_test.go
|
||||
internal/cluster/orchestrator_autorepair_test.go
|
||||
internal/cluster/orchestrator_autorepair_cleanup_test.go
|
||||
internal/cluster/orchestrator_autorepair_proxy_test.go
|
||||
internal/cluster/orchestrator_autorepair_service_test.go
|
||||
internal/cluster/orchestrator_critical_endpoint_additional_test.go
|
||||
internal/cluster/orchestrator_cordon_lease_test.go
|
||||
internal/cluster/orchestrator_test.go
|
||||
internal/cluster/orchestrator_hardening_test.go
|
||||
internal/cluster/orchestrator_unit_additional_test.go
|
||||
internal/cluster/orchestrator_workload_recovery_test.go
|
||||
internal/cluster/orchestrator_vault_test.go
|
||||
|
||||
@ -147,9 +147,9 @@ func lifecycleDispatcher(recorder *commandRecorder) func(context.Context, time.D
|
||||
case strings.Contains(command, "jsonpath={.spec.ref.branch}"):
|
||||
return "main", nil
|
||||
case strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":"30s"},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false,"timeout":"30s"},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":"30s"},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
|
||||
case strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
|
||||
return "services\n", nil
|
||||
return "flux-system\nservices\n", nil
|
||||
case strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
|
||||
return "monitoring/grafana\n", nil
|
||||
case strings.Contains(command, "annotate kustomizations.kustomize.toolkit.fluxcd.io"):
|
||||
|
||||
@ -155,6 +155,7 @@ func TestLifecycleStartupFluxImmutableJobSelfHeal(t *testing.T) {
|
||||
cfg := lifecycleConfig(t)
|
||||
cfg.Startup.RequireCriticalServiceEndpoints = false
|
||||
cfg.Startup.RequireWorkloadConvergence = false
|
||||
cfg.Startup.FluxHealthRequiredKustomizations = []string{"flux-system/services"}
|
||||
cfg.Startup.FluxHealthWaitSeconds = 4
|
||||
cfg.Startup.FluxHealthPollSeconds = 1
|
||||
|
||||
@ -186,15 +187,15 @@ func TestLifecycleStartupFluxImmutableJobSelfHeal(t *testing.T) {
|
||||
case strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
fluxCalls++
|
||||
if fluxCalls <= 2 {
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"Job update failed: field is immutable"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"Job update failed: field is immutable"}]}}]}`, nil
|
||||
}
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
case strings.Contains(command, "get jobs -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"reconcile-services","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}]}`, nil
|
||||
case strings.Contains(command, "delete job reconcile-services"):
|
||||
return "", nil
|
||||
case strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
|
||||
return "services\n", nil
|
||||
return "flux-system\nservices\n", nil
|
||||
case strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
|
||||
return "", nil
|
||||
case strings.Contains(command, "patch "), strings.Contains(command, "annotate "):
|
||||
|
||||
@ -32,7 +32,7 @@ func TestHookAccessDeepMatrixReconcileAndSSHAuth(t *testing.T) {
|
||||
}
|
||||
orch, _ := newHookOrchestrator(t, cfg, run, run)
|
||||
err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"})
|
||||
if err == nil || !strings.Contains(err.Error(), "access validation had") || !strings.Contains(err.Error(), "missing sudo access") {
|
||||
if err == nil || !strings.Contains(err.Error(), "access validation had 2 errors") || !strings.Contains(err.Error(), "host-privilege-unavailable") {
|
||||
t.Fatalf("expected aggregated reconcile error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@ -241,7 +241,7 @@ func TestHookAccessVaultLifecycleMatrix(t *testing.T) {
|
||||
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
|
||||
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get endpoints victoria-metrics-single-server"):
|
||||
|
||||
@ -323,7 +323,7 @@ func TestHookStartupConvergenceAndStabilitySaturation(t *testing.T) {
|
||||
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
|
||||
|
||||
@ -253,9 +253,17 @@ func TestHookFluxHealthFailureBranches(t *testing.T) {
|
||||
t.Run("flux-health-ready-and-immutable-job-heal-branches", func(t *testing.T) {
|
||||
cfg := lifecycleConfig(t)
|
||||
cfg.Startup.IgnoreFluxKustomizations = []string{"infra/ignored"}
|
||||
cfg.Startup.FluxHealthRequiredKustomizations = []string{"flux-system/services"}
|
||||
|
||||
fluxItems := map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"metadata": map[string]any{"namespace": "flux-system", "name": "flux-system"},
|
||||
"spec": map[string]any{"suspend": false, "timeout": "30s"},
|
||||
"status": map[string]any{"conditions": []map[string]any{
|
||||
{"type": "Ready", "status": "True", "reason": "ReconciliationSucceeded", "message": "ok"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
"metadata": map[string]any{"namespace": "infra", "name": "ignored"},
|
||||
"spec": map[string]any{"suspend": false, "timeout": "30s"},
|
||||
|
||||
@ -23,6 +23,7 @@ func TestHookFluxHealthAndStorageBranches(t *testing.T) {
|
||||
cfg.Startup.FluxHealthWaitSeconds = 2
|
||||
cfg.Startup.FluxHealthPollSeconds = 1
|
||||
cfg.Startup.IgnoreFluxKustomizations = []string{"flux-system/skip-me"}
|
||||
cfg.Startup.FluxHealthRequiredKustomizations = []string{"flux-system/services"}
|
||||
cfg.Startup.StorageReadyWaitSeconds = 2
|
||||
cfg.Startup.StorageReadyPollSeconds = 1
|
||||
cfg.Startup.StorageMinReadyNodes = 1
|
||||
@ -38,9 +39,9 @@ func TestHookFluxHealthAndStorageBranches(t *testing.T) {
|
||||
recorder.record(name, args)
|
||||
fluxCalls++
|
||||
if fluxCalls <= 1 {
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"Unknown","message":"waiting"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"Unknown","message":"waiting"}]}}]}`, nil
|
||||
}
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
|
||||
recorder.record(name, args)
|
||||
return `{"items":[]}`, nil
|
||||
|
||||
@ -54,8 +54,8 @@ func TestHookLowFileCoverageBoost(t *testing.T) {
|
||||
if !cluster.TestHookPodControllerOwned([]string{"DaemonSet"}) {
|
||||
t.Fatalf("expected DaemonSet owner to be controller-owned")
|
||||
}
|
||||
if cluster.TestHookPodControllerOwned([]string{"Job"}) {
|
||||
t.Fatalf("expected Job owner to be non controller-owned")
|
||||
if !cluster.TestHookPodControllerOwned([]string{"Job"}) {
|
||||
t.Fatalf("expected Job owner to be controller-owned")
|
||||
}
|
||||
|
||||
if got := cluster.TestHookStuckContainerReason([]string{"ImagePullBackOff"}, nil, []string{"ImagePullBackOff"}); got != "ImagePullBackOff" {
|
||||
@ -370,7 +370,7 @@ func TestHookLowFileCoverageBoost(t *testing.T) {
|
||||
command := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"ready"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"ready"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
default:
|
||||
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
|
||||
}
|
||||
@ -382,7 +382,7 @@ func TestHookLowFileCoverageBoost(t *testing.T) {
|
||||
if ready, detail, err := orchFlux.TestHookFluxHealthReady(context.Background()); err != nil || ready || !strings.Contains(detail, "not ready") {
|
||||
t.Fatalf("expected flux health not-ready result, ready=%v detail=%q err=%v", ready, detail, err)
|
||||
}
|
||||
if ready, detail, err := orchNoTimeout.TestHookFluxHealthReady(context.Background()); err != nil || !ready || !strings.Contains(detail, "all kustomizations ready=") {
|
||||
if ready, detail, err := orchNoTimeout.TestHookFluxHealthReady(context.Background()); err != nil || !ready || !strings.Contains(detail, "ready=") {
|
||||
t.Fatalf("expected flux health ready result, ready=%v detail=%q err=%v", ready, detail, err)
|
||||
}
|
||||
if !cluster.TestHookLooksLikeImmutableJobError("Job update failed: field is immutable") {
|
||||
|
||||
@ -124,7 +124,7 @@ func TestHookServiceStabilityLowFunctionMatrix(t *testing.T) {
|
||||
command := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
|
||||
return `{"items":[]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
|
||||
|
||||
@ -283,11 +283,12 @@ func TestHookVaultLifecycleBranchMatrix(t *testing.T) {
|
||||
t.Run("flux-ingress-service-and-inventory-low-branches", func(t *testing.T) {
|
||||
t.Run("flux-health-ready-reason-fallback-and-heal-query-error", func(t *testing.T) {
|
||||
cfg := lifecycleConfig(t)
|
||||
cfg.Startup.FluxHealthRequiredKustomizations = []string{"flux-system/apps"}
|
||||
runReady := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
|
||||
command := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
|
||||
return `{"items":[{"metadata":{"namespace":"","name":"skip"},"spec":{"suspend":false},"status":{"conditions":[]}},{"metadata":{"namespace":"flux-system","name":"apps"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"","message":""}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"","name":"skip"},"spec":{"suspend":false},"status":{"conditions":[]}},{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"apps"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"","message":""}]}}]}`, nil
|
||||
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
|
||||
return "", errors.New("jobs query failed")
|
||||
default:
|
||||
|
||||
@ -226,10 +226,11 @@ func TestHookVaultPostStartBranchMatrix(t *testing.T) {
|
||||
runMissingReady := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
|
||||
command := name + " " + strings.Join(args, " ")
|
||||
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Reconciling","status":"True","reason":"Progressing"}]}}]}`, nil
|
||||
return `{"items":[{"metadata":{"namespace":"flux-system","name":"flux-system"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}},{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Reconciling","status":"True","reason":"Progressing"}]}}]}`, nil
|
||||
}
|
||||
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
|
||||
}
|
||||
cfg.Startup.FluxHealthRequiredKustomizations = []string{"flux-system/services"}
|
||||
orchMissingReady, _ := newHookOrchestrator(t, cfg, runMissingReady, runMissingReady)
|
||||
ready, detail, err := orchMissingReady.TestHookFluxHealthReady(context.Background())
|
||||
if err != nil || ready || !strings.Contains(detail, "ready condition missing") {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user