fix(ops): align audit result constraints, action history parsing, and startup reconciliation (#21)

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-09-21 21:28:53 -04:00 committed by GitHub
parent 2b93d3ff98
commit f20c1d13e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 90 additions and 7 deletions

View File

@ -0,0 +1,52 @@
"""align ops audit constraints and reason_code length
Revision ID: f1a2b3c4d5e9
Revises: f1a2b3c4d5e8
Create Date: 2026-09-21
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e9'
down_revision = 'f1a2b3c4d5e8'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Update audit_result check constraint to include 'failed'
op.drop_constraint("audit_result", "audit_events", schema="ops")
op.create_check_constraint(
"audit_result",
"audit_events",
"result IN ('success', 'denied', 'failed')",
schema="ops",
)
# 2. Expand reason_code to VARCHAR(64) to match models and prevent truncation
op.alter_column(
"audit_events",
"reason_code",
type_=sa.String(length=64),
existing_type=sa.String(length=32),
schema="ops",
)
def downgrade() -> None:
op.alter_column(
"audit_events",
"reason_code",
type_=sa.String(length=32),
existing_type=sa.String(length=64),
schema="ops",
)
op.drop_constraint("audit_result", "audit_events", schema="ops")
op.create_check_constraint(
"audit_result",
"audit_events",
"result IN ('success', 'denied')",
schema="ops",
)

View File

@ -2882,6 +2882,7 @@ def ops_create_action(
actor_identity=operator.identity,
actor_role=operator.role,
target_resource=f"action:{action.id}",
reason_code=(action.failure_code or "")[:64] if action.state != "succeeded" else None,
result="success" if action.state == "succeeded" else "failed",
metadata={
"action_type": action.action_type,

View File

@ -197,11 +197,19 @@ def reconcile_stale_actions(db: DbSession) -> int:
for act in stale_actions:
if act.action_type in ("restart_api", "reboot_server"):
# The API is currently running and processing requests, which confirms the restart/reboot completed.
# Only actions that were actively in-flight (running/verifying) completed across restart.
# Actions merely queued or in preflight never executed before the system restarted.
if act.state in ("running", "verifying"):
act.state = "succeeded"
act.completion_timestamp = now
act.status_message = f"Reconciled upon system startup; {act.action_type} verified operational."
count += 1
else:
act.state = "failed"
act.completion_timestamp = now
act.failure_code = "interrupted_before_execution"
act.status_message = f"Action was interrupted before execution by a system restart."
count += 1
else:
# Other actions left running when process terminated were interrupted
act.state = "failed"

View File

@ -480,6 +480,21 @@ class TestOpsActions(unittest.TestCase):
self.assertEqual(act_deploy.state, "failed")
self.assertEqual(act_deploy.failure_code, "interrupted_by_restart")
def test_reconciliation_queued_action_fails_on_restart(self):
import ops_actions_service
with DbSession(self.engine) as db:
# Action that was merely queued should NOT report succeeded
act_queued_reboot = OpsAction(id=uuid.uuid4(), action_type="reboot_server", requested_by_id=self.op_id, state="queued")
db.add(act_queued_reboot)
db.commit()
reconciled = ops_actions_service.reconcile_stale_actions(db)
self.assertEqual(reconciled, 1)
db.refresh(act_queued_reboot)
self.assertEqual(act_queued_reboot.state, "failed")
self.assertEqual(act_queued_reboot.failure_code, "interrupted_before_execution")
if __name__ == '__main__':
unittest.main()

View File

@ -63,6 +63,9 @@ export async function GET(request: NextRequest) {
}
const data = await response.json();
if (Array.isArray(data)) {
return NextResponse.json({ actions: data, total: data.length });
}
return NextResponse.json(data);
} catch {
return NextResponse.json(

View File

@ -135,8 +135,12 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
cache: 'no-store',
});
if (resp.ok) {
const data: OpsActionsResponse = await resp.json();
const actionList = data.actions || [];
const data = await resp.json();
const actionList: OpsAction[] = Array.isArray(data)
? data
: Array.isArray((data as OpsActionsResponse)?.actions)
? (data as OpsActionsResponse).actions
: [];
setActions(actionList);
setHistoryError(null);