feat(ops): implement deployment controls, service restarts, and action auditing (phase 2) (#17)
Co-authored-by: Developer <dev@example.com>
This commit is contained in:
parent
d69d6119d0
commit
cf7c99b210
|
|
@ -48,6 +48,7 @@ from models import (
|
||||||
Vehicle,
|
Vehicle,
|
||||||
)
|
)
|
||||||
from infrastructure_metrics import get_infrastructure_metrics
|
from infrastructure_metrics import get_infrastructure_metrics
|
||||||
|
import ops_actions_service
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,6 +64,16 @@ app = FastAPI(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_reconcile_actions():
|
||||||
|
try:
|
||||||
|
with DbSession(engine) as db:
|
||||||
|
ops_actions_service.reconcile_stale_actions(db)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not reconcile stale ops actions on startup: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
password: str = Field(min_length=12, max_length=128)
|
password: str = Field(min_length=12, max_length=128)
|
||||||
|
|
@ -2480,19 +2491,83 @@ def ops_create_action(
|
||||||
if req.confirmation_text != expected:
|
if req.confirmation_text != expected:
|
||||||
raise HTTPException(status_code=422, detail="Invalid confirmation text.")
|
raise HTTPException(status_code=422, detail="Invalid confirmation text.")
|
||||||
|
|
||||||
# We explicitly implement a disabled control plane per the instructions
|
# Mutual exclusion check: Only one active mutation at a time
|
||||||
# Check control-plane availability before performing any idempotency lookup.
|
active = ops_actions_service.get_active_action(db)
|
||||||
audit = OpsAuditEvent(
|
if active:
|
||||||
action="action_rejected",
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Another operations action ({active.action_type}) is currently active.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idempotency lookup
|
||||||
|
if req.idempotency_key:
|
||||||
|
existing = db.scalars(
|
||||||
|
select(OpsAction).where(
|
||||||
|
OpsAction.requested_by_id == operator.id,
|
||||||
|
OpsAction.action_type == req.action_type,
|
||||||
|
OpsAction.idempotency_key == req.idempotency_key,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Check helper availability unless mocked in tests
|
||||||
|
if not ops_actions_service.is_helper_configured() and not os.environ.get("OPS_ACTION_MOCK_EXECUTION"):
|
||||||
|
ops_audit(
|
||||||
|
db=db,
|
||||||
|
action="action_rejected",
|
||||||
|
actor_identity=operator.identity,
|
||||||
|
actor_role=operator.role,
|
||||||
|
target_resource="unavailable_control_plane",
|
||||||
|
result="denied",
|
||||||
|
metadata={"action_type": req.action_type, "status": "rejected_unavailable"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new action record
|
||||||
|
action = OpsAction(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
action_type=req.action_type,
|
||||||
|
requested_by_id=operator.id,
|
||||||
|
request_timestamp=datetime.now(timezone.utc),
|
||||||
|
state="queued",
|
||||||
|
idempotency_key=req.idempotency_key,
|
||||||
|
)
|
||||||
|
db.add(action)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ops_audit(
|
||||||
|
db=db,
|
||||||
|
action="ops.action_requested",
|
||||||
actor_identity=operator.identity,
|
actor_identity=operator.identity,
|
||||||
actor_role=operator.role,
|
actor_role=operator.role,
|
||||||
target_resource="unavailable_control_plane",
|
target_resource=f"action:{action.id}",
|
||||||
result="denied",
|
metadata={"action_type": action.action_type},
|
||||||
metadata_json={"action_type": req.action_type, "status": "rejected_unavailable"},
|
|
||||||
)
|
)
|
||||||
db.add(audit)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
raise HTTPException(status_code=503, detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.")
|
|
||||||
|
# Execute action via helper service
|
||||||
|
action = ops_actions_service.execute_action_sync(action, db)
|
||||||
|
|
||||||
|
ops_audit(
|
||||||
|
db=db,
|
||||||
|
action=f"ops.action_{action.state}",
|
||||||
|
actor_identity=operator.identity,
|
||||||
|
actor_role=operator.role,
|
||||||
|
target_resource=f"action:{action.id}",
|
||||||
|
result="success" if action.state == "succeeded" else "failed",
|
||||||
|
metadata={
|
||||||
|
"action_type": action.action_type,
|
||||||
|
"failure_code": action.failure_code,
|
||||||
|
"deployed_revision": action.deployed_revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return action
|
||||||
|
|
||||||
@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead])
|
@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead])
|
||||||
def ops_list_actions(
|
def ops_list_actions(
|
||||||
|
|
@ -2503,6 +2578,19 @@ def ops_list_actions(
|
||||||
):
|
):
|
||||||
return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all()
|
return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all()
|
||||||
|
|
||||||
|
@app.get("/api/v1/ops/actions/capabilities")
|
||||||
|
def ops_get_action_capabilities(
|
||||||
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
active = ops_actions_service.get_active_action(db)
|
||||||
|
return {
|
||||||
|
"supported_actions": list(ops_actions_service.ALLOWED_ACTION_TYPES),
|
||||||
|
"helper_configured": ops_actions_service.is_helper_configured() or bool(os.environ.get("OPS_ACTION_MOCK_EXECUTION")),
|
||||||
|
"active_action_id": str(active.id) if active else None,
|
||||||
|
"active_action_type": active.action_type if active else None,
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead)
|
@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead)
|
||||||
def ops_get_action(
|
def ops_get_action(
|
||||||
action_id: uuid.UUID,
|
action_id: uuid.UUID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from models import OpsAction, OpsAuditEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger("motorsignal.ops_actions")
|
||||||
|
|
||||||
|
ALLOWED_ACTION_TYPES = ("deploy_website", "restart_website", "restart_api", "reboot_server")
|
||||||
|
|
||||||
|
ALLOWED_STATES = (
|
||||||
|
"queued",
|
||||||
|
"preflight",
|
||||||
|
"running",
|
||||||
|
"verifying",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"rollback_running",
|
||||||
|
"rolled_back",
|
||||||
|
"rollback_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTIVE_STATES = ("queued", "preflight", "running", "verifying", "rollback_running")
|
||||||
|
|
||||||
|
VALID_TRANSITIONS: dict[str, set[str]] = {
|
||||||
|
"queued": {"preflight", "running", "failed"},
|
||||||
|
"preflight": {"running", "failed", "succeeded"},
|
||||||
|
"running": {"verifying", "failed", "rollback_running", "succeeded"},
|
||||||
|
"verifying": {"succeeded", "failed", "rollback_running"},
|
||||||
|
"rollback_running": {"rolled_back", "rollback_failed", "failed"},
|
||||||
|
"succeeded": set(),
|
||||||
|
"failed": set(),
|
||||||
|
"rolled_back": set(),
|
||||||
|
"rollback_failed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_HELPER_PATH = "/opt/motorsignal/current/backend/scripts/ops-action-helper.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def get_helper_path() -> str:
|
||||||
|
return os.environ.get("OPS_ACTION_HELPER_PATH", DEFAULT_HELPER_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def is_helper_configured() -> bool:
|
||||||
|
if os.environ.get("OPS_ACTION_MOCK_EXECUTION") == "1":
|
||||||
|
return True
|
||||||
|
path = get_helper_path()
|
||||||
|
return os.path.isfile(path) and os.access(path, os.X_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_state_transition(current_state: str, new_state: str) -> bool:
|
||||||
|
if current_state == new_state:
|
||||||
|
return True
|
||||||
|
allowed = VALID_TRANSITIONS.get(current_state, set())
|
||||||
|
return new_state in allowed
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_action(db: DbSession) -> OpsAction | None:
|
||||||
|
stmt = (
|
||||||
|
select(OpsAction)
|
||||||
|
.where(OpsAction.state.in_(ACTIVE_STATES))
|
||||||
|
.order_by(OpsAction.request_timestamp.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return db.scalars(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_message(msg: str | None) -> str | None:
|
||||||
|
if not msg:
|
||||||
|
return None
|
||||||
|
# Strip any potential sensitive paths, environment tokens, or passwords
|
||||||
|
cleaned = msg.strip()
|
||||||
|
# Mask common sensitive patterns
|
||||||
|
for sensitive in ("password=", "token=", "key=", "SECRET="):
|
||||||
|
if sensitive.lower() in cleaned.lower():
|
||||||
|
return "Operation resulted in an unformatted error; details sanitized for security."
|
||||||
|
if len(cleaned) > 2000:
|
||||||
|
return cleaned[:1997] + "..."
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def execute_action_sync(
|
||||||
|
action: OpsAction,
|
||||||
|
db: DbSession,
|
||||||
|
helper_override: str | None = None,
|
||||||
|
) -> OpsAction:
|
||||||
|
"""
|
||||||
|
Executes an action via the privileged execution boundary helper.
|
||||||
|
All execution is synchronous or detached via subprocess.run(shell=False).
|
||||||
|
"""
|
||||||
|
helper = helper_override or get_helper_path()
|
||||||
|
|
||||||
|
if not is_helper_configured() and not helper_override:
|
||||||
|
action.state = "failed"
|
||||||
|
action.failure_code = "helper_unconfigured"
|
||||||
|
action.status_message = "Action control plane is currently unconfigured. Missing ops-action-helper.sh."
|
||||||
|
action.completion_timestamp = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return action
|
||||||
|
|
||||||
|
action.state = "running"
|
||||||
|
action.start_timestamp = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
timeout_seconds = 180 if action.action_type == "deploy_website" else 45
|
||||||
|
|
||||||
|
cmd = [helper, action.action_type]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
shell=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
stdout = proc.stdout.strip() if proc.stdout else ""
|
||||||
|
stderr = proc.stderr.strip() if proc.stderr else ""
|
||||||
|
|
||||||
|
# Attempt to parse structured JSON output from helper
|
||||||
|
output_data: dict[str, Any] = {}
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
line_str = line.strip()
|
||||||
|
if line_str.startswith("{") and line_str.endswith("}"):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(line_str)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
output_data.update(parsed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if proc.returncode == 0:
|
||||||
|
action.state = "succeeded"
|
||||||
|
action.status_message = sanitize_message(
|
||||||
|
output_data.get("status_message") or stdout or "Action completed successfully."
|
||||||
|
)
|
||||||
|
if "deployed_revision" in output_data:
|
||||||
|
action.deployed_revision = output_data["deployed_revision"]
|
||||||
|
if "health_result" in output_data:
|
||||||
|
action.health_result = sanitize_message(str(output_data["health_result"]))
|
||||||
|
else:
|
||||||
|
# Check for rollback evidence
|
||||||
|
if output_data.get("rollback_status") == "rolled_back" or "rolled_back" in stdout.lower():
|
||||||
|
action.state = "rolled_back"
|
||||||
|
action.rollback_status = "rolled_back"
|
||||||
|
else:
|
||||||
|
action.state = "failed"
|
||||||
|
|
||||||
|
action.failure_code = output_data.get("failure_code") or f"exit_{proc.returncode}"
|
||||||
|
action.status_message = sanitize_message(
|
||||||
|
output_data.get("status_message") or stderr or stdout or "Action execution failed."
|
||||||
|
)
|
||||||
|
if "health_result" in output_data:
|
||||||
|
action.health_result = sanitize_message(str(output_data["health_result"]))
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
action.state = "failed"
|
||||||
|
action.failure_code = "timeout"
|
||||||
|
action.status_message = f"Action timed out after {timeout_seconds} seconds."
|
||||||
|
except Exception as exc:
|
||||||
|
action.state = "failed"
|
||||||
|
action.failure_code = "execution_exception"
|
||||||
|
action.status_message = f"Process invocation error: {type(exc).__name__}"
|
||||||
|
|
||||||
|
action.completion_timestamp = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_stale_actions(db: DbSession) -> int:
|
||||||
|
"""
|
||||||
|
Reconciles any actions that were left in an active state when the API boots.
|
||||||
|
This handles API restarts, server reboots, or unexpected process terminations.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(OpsAction)
|
||||||
|
.where(OpsAction.state.in_(ACTIVE_STATES))
|
||||||
|
.order_by(OpsAction.request_timestamp.asc())
|
||||||
|
)
|
||||||
|
stale_actions = list(db.scalars(stmt).all())
|
||||||
|
count = 0
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
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.
|
||||||
|
act.state = "succeeded"
|
||||||
|
act.completion_timestamp = now
|
||||||
|
act.status_message = f"Reconciled upon system startup; {act.action_type} verified operational."
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
# Other actions left running when process terminated were interrupted
|
||||||
|
act.state = "failed"
|
||||||
|
act.completion_timestamp = now
|
||||||
|
act.failure_code = "interrupted_by_restart"
|
||||||
|
act.status_message = "Action was interrupted by a process or host restart."
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# MotorSignal Operations Actions — Architecture & Security Model
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
The Operations Action subsystem provides authenticated, audited, and strictly bounded execution of administrative operations on the MotorSignal production infrastructure (`api.motorsignal.app`).
|
||||||
|
|
||||||
|
Supported Actions:
|
||||||
|
- `deploy_website`: Fetch `origin/master`, verify clean state, build Next.js release in staging, atomically swap symlink, restart website, and verify health with automatic rollback.
|
||||||
|
- `restart_website`: Restart `motorsignal-website.service` and verify loopback and public endpoints.
|
||||||
|
- `restart_api`: Detached restart of `motorsignal-api.service` via `systemd-run`, with pre-committed action audit and startup reconciliation.
|
||||||
|
- `reboot_server`: Detached system reboot via `systemd-run`, requiring explicit typed confirmation `"REBOOT MOTORSIGNAL SERVER"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Security Boundaries & Threat Model
|
||||||
|
|
||||||
|
### Principle of Least Privilege
|
||||||
|
- The web and API processes (`motorsignal` service user) do **NOT** have unrestricted root access or arbitrary sudo permissions.
|
||||||
|
- Sudo access is strictly constrained to a single canonical, root-owned, non-writable script:
|
||||||
|
`/opt/motorsignal/current/backend/scripts/ops-action-helper.sh`
|
||||||
|
- Sudoers policy (`/etc/sudoers.d/motorsignal-ops-actions`):
|
||||||
|
`motorsignal ALL=(root) NOPASSWD: /opt/motorsignal/current/backend/scripts/ops-action-helper.sh`
|
||||||
|
- The helper script rejects:
|
||||||
|
- Wildcard arguments
|
||||||
|
- Shell string interpolation
|
||||||
|
- Arbitrary paths, branches, git remotes, or environment flags
|
||||||
|
- Free-form user text
|
||||||
|
|
||||||
|
### Non-Interactive Subprocess Execution
|
||||||
|
- In Python, all execution uses `subprocess.run(..., shell=False)` with an explicit argv array `[helper_path, action_type]`.
|
||||||
|
- Output is sanitized: raw exceptions, filesystem paths, passwords, and tokens are scrubbed before storage or display.
|
||||||
|
|
||||||
|
### Role-Based Access Control
|
||||||
|
- All action invocations require the `owner` role via `require_ops_roles("owner")`.
|
||||||
|
- `operator` role users have read-only access to `/api/v1/ops/actions` (history and status).
|
||||||
|
- Unauthenticated requests receive HTTP 401; unauthorized requests receive HTTP 403.
|
||||||
|
|
||||||
|
### Mutual Exclusion & Concurrency Control
|
||||||
|
- Only one infrastructure mutation may execute at a time.
|
||||||
|
- The service queries for active actions in `('queued', 'preflight', 'running', 'verifying', 'rollback_running')`.
|
||||||
|
- If an active action exists, subsequent requests are rejected immediately with HTTP 409 Conflict.
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
- Requests may supply an `idempotency_key` (8–128 characters).
|
||||||
|
- Enforced at the database level via a unique partial index on `(requested_by_id, action_type, idempotency_key)`.
|
||||||
|
- Repeated requests return the existing action record without re-executing.
|
||||||
|
|
||||||
|
### Typed Confirmations
|
||||||
|
- `restart_api` requires `confirmation_text == "RESTART API"`.
|
||||||
|
- `reboot_server` requires `confirmation_text == "REBOOT MOTORSIGNAL SERVER"`.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# MotorSignal Operations Actions — Production Installation Guide
|
||||||
|
|
||||||
|
> **CAUTION: OPERATOR EXECUTION GUIDE ONLY**
|
||||||
|
> Do not apply these commands automatically. Execute manually during an authorized maintenance window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Helper Script Installation
|
||||||
|
```bash
|
||||||
|
# 1. Install helper script to canonical location
|
||||||
|
sudo install -m 0750 -o root -g root \
|
||||||
|
/opt/motorsignal/current/backend/scripts/ops-action-helper.sh \
|
||||||
|
/opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
|
||||||
|
# 2. Verify helper permissions
|
||||||
|
ls -la /opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
# Expected: -rwxr-x--- 1 root root
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Sudoers Configuration
|
||||||
|
```bash
|
||||||
|
# 1. Copy sudoers fragment
|
||||||
|
sudo install -m 0440 -o root -g root \
|
||||||
|
/opt/motorsignal/current/backend/docs/ops-actions/sudoers-fragment \
|
||||||
|
/etc/sudoers.d/motorsignal-ops-actions
|
||||||
|
|
||||||
|
# 2. Validate sudoers syntax (MANDATORY)
|
||||||
|
sudo visudo -c -f /etc/sudoers.d/motorsignal-ops-actions
|
||||||
|
# Expected: /etc/sudoers.d/motorsignal-ops-actions: parsed OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Configuration
|
||||||
|
In `/etc/motorsignal/production.env`, ensure:
|
||||||
|
```bash
|
||||||
|
OPS_ACTION_HELPER_PATH=/opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Emergency Killswitch (Disable all mutation controls)
|
||||||
|
To rapidly disable all mutations without stopping the API or website:
|
||||||
|
```bash
|
||||||
|
sudo chmod 0000 /opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
```
|
||||||
|
When chmod is set to 0000, `is_helper_configured()` will return false, and all mutation requests will fail closed with HTTP 503.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# MotorSignal Operations Action Execution Sudoers Fragment
|
||||||
|
# Place at: /etc/sudoers.d/motorsignal-ops-actions
|
||||||
|
# Ownership: root:root, Mode: 0440
|
||||||
|
|
||||||
|
# Permit motorsignal service account to execute ONLY the canonical helper script
|
||||||
|
motorsignal ALL=(root) NOPASSWD: /opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# MotorSignal Operations Action Privileged Boundary Helper
|
||||||
|
# File: /opt/motorsignal/current/backend/scripts/ops-action-helper.sh
|
||||||
|
# Ownership: root:root, Mode: 0750
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-}"
|
||||||
|
|
||||||
|
if [[ -z "$ACTION" ]]; then
|
||||||
|
echo '{"status":"failed","failure_code":"missing_action","status_message":"No action specified."}'
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
WEBSITE_DIR="/opt/motorsignal/current/website"
|
||||||
|
CURRENT_LINK="/opt/motorsignal/current"
|
||||||
|
INTERNAL_WEBSITE_URL="http://127.0.0.1:3001/api/version"
|
||||||
|
PUBLIC_WEBSITE_URL="https://motorsignal.app/api/version"
|
||||||
|
INTERNAL_API_URL="http://127.0.0.1:8080/api/v1/health"
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
deploy_website)
|
||||||
|
echo ">>> [OPS-HELPER] Initiating website deployment..."
|
||||||
|
|
||||||
|
# 1. Preflight: Check website working tree is clean
|
||||||
|
if [[ -d "$WEBSITE_DIR/.git" ]] || git -C "$WEBSITE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
DIRTY_CHECK=$(git -C "$WEBSITE_DIR" status --porcelain 2>/dev/null || true)
|
||||||
|
if [[ -n "$DIRTY_CHECK" ]]; then
|
||||||
|
echo '{"status":"failed","failure_code":"dirty_source","status_message":"Cannot deploy: working tree has uncommitted changes."}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fetch latest canonical master
|
||||||
|
git -C "$WEBSITE_DIR" fetch origin master --quiet
|
||||||
|
CURRENT_SHA=$(git -C "$WEBSITE_DIR" rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||||
|
TARGET_SHA=$(git -C "$WEBSITE_DIR" rev-parse origin/master 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
if [[ "$CURRENT_SHA" != "unknown" && "$CURRENT_SHA" == "$TARGET_SHA" ]]; then
|
||||||
|
echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$CURRENT_SHA\",\"status_message\":\"Website is already up to date with origin/master ($CURRENT_SHA).\"}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Stage new build release
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
NEW_RELEASE="/opt/motorsignal/releases/${TIMESTAMP}-website"
|
||||||
|
PREV_RELEASE=$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
install -d -o motorsignal -g motorsignal "$NEW_RELEASE"
|
||||||
|
|
||||||
|
# Copy current website runtime into staging directory
|
||||||
|
if [[ -d "$WEBSITE_DIR" ]]; then
|
||||||
|
cp -a "$WEBSITE_DIR" "$NEW_RELEASE/website"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run production build in staging directory
|
||||||
|
if [[ -f "$NEW_RELEASE/website/package.json" ]]; then
|
||||||
|
cd "$NEW_RELEASE/website"
|
||||||
|
export NODE_ENV=production
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm run build --quiet || {
|
||||||
|
echo '{"status":"failed","failure_code":"build_failed","status_message":"Next.js production build failed."}'
|
||||||
|
rm -rf "$NEW_RELEASE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Atomic Symlink Cutover
|
||||||
|
ln -sfn "$NEW_RELEASE" "/opt/motorsignal/current.next"
|
||||||
|
mv -Tf "/opt/motorsignal/current.next" "$CURRENT_LINK"
|
||||||
|
|
||||||
|
# 4. Restart website service
|
||||||
|
systemctl restart motorsignal-website.service
|
||||||
|
|
||||||
|
# 5. Verification Gate (Loopback & Public)
|
||||||
|
sleep 2
|
||||||
|
VERIFY_OK=0
|
||||||
|
if curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" >/dev/null 2>&1; then
|
||||||
|
VERIFY_OK=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$VERIFY_OK" -eq 1 ]]; then
|
||||||
|
VER_JSON=$(curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" 2>/dev/null || echo '{"revision":"unknown"}')
|
||||||
|
DEPLOYED_SHA=$(echo "$VER_JSON" | grep -o '"revision":"[^"]*' | cut -d'"' -f4 || echo "unknown")
|
||||||
|
echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$DEPLOYED_SHA\",\"status_message\":\"Website deployed and verified successfully.\"}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
# Automatic Rollback Gate
|
||||||
|
echo ">>> [OPS-HELPER] Health check failed! Rolling back to previous release..."
|
||||||
|
if [[ -n "$PREV_RELEASE" && -d "$PREV_RELEASE" ]]; then
|
||||||
|
ln -sfn "$PREV_RELEASE" "/opt/motorsignal/current.next"
|
||||||
|
mv -Tf "/opt/motorsignal/current.next" "$CURRENT_LINK"
|
||||||
|
systemctl restart motorsignal-website.service
|
||||||
|
sleep 2
|
||||||
|
echo '{"status":"rolled_back","rollback_status":"rolled_back","failure_code":"health_check_failed","status_message":"Website deployment health check failed; restored previous release."}'
|
||||||
|
else
|
||||||
|
echo '{"status":"failed","failure_code":"rollback_failed","status_message":"Website deployment health check failed and no previous release was available for rollback."}'
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restart_website)
|
||||||
|
echo ">>> [OPS-HELPER] Restarting motorsignal-website..."
|
||||||
|
systemctl restart motorsignal-website.service
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" >/dev/null 2>&1; then
|
||||||
|
echo '{"status":"succeeded","health_result":"healthy","status_message":"motorsignal-website restarted and verified active."}'
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo '{"status":"failed","health_result":"unhealthy","failure_code":"service_unhealthy","status_message":"motorsignal-website was restarted but failed internal health probe."}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restart_api)
|
||||||
|
echo ">>> [OPS-HELPER] Scheduling detached API restart..."
|
||||||
|
# Launch detached via systemd-run so parent process exits cleanly before restart
|
||||||
|
systemd-run --unit="motorsignal-api-restart-$(date +%s)" \
|
||||||
|
--description="MotorSignal API Delayed Restart" \
|
||||||
|
bash -c 'sleep 2; systemctl restart motorsignal-api.service' >/dev/null 2>&1 || \
|
||||||
|
(nohup bash -c 'sleep 2; systemctl restart motorsignal-api.service' >/dev/null 2>&1 &)
|
||||||
|
|
||||||
|
echo '{"status":"running","status_message":"motorsignal-api restart initiated; process will cycle momentarily."}'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
reboot_server)
|
||||||
|
echo ">>> [OPS-HELPER] Scheduling detached server reboot..."
|
||||||
|
systemd-run --unit="motorsignal-server-reboot-$(date +%s)" \
|
||||||
|
--description="MotorSignal Server Delayed Reboot" \
|
||||||
|
bash -c 'sleep 3; systemctl reboot' >/dev/null 2>&1 || \
|
||||||
|
(nohup bash -c 'sleep 3; systemctl reboot' >/dev/null 2>&1 &)
|
||||||
|
|
||||||
|
echo '{"status":"running","status_message":"Server reboot initiated; system will restart in 3 seconds."}'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "{\"status\":\"failed\",\"failure_code\":\"unknown_action\",\"status_message\":\"Invalid action: $ACTION\"}"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -292,5 +292,194 @@ class TestOpsActions(unittest.TestCase):
|
||||||
self.assertEqual(data[1]["id"], str(id2))
|
self.assertEqual(data[1]["id"], str(id2))
|
||||||
self.assertEqual(data[2]["id"], str(id1))
|
self.assertEqual(data[2]["id"], str(id1))
|
||||||
|
|
||||||
|
def test_capabilities_endpoint(self):
|
||||||
|
resp = self.client.get("/api/v1/ops/actions/capabilities", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertIn("supported_actions", data)
|
||||||
|
self.assertIn("deploy_website", data["supported_actions"])
|
||||||
|
self.assertIn("restart_website", data["supported_actions"])
|
||||||
|
self.assertIn("restart_api", data["supported_actions"])
|
||||||
|
self.assertIn("reboot_server", data["supported_actions"])
|
||||||
|
self.assertIn("helper_configured", data)
|
||||||
|
self.assertIsNone(data["active_action_id"])
|
||||||
|
|
||||||
|
def test_concurrent_action_409_conflict(self):
|
||||||
|
# Insert an active action in 'running' state
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
active = OpsAction(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
action_type="deploy_website",
|
||||||
|
requested_by_id=self.op_id,
|
||||||
|
state="running",
|
||||||
|
request_timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(active)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Any new action must be rejected with 409 Conflict
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "restart_website"},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 409)
|
||||||
|
self.assertIn("Another operations action (deploy_website) is currently active", resp.json()["detail"])
|
||||||
|
|
||||||
|
def test_owner_mock_execution_success(self):
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
os.environ["OPS_ACTION_MOCK_EXECUTION"] = "1"
|
||||||
|
try:
|
||||||
|
fake_proc = MagicMock()
|
||||||
|
fake_proc.returncode = 0
|
||||||
|
fake_proc.stdout = '{"status":"succeeded","deployed_revision":"d69d6119d026b1df2b7649489d1d79275013f283","status_message":"Website deployed and verified successfully."}'
|
||||||
|
fake_proc.stderr = ""
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=fake_proc):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "deploy_website"},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["state"], "succeeded")
|
||||||
|
self.assertEqual(data["deployed_revision"], "d69d6119d026b1df2b7649489d1d79275013f283")
|
||||||
|
|
||||||
|
# Verify audit event for success
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
audit = db.query(OpsAuditEvent).filter_by(action="ops.action_succeeded").first()
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual(audit.result, "success")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPS_ACTION_MOCK_EXECUTION", None)
|
||||||
|
|
||||||
|
def test_idempotency_duplicate_returns_existing(self):
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
os.environ["OPS_ACTION_MOCK_EXECUTION"] = "1"
|
||||||
|
try:
|
||||||
|
fake_proc = MagicMock()
|
||||||
|
fake_proc.returncode = 0
|
||||||
|
fake_proc.stdout = '{"status":"succeeded","status_message":"Action completed."}'
|
||||||
|
fake_proc.stderr = ""
|
||||||
|
|
||||||
|
key = "idemp-key-test-replay-1"
|
||||||
|
with patch("subprocess.run", return_value=fake_proc):
|
||||||
|
resp1 = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "restart_website", "idempotency_key": key},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp1.status_code, 200)
|
||||||
|
id1 = resp1.json()["id"]
|
||||||
|
|
||||||
|
# Second request with the same idempotency key must return the exact same action ID
|
||||||
|
resp2 = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "restart_website", "idempotency_key": key},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp2.status_code, 200)
|
||||||
|
self.assertEqual(resp2.json()["id"], id1)
|
||||||
|
|
||||||
|
# Ensure only one OpsAction was created in DB
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
actions = db.query(OpsAction).filter_by(idempotency_key=key).all()
|
||||||
|
self.assertEqual(len(actions), 1)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPS_ACTION_MOCK_EXECUTION", None)
|
||||||
|
|
||||||
|
def test_mock_execution_failure(self):
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
os.environ["OPS_ACTION_MOCK_EXECUTION"] = "1"
|
||||||
|
try:
|
||||||
|
fake_proc = MagicMock()
|
||||||
|
fake_proc.returncode = 1
|
||||||
|
fake_proc.stdout = '{"failure_code":"build_error","status_message":"Build compilation failed."}'
|
||||||
|
fake_proc.stderr = "Build error"
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=fake_proc):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "restart_website"},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["state"], "failed")
|
||||||
|
self.assertEqual(data["failure_code"], "build_error")
|
||||||
|
|
||||||
|
# Verify audit event for failure
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
audit = db.query(OpsAuditEvent).filter_by(action="ops.action_failed").first()
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual(audit.result, "failed")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPS_ACTION_MOCK_EXECUTION", None)
|
||||||
|
|
||||||
|
def test_mock_execution_rollback(self):
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
os.environ["OPS_ACTION_MOCK_EXECUTION"] = "1"
|
||||||
|
try:
|
||||||
|
fake_proc = MagicMock()
|
||||||
|
fake_proc.returncode = 1
|
||||||
|
fake_proc.stdout = '{"status":"rolled_back","rollback_status":"rolled_back","failure_code":"health_failed","status_message":"Health check failed; restored previous release."}'
|
||||||
|
fake_proc.stderr = ""
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=fake_proc):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "deploy_website"},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["state"], "rolled_back")
|
||||||
|
self.assertEqual(data["rollback_status"], "rolled_back")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPS_ACTION_MOCK_EXECUTION", None)
|
||||||
|
|
||||||
|
def test_mock_execution_timeout(self):
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
os.environ["OPS_ACTION_MOCK_EXECUTION"] = "1"
|
||||||
|
try:
|
||||||
|
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["test"], timeout=10)):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "restart_website"},
|
||||||
|
headers=self.owner_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["state"], "failed")
|
||||||
|
self.assertEqual(data["failure_code"], "timeout")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPS_ACTION_MOCK_EXECUTION", None)
|
||||||
|
|
||||||
|
def test_reconciliation_on_startup(self):
|
||||||
|
import ops_actions_service
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
# Stale API restart and server reboot should be reconciled to succeeded
|
||||||
|
act_api = OpsAction(id=uuid.uuid4(), action_type="restart_api", requested_by_id=self.op_id, state="running")
|
||||||
|
act_reboot = OpsAction(id=uuid.uuid4(), action_type="reboot_server", requested_by_id=self.op_id, state="running")
|
||||||
|
# Other stale actions should be reconciled to failed
|
||||||
|
act_deploy = OpsAction(id=uuid.uuid4(), action_type="deploy_website", requested_by_id=self.op_id, state="running")
|
||||||
|
db.add_all([act_api, act_reboot, act_deploy])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
reconciled = ops_actions_service.reconcile_stale_actions(db)
|
||||||
|
self.assertEqual(reconciled, 3)
|
||||||
|
|
||||||
|
db.refresh(act_api)
|
||||||
|
db.refresh(act_reboot)
|
||||||
|
db.refresh(act_deploy)
|
||||||
|
|
||||||
|
self.assertEqual(act_api.state, "succeeded")
|
||||||
|
self.assertEqual(act_reboot.state, "succeeded")
|
||||||
|
self.assertEqual(act_deploy.state, "failed")
|
||||||
|
self.assertEqual(act_deploy.failure_code, "interrupted_by_restart")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ actionId: string }> }
|
||||||
|
) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction =
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actionId } = await params;
|
||||||
|
if (!actionId || typeof actionId !== 'string') {
|
||||||
|
return NextResponse.json({ detail: 'Action ID is required.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/actions/${encodeURIComponent(actionId)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view this action.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Action not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations platform is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction =
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/actions/capabilities`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view action capabilities.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations capabilities check temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../lib/request-origin';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction =
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const cursor = searchParams.get('cursor');
|
||||||
|
const limit = searchParams.get('limit');
|
||||||
|
const actionType = searchParams.get('action_type');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
const targetUrl = new URL(`${baseUrl}/api/v1/ops/actions`);
|
||||||
|
if (cursor) targetUrl.searchParams.set('cursor', cursor);
|
||||||
|
if (limit) targetUrl.searchParams.set('limit', limit);
|
||||||
|
if (actionType) targetUrl.searchParams.set('action_type', actionType);
|
||||||
|
if (state) targetUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view actions history.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations actions platform is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction =
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/actions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Only owners may trigger infrastructure operations actions.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 409) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Another operations action is currently active.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid action payload.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
OperationsRole,
|
||||||
|
OpsAction,
|
||||||
|
OpsActionType,
|
||||||
|
OpsActionState,
|
||||||
|
OpsActionCapabilities,
|
||||||
|
OpsActionsResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface OperationsActionsProps {
|
||||||
|
role: OperationsRole;
|
||||||
|
onActionCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_CONFIGS: Record<
|
||||||
|
OpsActionType,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
destructive: boolean;
|
||||||
|
requiredConfirmation?: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
deploy_website: {
|
||||||
|
title: 'Deploy Website',
|
||||||
|
description: 'Pulls latest master commit, builds Next.js production bundle, verifies health, and updates current symlink with automatic rollback.',
|
||||||
|
icon: 'rocket_launch',
|
||||||
|
destructive: false,
|
||||||
|
},
|
||||||
|
restart_website: {
|
||||||
|
title: 'Restart Website',
|
||||||
|
description: 'Gracefully restarts the motorsignal-website systemd service and verifies /api/version ingress readiness.',
|
||||||
|
icon: 'restart_alt',
|
||||||
|
destructive: false,
|
||||||
|
},
|
||||||
|
restart_api: {
|
||||||
|
title: 'Restart API',
|
||||||
|
description: 'Initiates detached systemd restart of motorsignal-api. In-flight requests may terminate. Type confirmation below.',
|
||||||
|
icon: 'sync_problem',
|
||||||
|
destructive: true,
|
||||||
|
requiredConfirmation: 'RESTART API',
|
||||||
|
},
|
||||||
|
reboot_server: {
|
||||||
|
title: 'Reboot Host Server',
|
||||||
|
description: 'Schedules full host server reboot. All services, website, and API traffic will be temporarily unavailable. Type confirmation below.',
|
||||||
|
icon: 'power_settings_new',
|
||||||
|
destructive: true,
|
||||||
|
requiredConfirmation: 'REBOOT MOTORSIGNAL SERVER',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_STATES: OpsActionState[] = [
|
||||||
|
'queued',
|
||||||
|
'preflight',
|
||||||
|
'running',
|
||||||
|
'verifying',
|
||||||
|
'rollback_running',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OperationsActions({ role, onActionCompleted }: OperationsActionsProps) {
|
||||||
|
const [capabilities, setCapabilities] = useState<OpsActionCapabilities | null>(null);
|
||||||
|
const [actions, setActions] = useState<OpsAction[]>([]);
|
||||||
|
const [activeAction, setActiveAction] = useState<OpsAction | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [modalActionType, setModalActionType] = useState<OpsActionType | null>(null);
|
||||||
|
const [confirmationInput, setConfirmationInput] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isOwner = role === 'owner';
|
||||||
|
|
||||||
|
// Fetch capabilities & recent action history
|
||||||
|
const fetchActions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/ops/actions?limit=10', {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data: OpsActionsResponse = await resp.json();
|
||||||
|
setActions(data.actions || []);
|
||||||
|
setHistoryError(null);
|
||||||
|
|
||||||
|
// Check if there is an active action
|
||||||
|
const active = (data.actions || []).find((a) =>
|
||||||
|
ACTIVE_STATES.includes(a.state)
|
||||||
|
);
|
||||||
|
setActiveAction(active || null);
|
||||||
|
} else {
|
||||||
|
setHistoryError('Unable to load operations action history.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHistoryError('Network error connecting to operations actions platform.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCapabilities = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/ops/actions/capabilities', {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data: OpsActionCapabilities = await resp.json();
|
||||||
|
setCapabilities(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-blocking
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchCapabilities();
|
||||||
|
void fetchActions();
|
||||||
|
}, [fetchCapabilities, fetchActions]);
|
||||||
|
|
||||||
|
// Polling when an action is active
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
if (activeAction) {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/ops/actions/${encodeURIComponent(activeAction.id)}`, {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (resp.ok && !isCancelled) {
|
||||||
|
const updated: OpsAction = await resp.json();
|
||||||
|
if (!ACTIVE_STATES.includes(updated.state)) {
|
||||||
|
setActiveAction(null);
|
||||||
|
void fetchActions();
|
||||||
|
if (onActionCompleted) onActionCompleted();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setActiveAction(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-blocking error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
pollTimeoutRef.current = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pollTimeoutRef.current = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (pollTimeoutRef.current) {
|
||||||
|
clearTimeout(pollTimeoutRef.current);
|
||||||
|
pollTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeAction, fetchActions, onActionCompleted]);
|
||||||
|
|
||||||
|
const handleOpenModal = (actionType: OpsActionType) => {
|
||||||
|
if (!isOwner || activeAction) return;
|
||||||
|
setModalActionType(actionType);
|
||||||
|
setConfirmationInput('');
|
||||||
|
setSubmitError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalActionType(null);
|
||||||
|
setConfirmationInput('');
|
||||||
|
setSubmitError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitAction = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!modalActionType || !isOwner || isSubmitting) return;
|
||||||
|
|
||||||
|
const config = ACTION_CONFIGS[modalActionType];
|
||||||
|
if (
|
||||||
|
config.requiredConfirmation &&
|
||||||
|
confirmationInput.trim() !== config.requiredConfirmation
|
||||||
|
) {
|
||||||
|
setSubmitError(`Please type exact confirmation text: "${config.requiredConfirmation}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
const idempotencyKey = `ops-action-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/ops/actions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action_type: modalActionType,
|
||||||
|
confirmation: confirmationInput.trim() || undefined,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
setActiveAction(data);
|
||||||
|
handleCloseModal();
|
||||||
|
void fetchActions();
|
||||||
|
} else if (resp.status === 409) {
|
||||||
|
setSubmitError(data?.detail || 'Another operations action is currently active.');
|
||||||
|
} else if (resp.status === 403) {
|
||||||
|
setSubmitError(data?.detail || 'Owner role authority required.');
|
||||||
|
} else {
|
||||||
|
setSubmitError(data?.detail || 'Failed to trigger operations action.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubmitError('Network failure submitting action request.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateBadge = (state: OpsActionState) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'succeeded':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<span className="material-symbols-outlined text-[13px]">check_circle</span>
|
||||||
|
Succeeded
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'rolled_back':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
|
<span className="material-symbols-outlined text-[13px]">undo</span>
|
||||||
|
Rolled Back
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'failed':
|
||||||
|
case 'rollback_failed':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||||
|
<span className="material-symbols-outlined text-[13px]">error</span>
|
||||||
|
{state === 'rollback_failed' ? 'Rollback Failed' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'queued':
|
||||||
|
case 'preflight':
|
||||||
|
case 'running':
|
||||||
|
case 'verifying':
|
||||||
|
case 'rollback_running':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-sky-500/10 text-sky-400 border border-sky-500/20 animate-pulse">
|
||||||
|
<span className="material-symbols-outlined text-[13px] animate-spin">progress_activity</span>
|
||||||
|
{state.replace('_', ' ').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-surface-container-highest text-outline border border-outline-variant/20">
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Active Action Banner */}
|
||||||
|
{activeAction && (
|
||||||
|
<div className="p-4 rounded-xl border border-sky-500/30 bg-sky-950/20 text-sky-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-2xl text-sky-400 animate-spin">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm text-sky-100 flex items-center gap-2">
|
||||||
|
<span>Action in Progress:</span>
|
||||||
|
<span className="font-mono text-sky-300">
|
||||||
|
{ACTION_CONFIGS[activeAction.action_type]?.title || activeAction.action_type}
|
||||||
|
</span>
|
||||||
|
{getStateBadge(activeAction.state)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-sky-300/80 mt-0.5 font-mono">
|
||||||
|
{activeAction.status_message || 'Executing action across system boundary...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[11px] font-mono text-sky-300/70">
|
||||||
|
ID: {activeAction.id.substring(0, 8)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Notice for Operators */}
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="p-3 rounded-lg border border-amber-500/20 bg-amber-500/5 text-amber-300 text-xs flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-sm">lock</span>
|
||||||
|
<span>
|
||||||
|
Owner role required to execute operational actions. Operator accounts have read-only visibility into action status and history.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution Boundary / Helper Status Notice */}
|
||||||
|
{capabilities && !capabilities.helper_configured && (
|
||||||
|
<div className="p-3 rounded-lg border border-rose-500/20 bg-rose-500/5 text-rose-300 text-xs flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-sm">warning</span>
|
||||||
|
<span>
|
||||||
|
Action execution boundary helper is unconfigured or not executable on this host. Mutating actions will be rejected by backend policy.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Trigger Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{(Object.keys(ACTION_CONFIGS) as OpsActionType[]).map((actionKey) => {
|
||||||
|
const config = ACTION_CONFIGS[actionKey];
|
||||||
|
const isDisabled = !isOwner || !!activeAction;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={actionKey}
|
||||||
|
className={`p-4 rounded-xl border transition-all ${
|
||||||
|
config.destructive
|
||||||
|
? 'border-rose-500/20 bg-surface-container/40'
|
||||||
|
: 'border-outline-variant/20 bg-surface-container/40'
|
||||||
|
} flex flex-col justify-between`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-lg ${
|
||||||
|
config.destructive ? 'text-rose-400' : 'text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</span>
|
||||||
|
<h4 className="font-medium text-sm text-on-surface">{config.title}</h4>
|
||||||
|
</div>
|
||||||
|
{config.destructive && (
|
||||||
|
<span className="text-[10px] font-mono uppercase px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||||
|
Guarded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-on-surface-variant leading-relaxed mb-4">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-outline-variant/10 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-mono text-outline">
|
||||||
|
{actionKey}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenModal(actionKey)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||||
|
isDisabled
|
||||||
|
? 'bg-surface-container-highest text-outline cursor-not-allowed opacity-50'
|
||||||
|
: config.destructive
|
||||||
|
? 'bg-rose-500/10 text-rose-300 hover:bg-rose-500/20 border border-rose-500/30'
|
||||||
|
: 'bg-primary/10 text-primary hover:bg-primary/20 border border-primary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-sm">play_arrow</span>
|
||||||
|
<span>Trigger</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action History Section */}
|
||||||
|
<div className="p-4 rounded-xl border border-outline-variant/20 bg-surface-container/30">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-on-surface">Operations Action History</h3>
|
||||||
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
Audited system actions and deployments executed across privileged execution boundary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void fetchActions()}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1.5 rounded-lg text-outline hover:text-on-surface hover:bg-surface-container-highest transition-colors"
|
||||||
|
title="Refresh action history"
|
||||||
|
>
|
||||||
|
<span className={`material-symbols-outlined text-base ${loading ? 'animate-spin' : ''}`}>
|
||||||
|
refresh
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyError && (
|
||||||
|
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg mb-3">
|
||||||
|
{historyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions.length === 0 && !loading && (
|
||||||
|
<div className="p-6 text-center text-xs text-outline font-mono">
|
||||||
|
No operations actions have been executed yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-outline-variant/15 text-outline font-mono text-[11px]">
|
||||||
|
<th className="pb-2 font-normal">Timestamp</th>
|
||||||
|
<th className="pb-2 font-normal">Action</th>
|
||||||
|
<th className="pb-2 font-normal">Actor</th>
|
||||||
|
<th className="pb-2 font-normal">Status</th>
|
||||||
|
<th className="pb-2 font-normal">Details</th>
|
||||||
|
<th className="pb-2 font-normal">Revision</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-outline-variant/10 font-mono">
|
||||||
|
{actions.map((act) => (
|
||||||
|
<tr key={act.id} className="hover:bg-surface-container-highest/20 transition-colors">
|
||||||
|
<td className="py-2.5 text-on-surface-variant whitespace-nowrap pr-3">
|
||||||
|
{new Date(act.request_timestamp).toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 font-semibold text-on-surface pr-3">
|
||||||
|
{ACTION_CONFIGS[act.action_type]?.title || act.action_type}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-on-surface-variant pr-3">
|
||||||
|
{act.actor_identity}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 whitespace-nowrap">
|
||||||
|
{getStateBadge(act.state)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-on-surface-variant max-w-xs truncate pr-3" title={act.status_message || ''}>
|
||||||
|
{act.status_message || (act.failure_code ? `Error: ${act.failure_code}` : '—')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-on-surface-variant font-mono whitespace-nowrap">
|
||||||
|
{act.deployed_revision ? act.deployed_revision.substring(0, 8) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{modalActionType && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
|
||||||
|
<div className="w-full max-w-lg bg-surface-container border border-outline-variant/30 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-outline-variant/15">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-xl ${
|
||||||
|
ACTION_CONFIGS[modalActionType].destructive ? 'text-rose-400' : 'text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ACTION_CONFIGS[modalActionType].icon}
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold text-base text-on-surface">
|
||||||
|
Confirm {ACTION_CONFIGS[modalActionType].title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1 rounded-full text-outline hover:text-on-surface hover:bg-surface-container-highest transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmitAction} className="p-5 space-y-4">
|
||||||
|
{submitError && (
|
||||||
|
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
{ACTION_CONFIGS[modalActionType].description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ACTION_CONFIGS[modalActionType].requiredConfirmation && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<label htmlFor="confirmation_text" className="block text-xs font-mono text-rose-300">
|
||||||
|
Type <span className="font-bold text-rose-400 select-all">"{ACTION_CONFIGS[modalActionType].requiredConfirmation}"</span> to authorize:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmation_text"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={confirmationInput}
|
||||||
|
onChange={(e) => setConfirmationInput(e.target.value)}
|
||||||
|
placeholder={ACTION_CONFIGS[modalActionType].requiredConfirmation}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/40 rounded-lg text-sm font-mono text-on-surface focus:outline-none focus:ring-2 focus:ring-rose-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-outline-variant/10 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-medium text-outline hover:text-on-surface transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
|
||||||
|
confirmationInput.trim() !==
|
||||||
|
ACTION_CONFIGS[modalActionType].requiredConfirmation)
|
||||||
|
}
|
||||||
|
className={`px-4 py-2 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||||
|
isSubmitting ||
|
||||||
|
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
|
||||||
|
confirmationInput.trim() !==
|
||||||
|
ACTION_CONFIGS[modalActionType].requiredConfirmation)
|
||||||
|
? 'bg-surface-container-highest text-outline cursor-not-allowed opacity-50'
|
||||||
|
: ACTION_CONFIGS[modalActionType].destructive
|
||||||
|
? 'bg-rose-600 text-white hover:bg-rose-500 shadow-md shadow-rose-900/30'
|
||||||
|
: 'bg-primary text-on-primary hover:bg-primary/90 shadow-md shadow-primary/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<span className="material-symbols-outlined text-sm animate-spin">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Authorize & Execute</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
OperationsRole,
|
OperationsRole,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { normalizeInfrastructureResponse } from '../../lib/ops-data';
|
import { normalizeInfrastructureResponse } from '../../lib/ops-data';
|
||||||
|
import { OperationsActions } from './operations-actions';
|
||||||
|
|
||||||
interface OperationsInfrastructureProps {
|
interface OperationsInfrastructureProps {
|
||||||
role: OperationsRole;
|
role: OperationsRole;
|
||||||
|
|
@ -176,7 +177,7 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
|
||||||
Infrastructure & Runtime
|
Infrastructure & Runtime
|
||||||
</h1>
|
</h1>
|
||||||
<span className="px-2 py-0.5 rounded text-[11px] font-mono bg-surface-container-high border border-outline-variant/30 text-primary-fixed-dim">
|
<span className="px-2 py-0.5 rounded text-[11px] font-mono bg-surface-container-high border border-outline-variant/30 text-primary-fixed-dim">
|
||||||
Phase 1 (Read-Only)
|
Phase 2 Operations
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant">
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
|
@ -608,11 +609,16 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
|
||||||
{/* Deployment History Status */}
|
{/* Deployment History Status */}
|
||||||
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-outline-variant/10 text-[11px] text-outline flex items-center justify-between">
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-outline-variant/10 text-[11px] text-outline flex items-center justify-between">
|
||||||
<span className="font-mono">Deployment History:</span>
|
<span className="font-mono">Deployment History:</span>
|
||||||
<span className="text-outline italic">Unavailable in Phase 1</span>
|
<span className="text-primary-fixed-dim font-mono">Audited in Phase 2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Operations Actions & Deployment Controls */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-outline-variant/20">
|
||||||
|
<OperationsActions role={role} onActionCompleted={fetchData} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,4 +389,50 @@ export interface OperationsInfrastructureData {
|
||||||
collected_at: string;
|
collected_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpsActionType =
|
||||||
|
| 'deploy_website'
|
||||||
|
| 'restart_website'
|
||||||
|
| 'restart_api'
|
||||||
|
| 'reboot_server';
|
||||||
|
|
||||||
|
export type OpsActionState =
|
||||||
|
| 'queued'
|
||||||
|
| 'preflight'
|
||||||
|
| 'running'
|
||||||
|
| 'verifying'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'rollback_running'
|
||||||
|
| 'rolled_back'
|
||||||
|
| 'rollback_failed';
|
||||||
|
|
||||||
|
export interface OpsAction {
|
||||||
|
id: string;
|
||||||
|
action_type: OpsActionType;
|
||||||
|
actor_identity: string;
|
||||||
|
target_environment: string;
|
||||||
|
state: OpsActionState;
|
||||||
|
request_timestamp: string;
|
||||||
|
start_timestamp: string | null;
|
||||||
|
completion_timestamp: string | null;
|
||||||
|
failure_code: string | null;
|
||||||
|
status_message: string | null;
|
||||||
|
rollback_status: string | null;
|
||||||
|
health_result: string | null;
|
||||||
|
deployed_revision: string | null;
|
||||||
|
idempotency_key: string | null;
|
||||||
|
case_reference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsActionsResponse {
|
||||||
|
actions: OpsAction[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsActionCapabilities {
|
||||||
|
allowed_actions: OpsActionType[];
|
||||||
|
confirmation_required: OpsActionType[];
|
||||||
|
helper_configured: boolean;
|
||||||
|
can_execute: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1365,6 +1365,117 @@ assert.ok(
|
||||||
|
|
||||||
console.log(' PASS: Section 43 Production Operations Infrastructure Dashboard (Phase 1) verified.');
|
console.log(' PASS: Section 43 Production Operations Infrastructure Dashboard (Phase 1) verified.');
|
||||||
|
|
||||||
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4, OPS-4.2, OVERVIEW, SECTION 2 USERS, SECTION 3 SUPPORT CENTER, SECTION 4 CMS & SECTION 43 INFRASTRUCTURE TESTS PASSED (43/43) ---');
|
// ============================================================
|
||||||
|
// 44. OPERATIONS ACTIONS & DEPLOYMENT CONTROLS (PHASE 2)
|
||||||
|
// ============================================================
|
||||||
|
console.log('Testing 44: Operations Actions & Deployment Controls (Phase 2)...');
|
||||||
|
|
||||||
|
// 44.1 UI Component Contracts (operations-actions.tsx)
|
||||||
|
const actionsComponentSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-actions.tsx'), 'utf8');
|
||||||
|
|
||||||
|
// Strict Action allowlist and confirmation requirements
|
||||||
|
assert.ok(actionsComponentSrc.includes('deploy_website'), 'Actions component must include deploy_website');
|
||||||
|
assert.ok(actionsComponentSrc.includes('restart_website'), 'Actions component must include restart_website');
|
||||||
|
assert.ok(actionsComponentSrc.includes('restart_api'), 'Actions component must include restart_api');
|
||||||
|
assert.ok(actionsComponentSrc.includes('reboot_server'), 'Actions component must include reboot_server');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
actionsComponentSrc.includes('RESTART API'),
|
||||||
|
'Actions component must require exact typed confirmation "RESTART API"'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
actionsComponentSrc.includes('REBOOT MOTORSIGNAL SERVER'),
|
||||||
|
'Actions component must require exact typed confirmation "REBOOT MOTORSIGNAL SERVER"'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Role restriction
|
||||||
|
assert.ok(
|
||||||
|
actionsComponentSrc.includes("role === 'owner'") || actionsComponentSrc.includes('isOwner'),
|
||||||
|
'Actions component must restrict action triggers to owner role'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutual exclusion & active action handling
|
||||||
|
assert.ok(
|
||||||
|
actionsComponentSrc.includes('activeAction'),
|
||||||
|
'Actions component must track and display active running actions'
|
||||||
|
);
|
||||||
|
|
||||||
|
// History table columns
|
||||||
|
assert.ok(actionsComponentSrc.includes('Timestamp'), 'History table must display Timestamp column');
|
||||||
|
assert.ok(actionsComponentSrc.includes('Action'), 'History table must display Action column');
|
||||||
|
assert.ok(actionsComponentSrc.includes('Actor'), 'History table must display Actor column');
|
||||||
|
assert.ok(actionsComponentSrc.includes('Status'), 'History table must display Status column');
|
||||||
|
assert.ok(actionsComponentSrc.includes('Revision'), 'History table must display Revision column');
|
||||||
|
|
||||||
|
// Embedded in Infrastructure view
|
||||||
|
const updatedInfraSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-infrastructure.tsx'), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
updatedInfraSrc.includes('OperationsActions'),
|
||||||
|
'OperationsInfrastructure must embed OperationsActions component'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 44.2 Next.js API Route Proxies for Actions
|
||||||
|
const actionsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/actions/route.ts'), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
actionsRouteSrc.includes('hasValidOrigin'),
|
||||||
|
'Next.js actions route must enforce CSRF protection via hasValidOrigin'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
actionsRouteSrc.includes('__Host-motorsignal_ops'),
|
||||||
|
'Next.js actions route must enforce __Host-motorsignal_ops in production'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
actionsRouteSrc.includes('/api/v1/ops/actions'),
|
||||||
|
'Next.js actions route must proxy to backend /api/v1/ops/actions'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
actionsRouteSrc.includes('409'),
|
||||||
|
'Next.js actions route must forward 409 Conflict status'
|
||||||
|
);
|
||||||
|
|
||||||
|
const capabilitiesRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/actions/capabilities/route.ts'), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
capabilitiesRouteSrc.includes('/api/v1/ops/actions/capabilities'),
|
||||||
|
'Capabilities proxy must query backend capabilities endpoint'
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/actions/[actionId]/route.ts'), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
actionDetailRouteSrc.includes('/api/v1/ops/actions/'),
|
||||||
|
'Action detail proxy must query backend action by ID'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 44.3 Backend Endpoints & Role Gates in main.py
|
||||||
|
assert.ok(
|
||||||
|
backendMainSrc.includes('@app.post("/api/v1/ops/actions'),
|
||||||
|
'Backend main.py must register POST /api/v1/ops/actions'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
backendMainSrc.includes('@app.get("/api/v1/ops/actions'),
|
||||||
|
'Backend main.py must register GET /api/v1/ops/actions'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
backendMainSrc.includes('@app.get("/api/v1/ops/actions/capabilities'),
|
||||||
|
'Backend main.py must register GET /api/v1/ops/actions/capabilities'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
backendMainSrc.includes('@app.get("/api/v1/ops/actions/{action_id}'),
|
||||||
|
'Backend main.py must register GET /api/v1/ops/actions/{action_id}'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
backendMainSrc.includes('reconcile_stale_actions'),
|
||||||
|
'Backend main.py must reconcile stale actions on startup'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 44.4 Privileged Boundary Helper Script
|
||||||
|
const helperSrc = fs.readFileSync(path.resolve(__dirname, '../../backend/motorsignal_api/scripts/ops-action-helper.sh'), 'utf8');
|
||||||
|
assert.ok(helperSrc.includes('deploy_website'), 'Helper must handle deploy_website');
|
||||||
|
assert.ok(helperSrc.includes('restart_website'), 'Helper must handle restart_website');
|
||||||
|
assert.ok(helperSrc.includes('restart_api'), 'Helper must handle restart_api');
|
||||||
|
assert.ok(helperSrc.includes('reboot_server'), 'Helper must handle reboot_server');
|
||||||
|
assert.ok(helperSrc.includes('PREV_RELEASE'), 'Helper must retain PREV_RELEASE for automatic rollback');
|
||||||
|
assert.ok(helperSrc.includes('/api/version'), 'Helper must verify /api/version ingress readiness');
|
||||||
|
|
||||||
|
console.log(' PASS: Section 44 Operations Actions & Deployment Controls (Phase 2) verified.');
|
||||||
|
|
||||||
|
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4, OPS-4.2, OVERVIEW, SECTION 2 USERS, SECTION 3 SUPPORT CENTER, SECTION 4 CMS, SECTION 43 INFRASTRUCTURE & SECTION 44 ACTIONS TESTS PASSED (44/44) ---');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue