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_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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue