fix(security): split API from executor identity
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m40s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
E2E Health Check / e2e-health (push) Failing after 1m3s
CD Pipeline / post-deploy-checks (push) Successful in 3m35s
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m40s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
E2E Health Check / e2e-health (push) Failing after 1m3s
CD Pipeline / post-deploy-checks (push) Successful in 3m35s
This commit is contained in:
@@ -3497,21 +3497,17 @@ def _autonomous_execution_loop_ledger(
|
||||
telegram_rows = [_row_mapping(row) for row in telegram_latest_rows]
|
||||
auto_repair_rows = [_row_mapping(row) for row in auto_repair_latest_rows]
|
||||
|
||||
latest_observed_apply = _first_operation(
|
||||
operation_rows,
|
||||
"ansible_apply_executed",
|
||||
)
|
||||
latest_apply = _first_operation(
|
||||
latest_terminal_apply = _first_operation(
|
||||
operation_rows,
|
||||
"ansible_apply_executed",
|
||||
statuses={"success", "failed"},
|
||||
) or latest_observed_apply
|
||||
latest_inflight_apply = (
|
||||
latest_observed_apply
|
||||
if latest_observed_apply is not None
|
||||
and latest_observed_apply.get("op_id") != (latest_apply or {}).get("op_id")
|
||||
else None
|
||||
)
|
||||
latest_inflight_apply = _first_operation(
|
||||
operation_rows,
|
||||
"ansible_apply_executed",
|
||||
statuses={"pending"},
|
||||
)
|
||||
latest_apply = latest_terminal_apply or latest_inflight_apply
|
||||
latest_check = None
|
||||
latest_candidate = None
|
||||
if latest_apply is not None:
|
||||
|
||||
@@ -2594,12 +2594,21 @@ async def run_controlled_apply_for_claim(
|
||||
)
|
||||
apply_op_id = str(inserted.scalar_one())
|
||||
|
||||
cancelled = False
|
||||
try:
|
||||
spec = build_ansible_apply_command(
|
||||
playbook_path=claim.apply_playbook_path,
|
||||
inventory_hosts=claim.inventory_hosts,
|
||||
)
|
||||
result = await _run_ansible_command(spec, timeout_seconds=timeout_seconds)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
result = AnsibleRunResult(
|
||||
returncode=130,
|
||||
stdout="",
|
||||
stderr="ansible_controlled_apply_interrupted_by_worker_shutdown",
|
||||
duration_ms=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
result = AnsibleRunResult(
|
||||
returncode=1,
|
||||
@@ -2631,6 +2640,16 @@ async def run_controlled_apply_for_claim(
|
||||
"op_id": apply_op_id,
|
||||
},
|
||||
)
|
||||
if cancelled:
|
||||
logger.warning(
|
||||
"ansible_controlled_apply_interrupted",
|
||||
op_id=apply_op_id,
|
||||
check_mode_op_id=claim.op_id,
|
||||
source_candidate_op_id=claim.source_candidate_op_id,
|
||||
incident_id=claim.incident_id,
|
||||
catalog_id=claim.catalog_id,
|
||||
)
|
||||
raise asyncio.CancelledError
|
||||
receipt_written = await _record_auto_repair_execution_receipt(
|
||||
claim,
|
||||
result,
|
||||
|
||||
@@ -2458,14 +2458,6 @@ def test_runtime_execution_loop_uses_terminal_apply_without_hiding_inflight_appl
|
||||
ledger = runtime_control_module._autonomous_execution_loop_ledger(
|
||||
project_id="awoooi",
|
||||
operation_latest_rows=[
|
||||
{
|
||||
"op_id": "pending-apply",
|
||||
"operation_type": "ansible_apply_executed",
|
||||
"status": "pending",
|
||||
"automation_run_id": "pending-run",
|
||||
"incident_id": "INC-PENDING",
|
||||
"created_at": "2026-07-10T15:00:00Z",
|
||||
},
|
||||
{
|
||||
"op_id": "terminal-apply",
|
||||
"operation_type": "ansible_apply_executed",
|
||||
@@ -2474,6 +2466,14 @@ def test_runtime_execution_loop_uses_terminal_apply_without_hiding_inflight_appl
|
||||
"incident_id": "INC-TERMINAL",
|
||||
"created_at": "2026-07-10T14:59:00Z",
|
||||
},
|
||||
{
|
||||
"op_id": "pending-apply",
|
||||
"operation_type": "ansible_apply_executed",
|
||||
"status": "pending",
|
||||
"automation_run_id": "pending-run",
|
||||
"incident_id": "INC-PENDING",
|
||||
"created_at": "2026-07-10T15:00:00Z",
|
||||
},
|
||||
],
|
||||
verifier_latest_rows=[],
|
||||
km_latest_rows=[],
|
||||
|
||||
@@ -1970,6 +1970,16 @@ def test_ansible_subprocess_is_terminated_when_worker_task_is_cancelled() -> Non
|
||||
assert "await process.communicate()" in source
|
||||
|
||||
|
||||
def test_controlled_apply_shutdown_marks_pending_row_before_cancelling() -> None:
|
||||
source = inspect.getsource(run_controlled_apply_for_claim)
|
||||
|
||||
assert "except asyncio.CancelledError" in source
|
||||
assert "ansible_controlled_apply_interrupted_by_worker_shutdown" in source
|
||||
assert '"status": status' in source
|
||||
assert '"op_id": apply_op_id' in source
|
||||
assert "ansible_controlled_apply_interrupted" in source
|
||||
|
||||
|
||||
def test_ansible_post_apply_verifier_helpers_are_deterministic() -> None:
|
||||
assert _post_apply_km_path_type("03ca6836-1b76-4da2-8e3e-6d3b6df9254a") == (
|
||||
"ansible_apply_receipt:03ca6836"
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from src.workers import signal_worker
|
||||
|
||||
|
||||
@@ -38,6 +40,62 @@ def test_worker_manifest_mounts_existing_ssh_mcp_transport() -> None:
|
||||
assert "optional: true" in manifest
|
||||
|
||||
|
||||
def test_api_manifest_has_read_only_identity_and_no_ssh_executor_mounts() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
deployment = next(
|
||||
document
|
||||
for document in yaml.safe_load_all(
|
||||
(repo_root / "k8s/awoooi-prod/06-deployment-api.yaml").read_text()
|
||||
)
|
||||
if document and document.get("kind") == "Deployment"
|
||||
)
|
||||
pod_spec = deployment["spec"]["template"]["spec"]
|
||||
container = pod_spec["containers"][0]
|
||||
env = {item["name"]: item.get("value") for item in container["env"]}
|
||||
mount_names = {item["name"] for item in container.get("volumeMounts", [])}
|
||||
volume_names = {item["name"] for item in pod_spec.get("volumes", [])}
|
||||
|
||||
assert pod_spec["serviceAccountName"] == "awoooi-api"
|
||||
assert env["SSH_MCP_ENABLED"] == "false"
|
||||
assert env["ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER"] == "false"
|
||||
assert env["ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER"] == "false"
|
||||
assert {"repair-ssh-key", "repair-known-hosts", "ssh-mcp-key"}.isdisjoint(
|
||||
mount_names | volume_names
|
||||
)
|
||||
|
||||
|
||||
def test_api_reader_rbac_has_no_workload_write_verbs() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
documents = [
|
||||
document
|
||||
for document in yaml.safe_load_all(
|
||||
(repo_root / "k8s/awoooi-prod/07-rbac.yaml").read_text()
|
||||
)
|
||||
if document
|
||||
]
|
||||
by_name = {
|
||||
(document["kind"], document["metadata"]["name"]): document
|
||||
for document in documents
|
||||
}
|
||||
reader = by_name[("ClusterRole", "awoooi-api-reader-role")]
|
||||
binding = by_name[("ClusterRoleBinding", "awoooi-api-reader-binding")]
|
||||
api_account = by_name[("ServiceAccount", "awoooi-api")]
|
||||
|
||||
assert api_account["metadata"]["namespace"] == "awoooi-prod"
|
||||
assert binding["subjects"] == [
|
||||
{
|
||||
"kind": "ServiceAccount",
|
||||
"name": "awoooi-api",
|
||||
"namespace": "awoooi-prod",
|
||||
}
|
||||
]
|
||||
assert binding["roleRef"]["name"] == "awoooi-api-reader-role"
|
||||
assert all(
|
||||
set(rule["verbs"]) <= {"get", "list", "watch"}
|
||||
for rule in reader["rules"]
|
||||
)
|
||||
|
||||
|
||||
def test_api_log_does_not_claim_skipped_executor_was_scheduled() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
main_source = (repo_root / "apps/api/src/main.py").read_text()
|
||||
|
||||
@@ -56,14 +56,9 @@ spec:
|
||||
app: awoooi-api
|
||||
environment: prod
|
||||
system: awoooi
|
||||
# Phase 7: 使用 RBAC ServiceAccount (最小權限)
|
||||
serviceAccountName: awoooi-executor
|
||||
# API is read-only. All Kubernetes/host mutations belong to the worker.
|
||||
serviceAccountName: awoooi-api
|
||||
automountServiceAccountToken: true
|
||||
# 2026-04-09 Claude Sonnet 4.6 Asia/Taipei: Bug #12 修正
|
||||
# fsGroup=1000 讓 appuser(1000) 可讀取 defaultMode=0400 的 repair-ssh-key Secret
|
||||
# SSH 要求 key 必須是 owner-only (0400/0600),0444 會被拒絕
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: api
|
||||
# 映像標籤由 CI/CD 動態注入 (格式: {sha}-{run_id})
|
||||
@@ -146,8 +141,10 @@ spec:
|
||||
value: "80"
|
||||
- name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: "/tmp/awoooi-prometheus-multiproc"
|
||||
- name: SSH_MCP_ENABLED
|
||||
value: "false"
|
||||
- name: ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER
|
||||
value: "true"
|
||||
value: "false"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS
|
||||
value: "300"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT
|
||||
@@ -158,16 +155,12 @@ spec:
|
||||
value: "120"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE
|
||||
value: "ssh_mcp"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH
|
||||
value: "/run/secrets/ssh_mcp_key"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH
|
||||
value: "/etc/ssh-mcp/known_hosts"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS
|
||||
value: "24"
|
||||
- name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_COOLDOWN_SECONDS
|
||||
value: "21600"
|
||||
- name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER
|
||||
value: "true"
|
||||
value: "false"
|
||||
- name: AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS
|
||||
value: "600"
|
||||
- name: AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_BATCH_LIMIT
|
||||
@@ -180,33 +173,13 @@ spec:
|
||||
value: "true"
|
||||
- name: AWOOOI_REBOOT_SLO_PROMETHEUS_QUERY_URL
|
||||
value: "http://192.168.0.110:9090/api/v1/query"
|
||||
# 2026-04-05 Claude Code: Sprint 3 — 掛載 SSH key 供 HostRepairAgent 使用
|
||||
volumeMounts:
|
||||
- name: repair-ssh-key
|
||||
mountPath: /etc/repair-ssh
|
||||
readOnly: true
|
||||
# 2026-04-06 Claude Code: Sprint 3 Security Fix A1 — known_hosts
|
||||
# 掛到獨立目錄,避免與 repair-ssh-key 的 mountPath 衝突
|
||||
- name: repair-known-hosts
|
||||
mountPath: /etc/repair-known-hosts
|
||||
readOnly: true
|
||||
# 2026-04-08 Claude Sonnet 4.6: Sprint 5.1 Guardrail — service registry YAML
|
||||
# 掛載到 /app/ops/config/ 讓 _find_registry_path() 可找到
|
||||
- name: service-registry
|
||||
mountPath: /app/ops/config/service-registry.yaml
|
||||
subPath: service-registry.yaml
|
||||
readOnly: true
|
||||
# MCP Phase 2a (2026-04-11 Claude Sonnet 4.6): SSH MCP key
|
||||
# ssh_mcp_key → /run/secrets/ssh_mcp_key (SSH_KEY_PATH in ssh_provider.py)
|
||||
# known_hosts → /etc/ssh-mcp/known_hosts (SSH_MCP_KNOWN_HOSTS_FILE)
|
||||
- name: ssh-mcp-key
|
||||
mountPath: /run/secrets/ssh_mcp_key
|
||||
subPath: ssh_mcp_key
|
||||
readOnly: true
|
||||
- name: ssh-mcp-key
|
||||
mountPath: /etc/ssh-mcp/known_hosts
|
||||
subPath: known_hosts
|
||||
readOnly: true
|
||||
- name: prometheus-multiproc
|
||||
mountPath: /tmp/awoooi-prometheus-multiproc
|
||||
resources:
|
||||
@@ -256,29 +229,11 @@ spec:
|
||||
matchLabels:
|
||||
app: awoooi-api
|
||||
topologyKey: kubernetes.io/hostname
|
||||
# 2026-04-05 Claude Code: Sprint 3 — repair SSH key (defaultMode 0400)
|
||||
volumes:
|
||||
- name: repair-ssh-key
|
||||
secret:
|
||||
secretName: awoooi-repair-ssh-key
|
||||
defaultMode: 0400 # 八進位 0400 = 十進位 256 = r-------- (owner read-only)
|
||||
# 2026-04-06 Claude Code: Sprint 3 Security Fix A1
|
||||
# optional: true — CD 首次跑時建立 secret,Pod 不阻塞等待
|
||||
- name: repair-known-hosts
|
||||
secret:
|
||||
secretName: awoooi-repair-known-hosts
|
||||
optional: true
|
||||
# 2026-04-08 Claude Sonnet 4.6: Sprint 5.1 Guardrail — service registry
|
||||
- name: service-registry
|
||||
configMap:
|
||||
name: service-registry
|
||||
# MCP Phase 2a (2026-04-11 Claude Sonnet 4.6): SSH MCP key
|
||||
# optional: true — SSH MCP 預設關閉,Secret 不存在時 Pod 不阻塞
|
||||
- name: ssh-mcp-key
|
||||
secret:
|
||||
secretName: ssh-mcp-key
|
||||
defaultMode: 0400
|
||||
optional: true
|
||||
- name: prometheus-multiproc
|
||||
emptyDir: {}
|
||||
|
||||
|
||||
@@ -19,6 +19,92 @@
|
||||
# - 存取 Secrets (除非明確需要)
|
||||
# - 執行任何 cluster-admin 操作
|
||||
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: awoooi-api
|
||||
namespace: awoooi-prod
|
||||
labels:
|
||||
app: awoooi
|
||||
component: api
|
||||
system: awoooi
|
||||
annotations:
|
||||
description: "AWOOOI API - read-only control-plane identity"
|
||||
|
||||
---
|
||||
# Public API may observe runtime state but cannot mutate workloads or hosts.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: awoooi-api-reader-role
|
||||
labels:
|
||||
app: awoooi
|
||||
component: api
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- pods
|
||||
- pods/status
|
||||
- events
|
||||
- namespaces
|
||||
- services
|
||||
- configmaps
|
||||
- persistentvolumeclaims
|
||||
- nodes
|
||||
- nodes/status
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["apps"]
|
||||
resources:
|
||||
- deployments
|
||||
- deployments/status
|
||||
- replicasets
|
||||
- statefulsets
|
||||
- daemonsets
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["autoscaling"]
|
||||
resources: ["horizontalpodautoscalers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["nodes", "pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["velero.io"]
|
||||
resources:
|
||||
- backups
|
||||
- backupstoragelocations
|
||||
- backuprepositories
|
||||
- podvolumebackups
|
||||
- podvolumerestores
|
||||
- restores
|
||||
- schedules
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: awoooi-api-reader-binding
|
||||
labels:
|
||||
app: awoooi
|
||||
component: api
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: awoooi-api
|
||||
namespace: awoooi-prod
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: awoooi-api-reader-role
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# k8s/rbac/api-velero-reader.yaml
|
||||
# API Pod 讀取 Velero backup 資源的 RBAC
|
||||
# Sprint 5.1 K-001 / 2026-04-08 Asia/Taipei
|
||||
# 說明: awoooi-executor ServiceAccount 需要讀取 velero namespace 的 backup 資源
|
||||
# 說明: awoooi-api ServiceAccount 需要讀取 velero namespace 的 backup 資源
|
||||
# 用於 Pre-flight Check 查詢最近備份時間(Q7 決策:kubectl 方式)
|
||||
# 注意: ServiceAccount 名稱為 awoooi-executor(非 awoooi-api,經 L0 確認)
|
||||
# 注意: executor 與 public API identity 已分離;此 binding 只給 API read-only。
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
@@ -28,7 +28,7 @@ metadata:
|
||||
sprint: "5.1"
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: awoooi-executor
|
||||
name: awoooi-api
|
||||
namespace: awoooi-prod
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
|
||||
Reference in New Issue
Block a user