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:
parent
2b93d3ff98
commit
f20c1d13e6
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -2882,6 +2882,7 @@ def ops_create_action(
|
||||||
actor_identity=operator.identity,
|
actor_identity=operator.identity,
|
||||||
actor_role=operator.role,
|
actor_role=operator.role,
|
||||||
target_resource=f"action:{action.id}",
|
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",
|
result="success" if action.state == "succeeded" else "failed",
|
||||||
metadata={
|
metadata={
|
||||||
"action_type": action.action_type,
|
"action_type": action.action_type,
|
||||||
|
|
|
||||||
|
|
@ -197,11 +197,19 @@ def reconcile_stale_actions(db: DbSession) -> int:
|
||||||
|
|
||||||
for act in stale_actions:
|
for act in stale_actions:
|
||||||
if act.action_type in ("restart_api", "reboot_server"):
|
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.
|
||||||
act.state = "succeeded"
|
# Actions merely queued or in preflight never executed before the system restarted.
|
||||||
act.completion_timestamp = now
|
if act.state in ("running", "verifying"):
|
||||||
act.status_message = f"Reconciled upon system startup; {act.action_type} verified operational."
|
act.state = "succeeded"
|
||||||
count += 1
|
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:
|
else:
|
||||||
# Other actions left running when process terminated were interrupted
|
# Other actions left running when process terminated were interrupted
|
||||||
act.state = "failed"
|
act.state = "failed"
|
||||||
|
|
|
||||||
|
|
@ -480,6 +480,21 @@ class TestOpsActions(unittest.TestCase):
|
||||||
self.assertEqual(act_deploy.state, "failed")
|
self.assertEqual(act_deploy.state, "failed")
|
||||||
self.assertEqual(act_deploy.failure_code, "interrupted_by_restart")
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
@ -63,6 +63,9 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return NextResponse.json({ actions: data, total: data.length });
|
||||||
|
}
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,12 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data: OpsActionsResponse = await resp.json();
|
const data = await resp.json();
|
||||||
const actionList = data.actions || [];
|
const actionList: OpsAction[] = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray((data as OpsActionsResponse)?.actions)
|
||||||
|
? (data as OpsActionsResponse).actions
|
||||||
|
: [];
|
||||||
setActions(actionList);
|
setActions(actionList);
|
||||||
setHistoryError(null);
|
setHistoryError(null);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue