Skip to content

feat(rack): make maintenance requests atomic and idempotent#3497

Closed
tmcroberts97 wants to merge 1 commit into
NVIDIA:mainfrom
tmcroberts97:rack/maintenance-scheduler
Closed

feat(rack): make maintenance requests atomic and idempotent#3497
tmcroberts97 wants to merge 1 commit into
NVIDIA:mainfrom
tmcroberts97:rack/maintenance-scheduler

Conversation

@tmcroberts97

Copy link
Copy Markdown
Contributor

Add a durable rack maintenance request lifecycle, stage request-scoped credentials outside database transactions, and track request completion through the rack state controller.

Related issues

Type of Change

  • Add - New feature or capability
  • Change - Changes in existing functionality
  • Fix - Bug fixes
  • Remove - Removed features or deprecated functionality
  • Internal - Internal changes (refactoring, tests, docs, etc.)

Breaking Changes

  • This PR contains breaking changes

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed
  • No testing required (docs, internal refactor, etc.)

Additional Notes

Add a durable rack maintenance request lifecycle, stage request-scoped credentials outside database transactions, and track request completion through the rack state controller.

Signed-off-by: Thomas McRoberts <tmcroberts@nvidia.com>
@tmcroberts97 tmcroberts97 requested a review from a team as a code owner July 14, 2026 18:13
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features
    • Added tracking for on-demand rack maintenance requests through their full lifecycle.
    • Improved maintenance scheduling with request-specific access credentials and safer duplicate-request handling.
    • Added clearer handling for busy, deferred, failed, cancelled, and completed maintenance operations.
  • Bug Fixes
    • Ensured maintenance credentials are correctly cleaned up for individual requests.
    • Improved error reporting and state consistency when maintenance operations fail.
  • Tests
    • Added coverage for successful, failed, concurrent, and retry maintenance scenarios.

Walkthrough

The PR adds persisted first-class rack maintenance requests, request-scoped credential handling, transactional scheduling through the state controller, and lifecycle updates across rack maintenance, error, completion, and reprovisioning paths.

Changes

Rack maintenance request lifecycle

Layer / File(s) Summary
Request contracts and persistence
crates/api-model/src/rack.rs, crates/api-db/..., crates/api-db/migrations/...
Adds request status/model types, rack configuration linkage, database schema, row locking, and guarded lifecycle transitions.
Request-scoped credentials and scheduling
crates/component-manager/..., crates/secrets/..., crates/rack/src/firmware_object.rs
Adds request-specific credential keys and transactional scheduling with eligibility, idempotency, token staging, cleanup, and concurrency tests.
API maintenance integration
crates/api-core/src/handlers/...
Routes on-demand maintenance through the state controller and maps outcomes and errors to API responses.
Controller lifecycle tracking
crates/rack-controller/src/...
Persists running, cancelled, completed, and failed request states across rack transitions and request-specific token cleanup.
Lifecycle integration validation
crates/api-core/src/tests/rack_state_controller/handler.rs
Tests successful and failed first-class maintenance request execution and persisted state changes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant API
  participant ComponentManager
  participant Database
  participant RackController
  participant CredentialStore

  API->>ComponentManager: request rack maintenance
  ComponentManager->>Database: reserve and prepare request
  ComponentManager->>CredentialStore: stage request access token
  ComponentManager->>Database: publish ready request and rack request ID
  RackController->>Database: mark request running
  RackController->>Database: mark request completed or failed
  RackController->>CredentialStore: delete request access token
Loading

Possibly related PRs

Suggested labels: rack lifecycle

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main change: atomic, idempotent rack maintenance request handling.
Description check ✅ Passed The description is directly aligned with the changeset and summarizes the new maintenance lifecycle and state-controller flow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/api-core/src/handlers/rack.rs (1)

574-591: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Stale pre-check at Lines 585-591 breaks the idempotent retry this PR adds.

request_rack_maintenance_via_state_controller (called below at 778-815) is explicitly designed to return AlreadyPending for a same-scope retry — a success, not an error. But the leftover pre-check at Line 585 unconditionally rejects the call whenever any maintenance is already requested on the rack, regardless of scope, and it runs before scope is even parsed (Line 659), so it structurally cannot special-case idempotent retries. Every legitimate retry of an in-flight on-demand maintenance request will now fail here before ever reaching the atomic scheduler that was built to accept it.

The eligibility pre-check at Lines 574-583 is also now redundant (it duplicates the identical Ready | Error criterion enforced atomically inside request_rack_maintenance_via_state_controller), but it's at least harmless; only Lines 585-591 introduce an actual regression.

🐛 Proposed fix: drop the stale non-transactional "already scheduled" rejection
-    if rack.config.maintenance_requested.is_some() {
-        return Err(CarbideError::InvalidArgument(format!(
-            "On-demand maintenance for rack {} is already scheduled.",
-            rack_id,
-        ))
-        .into());
-    }
-

Also applies to: 778-815

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/handlers/rack.rs` around lines 574 - 591, Remove the
unconditional maintenance_requested pre-check in the rack maintenance handler
before scope parsing, specifically the rejection around
request_rack_maintenance_via_state_controller, so same-scope retries can reach
the atomic scheduler and return AlreadyPending successfully. Leave the existing
state eligibility check unchanged unless needed by the scheduler flow.
🧹 Nitpick comments (5)
crates/api-core/src/tests/rack_state_controller/handler.rs (1)

1330-1344: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing assertion on the intermediate Ready → Maintenance transition.

Unlike the success test (Line 1253-1259), the first handle_object_state call's outcome here is committed but never asserted before the hardcoded firmware_state is fed into the second call. This means a regression in first_maintenance_state(scope)'s mapping from a FirmwareUpgrade activity to RackMaintenanceState::FirmwareUpgrade { rack_firmware_upgrade: FirmwareUpgradeState::Start } (in ready.rs) would go undetected — the test would still "pass" because the second call blindly assumes that sub-state rather than deriving it from the real outcome.

♻️ Proposed fix to assert the intermediate transition
     let mut outcome = handler
         .handle_object_state(&rack_id, &mut rack, &RackState::Ready, &mut ctx)
         .await?;
+    assert!(matches!(
+        &outcome,
+        StateHandlerOutcome::Transition {
+            next_state: RackState::Maintenance {
+                maintenance_state: RackMaintenanceState::FirmwareUpgrade {
+                    rack_firmware_upgrade: FirmwareUpgradeState::Start,
+                },
+            },
+            ..
+        }
+    ));
     if let Some(txn) = outcome.take_transaction() {
         txn.commit().await?;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/tests/rack_state_controller/handler.rs` around lines 1330
- 1344, In the test around the first handle_object_state call, assert that the
Ready → Maintenance outcome contains the expected FirmwareUpgrade maintenance
state before committing it. Replace the hardcoded firmware_state input with the
maintenance state derived from that asserted intermediate outcome, while
preserving the existing transaction commit and subsequent transition coverage.
crates/api-db/src/rack_maintenance_request.rs (2)

25-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

find_by_id should accept impl DbReader, not a bare PgConnection.

This is a plain, non-locking read. As per path instructions, "For read-only database functions, prefer accepting impl DbReader so callers can use a PgPool without starting a transaction." crates/api-db/src/rack.rs::find_by already establishes this pattern in the same crate; forcing &mut PgConnection here needlessly requires callers (e.g. an API handler that just wants request status) to hold a connection/transaction.

♻️ Proposed refactor to mirror `find_by`
 pub async fn find_by_id(
-    conn: &mut PgConnection,
+    conn: &mut DB,
     request_id: Uuid,
-) -> DatabaseResult<Option<RackMaintenanceRequest>> {
+) -> DatabaseResult<Option<RackMaintenanceRequest>>
+where
+    for<'db> &'db mut DB: DbReader<'db>,
+{
     let query = "SELECT * FROM rack_maintenance_requests WHERE id = $1";
     sqlx::query_as(query)
         .bind(request_id)
-        .fetch_optional(conn)
+        .fetch_optional(&mut *conn)
         .await
         .map_err(|error| DatabaseError::new(query, error))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-db/src/rack_maintenance_request.rs` around lines 25 - 35, Update
rack maintenance request find_by_id to accept the shared DbReader abstraction
instead of &mut PgConnection, matching the existing rack.rs find_by pattern.
Preserve the query, binding, optional-result behavior, and DatabaseError mapping
while allowing callers to provide a PgPool directly.

Source: Path instructions


104-178: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate mark_failed/mark_preparing_failed/mark_cancelled into the transition() helper.

These three functions duplicate the same SET status = ..., error_message = $2, updated = now(), completed = now() template, differing only in target status and eligible source states via status IN (...). Generalizing transition() to accept a slice of expected statuses (bound as a Postgres array with status = ANY($2)) would remove the duplication and the risk of the three copies drifting apart.

♻️ Sketch of a unified helper
async fn transition_from_any(
    conn: &mut PgConnection,
    request_id: Uuid,
    expected: &[RackMaintenanceRequestStatus],
    next: RackMaintenanceRequestStatus,
    error_message: Option<&str>,
) -> DatabaseResult<bool> {
    let terminal = next.is_terminal();
    let query = "UPDATE rack_maintenance_requests \
                 SET status = $3, error_message = $4, updated = now(), \
                     started = CASE WHEN $3 = 'running' THEN now() ELSE started END, \
                     completed = CASE WHEN $5 THEN now() ELSE completed END \
                 WHERE id = $1 AND status = ANY($2)";
    sqlx::query(query)
        .bind(request_id)
        .bind(expected)
        .bind(next)
        .bind(error_message)
        .bind(terminal)
        .execute(conn)
        .await
        .map(|result| result.rows_affected() == 1)
        .map_err(|error| DatabaseError::new(query, error))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-db/src/rack_maintenance_request.rs` around lines 104 - 178,
Consolidate mark_failed, mark_preparing_failed, and mark_cancelled by
generalizing transition to accept a slice of expected
RackMaintenanceRequestStatus values and use status = ANY($2). Update each
wrapper to call the helper with its eligible source statuses, target status, and
error message, preserving their existing behavior and removing the duplicated
SQL implementations.
crates/secrets/src/credentials.rs (1)

1209-1214: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the exact request-scoped Vault path.

This row only checks that the path starts with racks/, so it would pass if the request ID were omitted or the path segments were reordered. Assert the complete path for Uuid::nil(), including maintenance/requests/{request_id}/access-token.

As per the supplied credential-path contract, this path is shared by request staging and controller credential loading/deletion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/secrets/src/credentials.rs` around lines 1209 - 1214, Update the test
scenario for CredentialKey::RackMaintenanceRequestAccessToken to assert the
complete Vault path for Uuid::nil(), including the rack identifier and the
ordered maintenance/requests/{request_id}/access-token segments. Replace the
current prefix-only assertion while preserving the shared path expectation used
by request staging and controller credential loading/deletion.
crates/rack-controller/src/maintenance.rs (1)

216-258: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Three call sites duplicate the "clear maintenance request in this transaction" sequence.

clear_maintenance_requested_on_error (216-258) already encapsulates "take maintenance_request_id, clear maintenance_requested, mark the request, persist config" — but the firmware-failure (1688-1711), NVOS-failure (2013-2029), and Completed (2528-2536) branches each re-implement the same ~8-line sequence inline, since they need it to land in the same commit as their job-status writes. Any future fix to this bookkeeping (e.g., a new invariant check) now needs to be applied in 4 places.

Consider extracting a small helper that operates on an already-open &mut PgConnection (mirroring mark_maintenance_request_failed/mark_maintenance_request_completed), e.g.:

async fn clear_maintenance_requested_in_txn(
    txn: &mut sqlx::PgConnection,
    id: &RackId,
    state: &mut Rack,
    outcome: RequestOutcome<'_>, // Failed(&cause) | Completed
) -> Result<(), StateHandlerError> {
    if state.config.maintenance_requested.is_none() && state.config.maintenance_request_id.is_none() {
        return Ok(());
    }
    let request_id = state.config.maintenance_request_id.take();
    state.config.maintenance_requested = None;
    if let Some(request_id) = request_id {
        match outcome {
            RequestOutcome::Failed(cause) => mark_maintenance_request_failed(txn, request_id, cause).await?,
            RequestOutcome::Completed => mark_maintenance_request_completed(txn, request_id).await?,
        }
    }
    db_rack::update(txn, id, &state.config).await?;
    Ok(())
}

Each of the three call sites would then just call this helper on their already-open txn before persisting their job status.

Also applies to: 1688-1711, 2013-2029, 2528-2536

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rack-controller/src/maintenance.rs` around lines 216 - 258, Extract
the duplicated maintenance-request cleanup into a transaction-scoped helper near
clear_maintenance_requested_on_error, accepting the open PgConnection, rack
identifier, mutable Rack state, and failed/completed outcome. Centralize taking
maintenance_request_id, clearing maintenance_requested, invoking
mark_maintenance_request_failed or mark_maintenance_request_completed, and
calling db_rack::update. Replace the inline sequences in the firmware-failure,
NVOS-failure, and Completed branches, while preserving their existing
transaction ordering with job-status writes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql`:
- Line 12: Update the rack_id foreign key in the maintenance request migration
to prevent rack deletion from removing maintenance history: replace ON DELETE
CASCADE with ON DELETE RESTRICT, preserving terminal request rows when
admin_force_delete_rack hard-deletes a rack.

In `@crates/component-manager/src/component_manager.rs`:
- Around line 133-181: When superseding a stale request in the existing
`Preparing` branch, also delete its request-scoped credential using the key
derived from prepare_rack_id and existing.id. Perform the credential cleanup
after the transaction commits, preserving the current mark_preparing_failed
error handling and avoiding deletion before the request state update is
committed.

In `@crates/rack-controller/src/error_state.rs`:
- Around line 65-72: The Error-state transition around mark_running must allow
an already-Running maintenance request to recover instead of rejecting it as
“not ready to run.” Update the maintenance request handling in the Error state
to treat Running as idempotently active, or otherwise clear/mark the request
appropriately when entering Error, while preserving the Ready-to-Running
transition; add a regression test covering recovery with an existing Running
request.

---

Outside diff comments:
In `@crates/api-core/src/handlers/rack.rs`:
- Around line 574-591: Remove the unconditional maintenance_requested pre-check
in the rack maintenance handler before scope parsing, specifically the rejection
around request_rack_maintenance_via_state_controller, so same-scope retries can
reach the atomic scheduler and return AlreadyPending successfully. Leave the
existing state eligibility check unchanged unless needed by the scheduler flow.

---

Nitpick comments:
In `@crates/api-core/src/tests/rack_state_controller/handler.rs`:
- Around line 1330-1344: In the test around the first handle_object_state call,
assert that the Ready → Maintenance outcome contains the expected
FirmwareUpgrade maintenance state before committing it. Replace the hardcoded
firmware_state input with the maintenance state derived from that asserted
intermediate outcome, while preserving the existing transaction commit and
subsequent transition coverage.

In `@crates/api-db/src/rack_maintenance_request.rs`:
- Around line 25-35: Update rack maintenance request find_by_id to accept the
shared DbReader abstraction instead of &mut PgConnection, matching the existing
rack.rs find_by pattern. Preserve the query, binding, optional-result behavior,
and DatabaseError mapping while allowing callers to provide a PgPool directly.
- Around line 104-178: Consolidate mark_failed, mark_preparing_failed, and
mark_cancelled by generalizing transition to accept a slice of expected
RackMaintenanceRequestStatus values and use status = ANY($2). Update each
wrapper to call the helper with its eligible source statuses, target status, and
error message, preserving their existing behavior and removing the duplicated
SQL implementations.

In `@crates/rack-controller/src/maintenance.rs`:
- Around line 216-258: Extract the duplicated maintenance-request cleanup into a
transaction-scoped helper near clear_maintenance_requested_on_error, accepting
the open PgConnection, rack identifier, mutable Rack state, and failed/completed
outcome. Centralize taking maintenance_request_id, clearing
maintenance_requested, invoking mark_maintenance_request_failed or
mark_maintenance_request_completed, and calling db_rack::update. Replace the
inline sequences in the firmware-failure, NVOS-failure, and Completed branches,
while preserving their existing transaction ordering with job-status writes.

In `@crates/secrets/src/credentials.rs`:
- Around line 1209-1214: Update the test scenario for
CredentialKey::RackMaintenanceRequestAccessToken to assert the complete Vault
path for Uuid::nil(), including the rack identifier and the ordered
maintenance/requests/{request_id}/access-token segments. Replace the current
prefix-only assertion while preserving the shared path expectation used by
request staging and controller credential loading/deletion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6dc07a22-c6f4-4adb-bc49-b8433968c7a8

📥 Commits

Reviewing files that changed from the base of the PR and between 536bb26 and a96a1cd.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • crates/api-core/src/handlers/component_manager.rs
  • crates/api-core/src/handlers/rack.rs
  • crates/api-core/src/tests/rack_state_controller/handler.rs
  • crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql
  • crates/api-db/src/lib.rs
  • crates/api-db/src/rack.rs
  • crates/api-db/src/rack_maintenance_request.rs
  • crates/api-model/src/rack.rs
  • crates/component-manager/Cargo.toml
  • crates/component-manager/src/component_manager.rs
  • crates/rack-controller/Cargo.toml
  • crates/rack-controller/src/error_state.rs
  • crates/rack-controller/src/maintenance.rs
  • crates/rack-controller/src/ready.rs
  • crates/rack/Cargo.toml
  • crates/rack/src/firmware_object.rs
  • crates/secrets/Cargo.toml
  • crates/secrets/src/credentials.rs


CREATE TABLE rack_maintenance_requests (
id uuid PRIMARY KEY,
rack_id varchar(64) NOT NULL REFERENCES racks(id) ON DELETE CASCADE,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether racks are ever hard-deleted (vs. soft-deleted) in the codebase.
rg -nP --type=rust '\bDELETE\s+FROM\s+racks\b' -i
rg -nP --type=rust 'fn\s+\w*force_delete\w*' -C5 crates/api-db crates/api-core

Repository: NVIDIA/infra-controller

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- migration ---'
cat -n crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql | sed -n '1,220p'

echo '--- rack module outline ---'
ast-grep outline crates/api-db/src/rack.rs --view expanded || true

echo '--- rack soft-delete / delete-related symbols ---'
rg -n -C3 'deleted\s+IS\s+NULL|deleted\b|remove_rack|delete_rack|hard_delete|force_delete|purge|DROP\s+.*racks|DELETE\s+FROM\s+racks' crates/api-db crates/api-core crates/api* || true

Repository: NVIDIA/infra-controller

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql | sed -n '1,220p'

echo '---'
ast-grep outline crates/api-db/src/rack.rs --view expanded || true

echo '---'
rg -n -C3 'deleted\s+IS\s+NULL|deleted\b|remove_rack|delete_rack|hard_delete|force_delete|purge|DROP\s+.*racks|DELETE\s+FROM\s+racks' crates/api-db crates/api-core crates/api* || true

Repository: NVIDIA/infra-controller

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- hard-delete search across repo ---'
rg -n -C2 -i '\bDELETE\s+FROM\s+racks\b|force_delete|hard_delete|purge.*rack|delete.*rack' . || true

echo '--- rack model / delete semantics ---'
rg -n -C3 -i 'deleted\s+IS\s+NULL|soft[- ]delete|deleted_at|deleted\b' crates/api-db/src crates/api-core/src crates/api-web/src || true

echo '--- migration file excerpt ---'
sed -n '1,120p' crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql

Repository: NVIDIA/infra-controller

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- handlers/rack.rs: force delete slice ---'
sed -n '182,260p' crates/api-core/src/handlers/rack.rs

echo '--- api-db/src/rack.rs: delete helpers slice ---'
sed -n '180,260p' crates/api-db/src/rack.rs

echo '--- migration slice ---'
sed -n '1,80p' crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql

Repository: NVIDIA/infra-controller

Length of output: 6741


Do not cascade rack deletions into maintenance history. admin_force_delete_rack hard-deletes racks, so ON DELETE CASCADE will erase the terminal request rows that are supposed to remain for audit. Use ON DELETE RESTRICT or keep the rack row as a tombstone instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-db/migrations/20260714120000_rack_maintenance_requests.sql` at
line 12, Update the rack_id foreign key in the maintenance request migration to
prevent rack deletion from removing maintenance history: replace ON DELETE
CASCADE with ON DELETE RESTRICT, preserving terminal request rows when
admin_force_delete_rack hard-deletes a rack.

Comment on lines +133 to +181
if let Some(existing) =
db::rack_maintenance_request::find_active_for_rack_for_update(
txn.as_mut(),
&prepare_rack_id,
)
.await
.map_err(|error| ComponentManagerError::Internal(error.to_string()))?
{
if existing.status != RackMaintenanceRequestStatus::Preparing {
return Ok(Preparation::Complete(if existing.scope == prepare_scope {
RackMaintenanceRequestOutcome::AlreadyPending {
request_id: Some(existing.id),
}
} else {
RackMaintenanceRequestOutcome::Busy
}));
}

let preparation_age =
chrono::Utc::now().signed_duration_since(existing.updated);
if preparation_age < chrono::Duration::minutes(5) {
return Ok(Preparation::Complete(if existing.scope == prepare_scope {
RackMaintenanceRequestOutcome::AlreadyPending {
request_id: Some(existing.id),
}
} else {
RackMaintenanceRequestOutcome::Busy
}));
}

if !db::rack_maintenance_request::mark_preparing_failed(
txn.as_mut(),
existing.id,
"rack maintenance request preparation timed out",
)
.await
.map_err(|error| ComponentManagerError::Internal(error.to_string()))?
{
return Err(ComponentManagerError::Internal(format!(
"stale rack maintenance request {} could not be failed",
existing.id
)));
}
tracing::warn!(
rack_id = %prepare_rack_id,
request_id = %existing.id,
"Superseding stale rack maintenance request preparation"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether the row returned by find_active_for_rack_for_update exposes
# requires_access_token, and whether any other path deletes credentials for
# requests that are superseded/timed-out rather than explicitly failed by
# their own caller.
rg -n -A5 -B5 'requires_access_token' crates/api-db/src/rack_maintenance_request.rs
rg -n 'fn find_active_for_rack_for_update' -A 30 crates/api-db/src/rack_maintenance_request.rs

Repository: NVIDIA/infra-controller

Length of output: 2060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant symbols and read the smallest useful slices.
ast-grep outline crates/component-manager/src/component_manager.rs --view expanded || true
ast-grep outline crates/api-db/src/rack_maintenance_request.rs --view expanded || true
rg -n "cleanup_rack_maintenance_access_token|cleanup_credential|rack_maintenance_access_token_key|requires_access_token|mark_preparing_failed|Preparing" crates -g '!**/target/**'

Repository: NVIDIA/infra-controller

Length of output: 6581


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '90,240p' crates/component-manager/src/component_manager.rs
printf '\n----\n'
sed -n '340,480p' crates/component-manager/src/component_manager.rs
printf '\n----\n'
sed -n '100,180p' crates/api-db/src/rack_maintenance_request.rs

Repository: NVIDIA/infra-controller

Length of output: 14108


Delete the request-scoped credential when superseding a stale Preparing request.
mark_preparing_failed() only updates the row; it leaves the credential at rack_maintenance_access_token_key(rack_id, Some(existing.id)) in place, so a crashed/hung staging attempt can leave a live secret behind indefinitely. Clean it up after commit, or add a terminal-request reaper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/component-manager/src/component_manager.rs` around lines 133 - 181,
When superseding a stale request in the existing `Preparing` branch, also delete
its request-scoped credential using the key derived from prepare_rack_id and
existing.id. Perform the credential cleanup after the transaction commits,
preserving the current mark_preparing_failed error handling and avoiding
deletion before the request state update is committed.

Comment on lines +65 to +72
let mut txn = ctx.services.db_pool.begin().await?;
if let Some(request_id) = config.maintenance_request_id
&& !db::rack_maintenance_request::mark_running(txn.as_mut(), request_id).await?
{
return Err(StateHandlerError::GenericError(eyre::eyre!(
"rack maintenance request {request_id} is not ready to run"
)));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not reapply the Ready -> Running gate to an already-running request.

mark_running only accepts requests in Ready. Since the Ready handler leaves maintenance_requested set until maintenance completes, a request that is already Running may reach Error with the same scope; this branch then returns “not ready to run” and blocks recovery. Treat Running as idempotently active, or mark the request failed/clear the scope when entering Error, and add a regression test.

As per path instructions, controller logic must handle state-machine transitions and safe recovery from partial failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rack-controller/src/error_state.rs` around lines 65 - 72, The
Error-state transition around mark_running must allow an already-Running
maintenance request to recover instead of rejecting it as “not ready to run.”
Update the maintenance request handling in the Error state to treat Running as
idempotently active, or otherwise clear/mark the request appropriately when
entering Error, while preserving the Ready-to-Running transition; add a
regression test covering recovery with an existing Running request.

@github-actions

Copy link
Copy Markdown

🔍 Container Scan Summary

No Grype artifacts were found to aggregate.

@tmcroberts97

Copy link
Copy Markdown
Contributor Author

@Matthias247 is designing something here which is aiming to solve the same problem this PR is. Closing to avoid duplicated effort, will go for a different approach in #3249.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant