Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,18 +19,28 @@ 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:
contents: read
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
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions deploy/crds/scheduledmachine.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/src/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/bin/crddoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
8 changes: 8 additions & 0 deletions src/crd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"#,
Expand Down Expand Up @@ -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<String>,
Expand Down
7 changes: 4 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Expand Down
21 changes: 17 additions & 4 deletions src/reconcilers/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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"
);
}
}

Expand Down Expand Up @@ -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()
};

Expand Down Expand Up @@ -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()
};

Expand Down Expand Up @@ -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()
};

Expand Down
149 changes: 149 additions & 0 deletions src/reconcilers/helpers_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading