diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index afd926b..f05cfcf 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -4,6 +4,9 @@ name: Documentation on: + # Build-only triggers — exercise the docs pipeline so breakage is + # caught at PR / merge time, but do NOT publish to GitHub Pages. + # Deployment is gated on the `release` event below. push: branches: - main @@ -16,6 +19,12 @@ on: - 'docs/**' - 'src/**/*.rs' - '.github/workflows/docs.yaml' + # Deploy trigger — fires when an operator publishes a GitHub Release. + # The docs site published to Pages is what end users see, so we tie + # publish cadence to the release cadence: every release ships matching + # docs, and intermediate main commits don't churn the live site. + release: + types: [published] workflow_dispatch: permissions: @@ -23,11 +32,15 @@ permissions: pages: write id-token: write -# Allow one concurrent deployment; cancel any in-progress run on the same -# branch so a rapid series of pushes never leaves a stale deploy running. +# Build runs on every PR / push and can race with itself — cancel +# in-progress to keep CI from queuing redundant builds. Release deploys +# happen at most once per release tag and must NEVER be cancelled by a +# subsequent push (operators rely on the published release deploying); +# the `cancel-in-progress` expression below disables cancellation only +# for the release event. concurrency: - group: "pages" - cancel-in-progress: true + group: pages-${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + cancel-in-progress: ${{ github.event_name != 'release' }} env: CARGO_TERM_COLOR: always @@ -160,19 +173,26 @@ jobs: npm install -g "linkinator@${LINKINATOR_VERSION}" linkinator docs/site/ --recurse --skip "rustdoc/.*" --verbosity error + # Pages setup + artifact upload only happen when the trigger is a + # release publication. PR / push runs build the site (proving it + # compiles cleanly) but never produce a deploy artifact. - name: Setup Pages - if: github.ref == 'refs/heads/main' + if: github.event_name == 'release' uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload Pages artifact - if: github.ref == 'refs/heads/main' + if: github.event_name == 'release' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docs/site deploy: name: 🚀 Deploy to GitHub Pages - if: github.ref == 'refs/heads/main' + # Gated on the `release: published` event — a main push or PR build + # validates the docs but does NOT publish them. The published site + # tracks release cadence so end users see a stable, versioned doc + # set rather than every intermediate main commit. + if: github.event_name == 'release' needs: build runs-on: ubuntu-latest environment: diff --git a/deploy/crds/scheduledmachine.yaml b/deploy/crds/scheduledmachine.yaml index aac4462..0d2b95c 100644 --- a/deploy/crds/scheduledmachine.yaml +++ b/deploy/crds/scheduledmachine.yaml @@ -1,3 +1,5 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s + Running `target/debug/crdgen` apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -17,6 +19,9 @@ spec: - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.ready + name: Ready + type: boolean - jsonPath: .status.inSchedule name: InSchedule type: boolean @@ -446,6 +451,14 @@ spec: unique across the cluster. nullable: true type: string + ready: + default: false + description: |- + True only when the machine has reached the `Active` phase. Surfaced as + the `Ready` printer column for fast operator triage; any other phase + (Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is + reported as `False`. + type: boolean type: object required: - spec diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 7d5c324..6668894 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -196,6 +196,12 @@ Array of condition objects with the following fields: (boolean) Whether the machine is currently within its scheduled time window. +#### ready + +(boolean) `True` only when `phase` is `Active`. Surfaced as the `Ready` printer column +for fast operator triage — any other phase (`Pending`, `ShuttingDown`, `Inactive`, +`Disabled`, `Terminated`, `Error`) is reported as `False`. + #### message (string) Human-readable message describing the current state. diff --git a/src/bin/crddoc.rs b/src/bin/crddoc.rs index 1a777aa..f72ec13 100644 --- a/src/bin/crddoc.rs +++ b/src/bin/crddoc.rs @@ -248,6 +248,14 @@ fn main() { println!(); println!("(boolean) Whether the machine is currently within its scheduled time window."); println!(); + println!("#### ready"); + println!(); + println!( + "(boolean) `True` only when `phase` is `Active`. Surfaced as the `Ready` printer column" + ); + println!("for fast operator triage — any other phase (`Pending`, `ShuttingDown`, `Inactive`,"); + println!("`Disabled`, `Terminated`, `Error`) is reported as `False`."); + println!(); println!("#### message"); println!(); println!("(string) Human-readable message describing the current state."); diff --git a/src/crd.rs b/src/crd.rs index dd72a06..43831e4 100644 --- a/src/crd.rs +++ b/src/crd.rs @@ -33,6 +33,7 @@ use std::collections::{BTreeMap, HashSet}; shortname = "sm", status = "ScheduledMachineStatus", printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#, + printcolumn = r#"{"name":"Ready","type":"boolean","jsonPath":".status.ready"}"#, printcolumn = r#"{"name":"InSchedule","type":"boolean","jsonPath":".status.inSchedule"}"#, printcolumn = r#"{"name":"Enabled","type":"boolean","jsonPath":".spec.schedule.enabled"}"#, printcolumn = r#"{"name":"Schedule Days","type":"string","jsonPath":".spec.schedule.daysOfWeek"}"#, @@ -487,6 +488,13 @@ pub struct ScheduledMachineStatus { #[serde(default)] pub in_schedule: bool, + /// True only when the machine has reached the `Active` phase. Surfaced as + /// the `Ready` printer column for fast operator triage; any other phase + /// (Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is + /// reported as `False`. + #[serde(default)] + pub ready: bool, + /// Next scheduled activation time (RFC3339 format) #[serde(skip_serializing_if = "Option::is_none")] pub next_activation: Option, diff --git a/src/main.rs b/src/main.rs index a9b4e50..5d4619e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -347,10 +347,11 @@ async fn main() -> Result<()> { .run(reconcile_scheduled_machine, error_policy, context) .for_each(|res| async move { match res { - Ok(o) => { + Ok((obj_ref, action)) => { info!( - resource = o.0.name, - namespace = ?o.0.namespace, + resource = %obj_ref.name, + namespace = ?obj_ref.namespace, + next_action = ?action, "Reconciliation completed" ); } diff --git a/src/reconcilers/helpers.rs b/src/reconcilers/helpers.rs index c5f0145..4bb8ec9 100644 --- a/src/reconcilers/helpers.rs +++ b/src/reconcilers/helpers.rs @@ -327,17 +327,19 @@ pub async fn handle_deletion( ReconcilerError::InvalidConfig("ScheduledMachine must be namespaced".to_string()) })?; let name = resource.name_any(); + let current_phase = resource.status.as_ref().and_then(|s| s.phase.as_deref()); info!( resource = %name, namespace = %namespace, - "Handling deletion" + phase = current_phase.unwrap_or("(none)"), + deletion_timestamp = ?resource.meta().deletion_timestamp, + "DELETE: ScheduledMachine deletion requested — running finalizer cleanup" ); // Wrap machine removal in a hard timeout so a hung removal cannot block // namespace deletion or cluster upgrades indefinitely. let cleanup_timeout = Duration::from_secs(FINALIZER_CLEANUP_TIMEOUT_SECS); - let current_phase = resource.status.as_ref().and_then(|s| s.phase.as_deref()); if let Some(phase) = current_phase { if matches!(phase, PHASE_ACTIVE | PHASE_SHUTTING_DOWN) { @@ -435,7 +437,7 @@ pub async fn handle_deletion( info!( resource = %name, namespace = %namespace, - "Finalizer removed, resource will be deleted" + "DELETE: finalizer removed — Kubernetes will now delete the ScheduledMachine" ); Ok(Action::await_change()) @@ -482,10 +484,18 @@ pub async fn handle_kill_switch( info!( resource = %name, namespace = %namespace, - "Kill switch active - removing machine immediately" + phase = %phase, + "KILL: removing CAPI Machine due to kill switch" ); remove_machine_from_cluster(&resource, &ctx.client, &namespace).await?; + } else { + info!( + resource = %name, + namespace = %namespace, + phase = %phase, + "KILL: kill switch set but machine not running — recording Terminated phase only" + ); } } @@ -735,6 +745,7 @@ pub async fn update_phase( message: Some(resolved_message.to_string()), conditions: vec![condition], in_schedule, + ready: phase == PHASE_ACTIVE, ..Default::default() }; @@ -808,6 +819,7 @@ pub async fn update_phase_with_last_schedule( conditions: vec![condition], last_scheduled_time: Some(Utc::now().to_rfc3339()), in_schedule, + ready: phase == PHASE_ACTIVE, ..Default::default() }; @@ -880,6 +892,7 @@ pub async fn update_phase_with_grace_period( message: Some(resolved_message.to_string()), conditions: vec![condition], in_schedule, + ready: phase == PHASE_ACTIVE, ..Default::default() }; diff --git a/src/reconcilers/helpers_tests.rs b/src/reconcilers/helpers_tests.rs index 28c99b8..e27cb44 100644 --- a/src/reconcilers/helpers_tests.rs +++ b/src/reconcilers/helpers_tests.rs @@ -854,6 +854,155 @@ mod tests { srv.await.unwrap(); } + // ---- ready-field projection on status patch ---- + + #[tokio::test] + async fn test_update_phase_active_sets_ready_true() { + let (client, handle) = mock_client_pair(); + let ctx = make_test_context(client); + + let srv = tokio::spawn(async move { + let mut h = pin!(handle); + let (_req, send) = h.next_request().await.expect("events call"); + send.send_response( + Response::builder() + .status(201) + .header("content-type", "application/json") + .body(Body::from(k8s_event_response_body())) + .unwrap(), + ); + let (req, send) = h.next_request().await.expect("patch_status call"); + let body = collect_json_body(req.into_body()).await; + assert_eq!(body["status"]["phase"], "Active"); + assert_eq!( + body["status"]["ready"], true, + "ready must be True when phase is Active" + ); + send.send_response( + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(sm_response_body("test-sm", "default", "Active"))) + .unwrap(), + ); + }); + + update_phase( + &ctx, + "default", + "test-sm", + Some("Pending"), + "Active", + None, + None, + true, + ) + .await + .expect("update_phase should succeed"); + + srv.await.unwrap(); + } + + #[tokio::test] + async fn test_update_phase_inactive_sets_ready_false() { + let (client, handle) = mock_client_pair(); + let ctx = make_test_context(client); + + let srv = tokio::spawn(async move { + let mut h = pin!(handle); + let (_req, send) = h.next_request().await.expect("events call"); + send.send_response( + Response::builder() + .status(201) + .header("content-type", "application/json") + .body(Body::from(k8s_event_response_body())) + .unwrap(), + ); + let (req, send) = h.next_request().await.expect("patch_status call"); + let body = collect_json_body(req.into_body()).await; + assert_eq!(body["status"]["phase"], "Inactive"); + assert_eq!( + body["status"]["ready"], false, + "ready must be False for any non-Active phase" + ); + send.send_response( + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(sm_response_body( + "test-sm", "default", "Inactive", + ))) + .unwrap(), + ); + }); + + update_phase( + &ctx, + "default", + "test-sm", + Some("Active"), + "Inactive", + None, + None, + false, + ) + .await + .expect("update_phase should succeed"); + + srv.await.unwrap(); + } + + #[tokio::test] + async fn test_update_phase_with_grace_period_shutting_down_sets_ready_false() { + let (client, handle) = mock_client_pair(); + let ctx = make_test_context(client); + + let srv = tokio::spawn(async move { + let mut h = pin!(handle); + let (_req, send) = h.next_request().await.expect("events call"); + send.send_response( + Response::builder() + .status(201) + .header("content-type", "application/json") + .body(Body::from(k8s_event_response_body())) + .unwrap(), + ); + let (req, send) = h.next_request().await.expect("patch_status call"); + let body = collect_json_body(req.into_body()).await; + assert_eq!(body["status"]["phase"], "ShuttingDown"); + assert_eq!( + body["status"]["ready"], false, + "ready must be False during ShuttingDown" + ); + send.send_response( + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(sm_response_body( + "test-sm", + "default", + "ShuttingDown", + ))) + .unwrap(), + ); + }); + + update_phase_with_grace_period( + &ctx, + "default", + "test-sm", + Some("Active"), + "ShuttingDown", + None, + None, + false, + ) + .await + .expect("grace period update should succeed"); + + srv.await.unwrap(); + } + // ---- Context::new — unit tests ---- #[tokio::test] diff --git a/src/reconcilers/scheduled_machine.rs b/src/reconcilers/scheduled_machine.rs index 4fcb266..6a17c4a 100644 --- a/src/reconcilers/scheduled_machine.rs +++ b/src/reconcilers/scheduled_machine.rs @@ -305,14 +305,36 @@ async fn reconcile_guarded( return Ok(Action::await_change()); } + let observed_phase = resource + .status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .unwrap_or("(none)"); + let generation = resource.metadata.generation.unwrap_or(0); + let deleting = resource.meta().deletion_timestamp.is_some(); info!( resource = %name, namespace = %namespace, reconcile_id = %reconcile_id, priority = resource.spec.priority, + phase = %observed_phase, + generation = generation, + deleting = deleting, + kill_switch = resource.spec.kill_switch, "Starting reconciliation" ); + // First-observation marker: a fresh CR has no status sub-resource yet, so + // operators staring at the log expect a clear "I saw this thing get + // created" line — not just the generic reconcile header. + if resource.status.is_none() { + info!( + resource = %name, + namespace = %namespace, + "ScheduledMachine observed for the first time (creation)" + ); + } + // Check if this instance should process this resource if !should_process_resource( &name, @@ -441,7 +463,9 @@ async fn reconcile_inner( info!( resource = %name, namespace = %namespace, - "Kill switch activated - removing machine immediately" + phase = ?current_phase, + cause = "kill_switch", + "KILL: kill switch active — removing machine immediately" ); KILL_SWITCH_ACTIVATIONS_TOTAL.inc(); return handle_kill_switch(resource, ctx).await; @@ -460,12 +484,18 @@ async fn reconcile_inner( // Record schedule evaluation metric record_schedule_evaluation(should_be_active); - debug!( + // Emitted on every reconcile (timer-driven or event-driven) so operators + // can confirm the controller is awake and tracking the schedule for each + // SM. Demoting this back to debug hides 60-second timer fires from + // routine `kubectl logs` triage. + info!( resource = %name, namespace = %namespace, should_be_active = should_be_active, enabled = resource.spec.schedule.enabled, - "Schedule evaluation" + phase = ?current_phase, + timezone = %resource.spec.schedule.timezone, + "Schedule evaluated" ); // Handle state transitions based on current phase and schedule @@ -536,7 +566,8 @@ async fn check_emergency_reclaim( resource = %resource.name_any(), node = %node_name, reason = request.reason.as_deref().unwrap_or("(none)"), - "Reclaim annotation observed — engaging emergency remove" + cause = "kill_if_commands", + "KILL: reclaim annotation observed (kill_if_commands triggered) — engaging emergency remove" ); let action = super::helpers::handle_emergency_remove( Arc::clone(resource), @@ -734,8 +765,7 @@ async fn handle_active_phase( // to the underlying VM and Node without manual cross-referencing. // Best-effort: if the fetch or patch fails, log and continue — status // enrichment must never block the reconcile. - let machine_name = format!("{name}-machine"); - match fetch_capi_machine(&ctx.client, &namespace, &machine_name).await { + match fetch_capi_machine(&ctx.client, &namespace, &name).await { Ok(Some(machine)) => { let (provider_id, node_ref) = extract_machine_refs(&machine); if let Err(e) = patch_machine_refs_status( @@ -962,10 +992,7 @@ async fn handle_shutting_down_phase( info!(resource = %name, namespace = %namespace, "Grace period elapsed - draining node and removing machine"); // Step 1: Drain the node if it exists - let machine_name = format!("{name}-machine"); - if let Some(node_name) = - get_node_from_machine(&ctx.client, &namespace, &machine_name).await? - { + if let Some(node_name) = get_node_from_machine(&ctx.client, &namespace, &name).await? { info!( resource = %name, namespace = %namespace,