Compare commits

...
Author SHA1 Message Date
chatgpt-gitops-bot c2525ecd51 feat(gitops): harden one-approval silent release saga 2026-07-23 23:39:47 +08:00
8 changed files with 3089 additions and 273 deletions
+120 -3
View File
@@ -24,6 +24,15 @@ from gitea_tag_publication import (
validate_version,
)
from orchestrator.action_monitor import ActionMonitor
from silent_release_hardening import (
ApprovalKeyRing,
ApprovalRevocationStore,
CapabilityAuthorizationError,
SagaConflict,
SignedApprovalVerifier,
SignedReleasePlanApproval,
SilentReleaseActionService,
)
try:
from authoritative_git import (
AuthoritativeGitError,
@@ -60,6 +69,17 @@ OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
'AIOS_SILENT_RELEASE_STATE_ROOT',
'/var/lib/aios-gitops/silent-release',
))
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
)).lower() in {'1', 'true', 'yes', 'on'}
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
'/etc/aios-gitops/silent-release-owner-public-keys.json',
))
monitor = ActionMonitor()
BASE_MODELS = [
@@ -80,11 +100,19 @@ READ_ONLY_GET_PATHS = frozenset({
'/admin/ai-evolution/api/data',
'/api/release-consistency', '/api/tag-publication-status',
'/api/release-acceptance',
'/getReleaseCandidate', '/getApprovalEnvelope', '/getReleaseSaga',
})
SILENT_RELEASE_POST_PATHS = frozenset({
'/createReleaseCandidate',
'/submitOwnerApproval',
'/startDeployment',
})
def reject_non_read_http(handler, method: str, path: str) -> bool:
if method == 'GET':
return False
if path in SILENT_RELEASE_POST_PATHS:
return False
decision = evaluate_git_write(WS)
if decision.allowed:
return False
@@ -101,6 +129,32 @@ def sh(args, check=False):
def jload(p: Path, default):
try: return json.loads(p.read_text(encoding='utf-8'))
except Exception: return default
def _file_sha256(path: Path) -> str:
digest=hashlib.sha256()
with path.open('rb') as source:
for chunk in iter(lambda: source.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def silent_release_service() -> SilentReleaseActionService:
verifier=None
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
verifier=SignedApprovalVerifier(
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
ApprovalRevocationStore(
SILENT_RELEASE_STATE_ROOT / 'revocations'
),
)
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
return SilentReleaseActionService(
SILENT_RELEASE_STATE_ROOT,
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
approval_verifier=verifier,
expected_repository=REPOSITORY_IDENTITY,
policy_sha256=policy_sha,
)
def relpath(path: str) -> str:
x = str(path or '').replace('\\','/').strip()
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
@@ -468,12 +522,36 @@ class H(BaseHTTPRequestHandler):
if path == '/api/release-acceptance':
request=publication_request_from_query(q)
return self.sendj(publication_publisher().run_release_acceptance(request))
if path in (
'/getReleaseCandidate',
'/getApprovalEnvelope',
'/getReleaseSaga',
):
candidate_id=(q.get('candidate_id') or [''])[0]
if not candidate_id:
return self.sendj({'error':'candidate_id is required'},400)
service=silent_release_service()
if path == '/getReleaseCandidate':
return self.sendj(
service.get_release_candidate(candidate_id)
)
if path == '/getApprovalEnvelope':
return self.sendj(
service.get_approval_envelope(candidate_id)
)
return self.sendj(service.get_release_saga(candidate_id))
# Admin AI Evolution Dashboard
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
return self.ai_evolution_page()
if path in ('/admin/ai-evolution/api/data'):
return self.ai_evolution_data()
return self.sendj({'error':'not_found','path':path},404)
except FileNotFoundError:
return self.sendj({'error':'release candidate not found'},404)
except ValueError as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
return self.sendj({'error':error},400)
except Exception as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
@@ -489,6 +567,32 @@ class H(BaseHTTPRequestHandler):
if reject_non_read_http(self, 'POST', path): return
data=self.body()
try:
if path == '/createReleaseCandidate':
a=self.need('release:candidate')
if not a: return
return self.sendj(
silent_release_service().create_release_candidate(data)
)
if path == '/submitOwnerApproval':
a=self.need('release:approve')
if not a: return
return self.sendj(
silent_release_service().submit_owner_approval(data)
)
if path == '/startDeployment':
a=self.need('release:deploy')
if not a: return
approval=(
SignedReleasePlanApproval.from_dict(data['approval'])
if isinstance(data.get('approval'), dict)
else None
)
return self.sendj(
silent_release_service().start_deployment(
str(data.get('candidate_id') or ''),
approval=approval,
)
)
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
if path in ('/api/file','/api/commit-file'): return self.file(data)
if path == '/api/commit': return self.commit(data)
@@ -499,9 +603,22 @@ class H(BaseHTTPRequestHandler):
if path == '/api/model-priority': return self.model_priority(data)
if path == '/api/nl': return self.nl(data)
return self.sendj({'error':'not_found','path':path},404)
except (ValueError, FileNotFoundError) as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},400)
except CapabilityAuthorizationError as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},403)
except SagaConflict as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},409)
except Exception as e:
monitor.record_failure('do_POST', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},500)
def do_PATCH(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'PATCH', path): return
@@ -703,7 +820,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
+260 -57
View File
@@ -46,6 +46,57 @@ def redact(value: Any) -> str:
return text
class EnvironmentAllowList:
"""Build a subprocess environment without inheriting process secrets."""
SENSITIVE_NAME_RE = re.compile(
r"(?i)(authorization|credential|password|secret|token|api[_-]?key|"
r"askpass|auth[_-]?sock|ld_preload|pythonpath|database_url)"
)
def __init__(self, allowed_names: set[str] | frozenset[str]):
self.allowed_names = frozenset(str(name) for name in allowed_names)
def build(self, values: dict[str, str]) -> dict[str, str]:
result: dict[str, str] = {}
for name, raw in values.items():
if (
name not in self.allowed_names
or self.SENSITIVE_NAME_RE.search(name)
):
raise ValueError(f"environment variable is not allowed: {name}")
value = str(raw)
if redact(value) != value:
raise ValueError(
f"environment value is not allowed: {name}"
)
result[name] = value
return result
SAFE_PROCESS_ENVIRONMENT = EnvironmentAllowList(
{
"PATH",
"LANG",
"LC_ALL",
"TZ",
"HOME",
"GIT_OPTIONAL_LOCKS",
"PYTHONDONTWRITEBYTECODE",
}
).build(
{
"PATH": "/usr/bin:/bin",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"TZ": "UTC",
"HOME": "/var/empty",
"GIT_OPTIONAL_LOCKS": "0",
"PYTHONDONTWRITEBYTECODE": "1",
}
)
def _sanitize(value: Any) -> Any:
if isinstance(value, dict):
return {
@@ -510,11 +561,16 @@ class DeploymentController:
ledger: ApprovalLedger,
policy: SilentReleasePolicy,
store: ReleaseCandidateStore | None = None,
*,
allow_legacy_unsigned_approval: bool = False,
):
self.deployment = deployment
self.ledger = ledger
self.policy = policy
self.store = store
self.allow_legacy_unsigned_approval = (
allow_legacy_unsigned_approval
)
def _save(self, candidate: ReleaseCandidate) -> None:
if self.store:
@@ -525,6 +581,11 @@ class DeploymentController:
candidate: ReleaseCandidate,
approval: ApprovalEnvelope,
) -> dict[str, Any]:
if not self.allow_legacy_unsigned_approval:
raise ApprovalError(
"legacy unsigned runtime approval is disabled; "
"use SignedReleasePlanApproval"
)
if candidate.state != ReleaseState.READY_FOR_APPROVAL:
raise ApprovalError("candidate is not ready for approval")
if not self.policy.requires_owner("production_runtime_switch"):
@@ -675,7 +736,7 @@ class CommandReleaseBuilder:
text=True,
capture_output=True,
timeout=self.config.timeout,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
env=dict(SAFE_PROCESS_ENVIRONMENT),
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError("release build unavailable") from exc
@@ -741,12 +802,25 @@ class TagController:
adapter: TagAdapter,
policy: SilentReleasePolicy,
ledger: ApprovalLedger | None,
*,
allow_legacy_unsigned_approval: bool = False,
):
self.adapter = adapter
self.policy = policy
self.ledger = ledger
self.allow_legacy_unsigned_approval = (
allow_legacy_unsigned_approval
)
def _require_legacy_test_mode(self) -> None:
if not self.allow_legacy_unsigned_approval:
raise ApprovalError(
"legacy unsigned tag approval is disabled; "
"use SignedReleasePlanApproval"
)
def approve(self, envelope: TagApprovalEnvelope) -> TagApprovalEnvelope:
self._require_legacy_test_mode()
if not self.policy.requires_owner("production_tag"):
raise ApprovalError("production tag is not owner governed")
if not self.policy.is_forbidden("tag_rewrite"):
@@ -757,6 +831,7 @@ class TagController:
def execute(
self, approved: TagApprovalEnvelope
) -> dict[str, Any]:
self._require_legacy_test_mode()
approved.validate()
existing = self.adapter.lookup(approved.version)
expected = (approved.tag_oid, approved.target_oid)
@@ -793,6 +868,7 @@ class TagController:
def verify(
self, approved: TagApprovalEnvelope
) -> dict[str, Any]:
self._require_legacy_test_mode()
actual = self.adapter.verify(approved.version)
expected = (approved.tag_oid, approved.target_oid)
if actual != expected:
@@ -837,7 +913,7 @@ class CommandReleaseProbe:
text=True,
capture_output=True,
timeout=300,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
env=dict(SAFE_PROCESS_ENVIRONMENT),
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError("COMMAND_UNAVAILABLE") from exc
@@ -1000,6 +1076,13 @@ class SystemdDeploymentConfig:
health_url: str
command_prefix: tuple[str, ...] = ()
dropin_name: str = "aios-gitops-model-api-production.conf"
service_allowlist: tuple[str, ...] = ("aios-gitops-model-api",)
dropin_allowlist: tuple[str, ...] = (
"aios-gitops-model-api-production.conf",
)
deployment_lock: Path = Path(
"/run/lock/aios-silent-release-deployment.lock"
)
class SystemdDeploymentAdapter:
@@ -1007,39 +1090,134 @@ class SystemdDeploymentAdapter:
def __init__(self, config: SystemdDeploymentConfig):
self.config = config
self._lock_handle = None
self._restart_evidence: dict[str, Any] = {}
if self.config.service_name not in self.config.service_allowlist:
raise ValueError("systemd service is not allowlisted")
if (
self.config.dropin_name not in self.config.dropin_allowlist
or "/" in self.config.dropin_name
or "\\" in self.config.dropin_name
):
raise ValueError("systemd drop-in is not allowlisted")
def _acquire_lock(self) -> None:
if self._lock_handle is not None:
raise RuntimeError("deployment lock is already held")
path = self.config.deployment_lock
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+", encoding="utf-8")
try:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
except ImportError:
pass
self._lock_handle = handle
def _release_lock(self) -> None:
handle = self._lock_handle
self._lock_handle = None
if handle is None:
return
try:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except ImportError:
pass
handle.close()
def _service_identity(self) -> dict[str, Any]:
result = subprocess.run(
[
*self.config.command_prefix,
"systemctl",
"show",
self.config.service_name,
"--property=MainPID",
"--property=ActiveEnterTimestampMonotonic",
"--value",
],
check=True,
capture_output=True,
text=True,
env=dict(SAFE_PROCESS_ENVIRONMENT),
)
values = result.stdout.splitlines()
if len(values) < 2:
raise RuntimeError("systemd process identity unavailable")
try:
pid = int(values[0])
started = int(values[1])
except ValueError as exc:
raise RuntimeError(
"systemd process identity is invalid"
) from exc
return {"main_pid": pid, "started_monotonic": started}
def atomic_switch(
self, candidate: ReleaseCandidate
) -> dict[str, Any]:
target = (
self.config.release_root
/ candidate.source_commit
/ "deployment"
/ self.config.dropin_name
).resolve(strict=True)
link = self.config.cutover_link
if link.exists() and not link.is_symlink():
raise RuntimeError("cutover path is not a managed symlink")
previous = os.readlink(link) if link.is_symlink() else None
link.parent.mkdir(parents=True, exist_ok=True)
temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp")
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
os.symlink(str(target), temporary)
os.replace(temporary, link)
return {
"previous_target": previous,
"new_target": str(target),
"cutover_link": str(link),
}
self._acquire_lock()
try:
release_root = self.config.release_root.resolve(strict=True)
raw_target = (
release_root
/ candidate.source_commit
/ "deployment"
/ self.config.dropin_name
)
if raw_target.is_symlink():
raise RuntimeError("release drop-in must not be a symlink")
target = raw_target.resolve(strict=True)
if target.parent.parent.parent != release_root:
raise RuntimeError("release drop-in escapes release root")
link = self.config.cutover_link
if link.exists() and not link.is_symlink():
raise RuntimeError("cutover path is not a managed symlink")
previous = os.readlink(link) if link.is_symlink() else None
if previous:
previous_path = Path(previous)
if not previous_path.is_absolute():
previous_path = link.parent / previous_path
try:
previous_path.resolve().relative_to(release_root)
except ValueError as exc:
raise RuntimeError(
"existing cutover symlink escapes release root"
) from exc
link.parent.mkdir(parents=True, exist_ok=True)
temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp")
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
os.symlink(str(target), temporary)
os.replace(temporary, link)
return {
"previous_target": previous,
"new_target": str(target),
"cutover_link": str(link),
}
except Exception:
self._release_lock()
raise
def restart(self) -> None:
if self._lock_handle is None:
raise RuntimeError("deployment lock is not held")
before = self._service_identity()
command = [
*self.config.command_prefix,
"systemctl",
"daemon-reload",
]
subprocess.run(command, check=True, capture_output=True, text=True)
subprocess.run(
command,
check=True,
capture_output=True,
text=True,
env=dict(SAFE_PROCESS_ENVIRONMENT),
)
subprocess.run(
[
*self.config.command_prefix,
@@ -1050,7 +1228,18 @@ class SystemdDeploymentAdapter:
check=True,
capture_output=True,
text=True,
env=dict(SAFE_PROCESS_ENVIRONMENT),
)
after = self._service_identity()
if (
after["main_pid"] <= 0
or after["started_monotonic"]
<= before["started_monotonic"]
):
raise RuntimeError(
"systemd restart did not produce a new start identity"
)
self._restart_evidence = {"before": before, "after": after}
def health_check(
self, candidate: ReleaseCandidate
@@ -1062,44 +1251,58 @@ class SystemdDeploymentAdapter:
payload = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise RuntimeError("runtime health check unavailable") from exc
return {
"passed": response.status == 200,
"runtime_commit": payload.get("runtime_release_commit"),
"writes_enabled": payload.get("writes_enabled"),
"authoritative_head_oid": payload.get(
"authoritative_head_oid"
),
"repository_identity": payload.get("repository_identity"),
}
try:
return {
"passed": response.status == 200,
"runtime_commit": payload.get("runtime_release_commit"),
"writes_enabled": payload.get("writes_enabled"),
"authoritative_head_oid": payload.get(
"authoritative_head_oid"
),
"repository_identity": payload.get("repository_identity"),
"systemd_restart": _sanitize(self._restart_evidence),
}
finally:
self._release_lock()
def rollback(
self, switch_evidence: dict[str, Any]
) -> dict[str, Any]:
link = self.config.cutover_link
previous = switch_evidence.get("previous_target")
temporary = link.with_name(f".{link.name}.{os.getpid()}.rollback")
if previous:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
os.symlink(str(previous), temporary)
os.replace(temporary, link)
elif link.is_symlink():
link.unlink()
self.restart()
try:
with urllib.request.urlopen(
self.config.health_url, timeout=15
) as response:
payload = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise RuntimeError(
"rollback health check unavailable"
) from exc
return {
"passed": response.status == 200,
"writes_enabled": payload.get("writes_enabled"),
"runtime_commit": payload.get("runtime_release_commit"),
}
if self._lock_handle is None:
self._acquire_lock()
link = self.config.cutover_link
previous = switch_evidence.get("previous_target")
temporary = link.with_name(
f".{link.name}.{os.getpid()}.rollback"
)
if previous:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
os.symlink(str(previous), temporary)
os.replace(temporary, link)
elif link.is_symlink():
link.unlink()
self.restart()
try:
with urllib.request.urlopen(
self.config.health_url, timeout=15
) as response:
payload = json.loads(
response.read().decode("utf-8")
)
except Exception as exc:
raise RuntimeError(
"rollback health check unavailable"
) from exc
return {
"passed": response.status == 200,
"writes_enabled": payload.get("writes_enabled"),
"runtime_commit": payload.get("runtime_release_commit"),
"systemd_restart": _sanitize(self._restart_evidence),
}
finally:
self._release_lock()
def _sha256(path: Path) -> str:
File diff suppressed because it is too large Load Diff
+32 -5
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"schema_version": 2,
"automatic": [
"build",
"test",
@@ -8,8 +8,7 @@
"evidence"
],
"owner": [
"production_runtime_switch",
"production_tag"
"signed_release_plan"
],
"forbidden": [
"force_push",
@@ -19,7 +18,8 @@
"registry_mutation"
],
"runtime_deployment": {
"approval_action": "production_runtime_switch",
"approval_action": "signed_release_plan",
"required_capability": "runtime_switch",
"tag_operations_allowed": false,
"baseline_mutation_allowed": false,
"registry_mutation_allowed": false,
@@ -27,10 +27,37 @@
"rollback_required": true
},
"tag_release": {
"approval_action": "production_tag",
"approval_action": "signed_release_plan",
"required_capability": "tag_publish",
"separate_controller": true,
"duplicate_conflict": "fail_closed",
"rewrite_allowed": false,
"force_push_allowed": false
},
"baseline_registry_finalize": {
"approval_action": "signed_release_plan",
"required_capability": "baseline_registry_finalize",
"separate_controller": true,
"remote_tag_verification_required": true,
"gitea_api_verification_required": true,
"registry_baseline_consistency_required": true
},
"approval": {
"algorithm": "Ed25519",
"replay_protection": true,
"revocation_fail_closed": true,
"key_rotation_supported": true,
"capabilities": [
"runtime_switch",
"tag_publish",
"baseline_registry_finalize"
]
},
"execution": {
"environment_variable": "AIOS_SILENT_RELEASE_EXECUTION_ENABLED",
"default_enabled": false,
"shadow_side_effects_allowed": false,
"durable_saga_required": true,
"pre_switch_revalidation_required": true
}
}
+176 -207
View File
@@ -1,91 +1,80 @@
# AIOS Silent Release Controller Design Report
状态:isolated branch implementation
Production mutation:forbidden
目标流程:自动准备 → 自动验证 → 单次 Owner runtime 授权 → 自动部署/失败回滚
状态:隔离分支实现;生产 mutation 禁止
目标:自动准备 → 自动验证 → 单次 Owner 签名授权 → 分控制器自动执行与验收
## 1. 当前痛点
当前发布依赖多轮人工传递 commit、tree、archive、manifest、drop-in、测试和
canary 证据。证据在不同消息和报告中重复抄写,容易出现:
现有流程需要多轮人工传递 commit、tree、artifact、manifest、测试、canary、drop-in
和回滚证据。证据在不同消息里重复抄写,容易出现:
- artifact 在测试后被 cache 文件污染,closure 数量漂移;
- canary 通过,但批准文本与 runtime 实际字段契约不同;
- Git、Gitea、artifact、systemd 和 rollback 门禁由人手工拼接;
- runtime deployment 与 production tag 容易被误认为同一授权;
- 授权重放、失败回滚和持久化状态缺少统一接口。
- 构建后 artifact、manifest 或 policy 已变化,但旧批准仍被使用;
- Git、Gitea、systemd 与回滚结果由人工拼接,缺少同一一致性快照;
- runtime switch、tag、Baseline、Registry 的授权边界不清;
- 重启后只剩工作树内临时 JSON,无法可靠恢复;
- approval replay、并发 writer、key revoke 和 secret inheritance 缺少统一防线。
## 2. 新架构
```text
ReleaseBuilder adapter
Build / Test / Canary
|
v
SilentReleaseController.prepare(source_commit)
|
+--> ReleaseCandidateStore
ReleaseCandidate + DeploymentPlan
|
v
ReleaseAcceptanceEngine.evaluate(candidate)
|
+--> ReleaseProbe adapter
- local/bare/Gitea main
- repository identity
- artifact/manifest
- tests/canary
- rollback
- authoritative Git binding
ReleaseAcceptanceEngine
|
v
READY_FOR_APPROVAL
|
v one ApprovalEnvelope: production_runtime_switch
DeploymentController.deploy(candidate, approval)
v
SignedReleasePlanApproval (Ed25519)
|
+--> atomic switch
+--> restart only GitOps runtime
+--> health verification
+--> rollback on any failure
+--> runtime_switch capability
| DeploymentController
|
+--> tag_publish capability
| TagController
|
+--> baseline_registry_finalize capability
BaselineRegistryFinalizeController
TagController (separate seam)
|
+--> separate production_tag approval
+--> execute
+--> verify
Durable ReleaseSagaStore + CapabilityLedger + Event Journal
贯穿三个控制器;控制器互相不拥有对方的 mutation API。
```
`SilentReleaseController` 是外部深模块,只暴露 `prepare()` 和 `deploy()`。
构建、探针、systemd 与 tag provider 都是 adapter;复杂实现保持在模块内部。
`CommandReleaseBuilder` 使用无 shell 的 argv 调用确定性构建流水线,并只接受
与请求 source commit 一致的候选 JSON。
一次 Owner 批准是同一个不可变 release plan 的签名,不是一个万能执行入口。三个 capability
分别消费、分别留痕、分别幂等恢复。`force_push`、`tag_rewrite`、`direct_main_push`
永远不是可签发 capability。
## 3. 状态机
```text
BUILDING
|
v
VERIFYING ------------------------> FAILED
|
v
READY_FOR_APPROVAL --------------> FAILED
|
v
APPROVED ------------------------> FAILED
|
v
DEPLOYING -------> VERIFIED
| |
+-------------> ROLLED_BACK
|
+-------------> FAILED (rollback itself failed)
PREPARING
-> VERIFYING
-> READY_FOR_APPROVAL
-> APPROVED
-> PRE_SWITCH_REVALIDATING
-> SWITCH_PENDING
-> SWITCHED
-> RUNTIME_VERIFYING
-> TAG_PENDING
-> TAG_VERIFIED
-> BASELINE_REGISTRY_PENDING
-> COMPLETED
任一可恢复故障 -> RECONCILIATION_REQUIRED
对象冲突 -> MANUAL_OWNER_REVIEW_REQUIRED
切换后失败 -> ROLLBACK_PENDING -> ROLLED_BACK
```
状态不可跳跃。`VERIFIED` 只允许显式进入 `ROLLED_BACK`,终态不能自动回到准备态。
状态快照使用 revision compare-and-set;事件同时写入 append-only journal。writer 崩溃后根据
状态确定唯一恢复动作。并发控制器中只有一个 CAS winner。
## 4. 数据模型
`ReleaseCandidate`:
### ReleaseCandidate
```text
candidate_id
@@ -99,201 +88,181 @@ rollback_ready
git_consistency
gitea_consistency
approval_state
state
failure_reason
created_at
updated_at
```
核心 OID 和 SHA 在构造时验证。候选 fingerprint 绑定 candidate、commit、tree、
artifact 和 manifest,ApprovalEnvelope 必须逐字段匹配。
`AcceptanceEvidence` 是 adapter 与 acceptance engine 之间的 seam,包含三方 main、
repository identity、artifact/manifest、测试、canary、rollback 和 authoritative Git。
## 5. Interface
Python interface:
```python
candidate = controller.prepare(source_commit)
approval = ApprovalEnvelope.for_candidate(
candidate,
approval_id=...,
owner_id=...,
expires_at=...,
)
result = controller.deploy(candidate.candidate_id, approval)
```
建议 HTTP Action adapter:
### DeploymentPlan
```text
POST /api/silent-release/candidates
GET /api/silent-release/candidates/{candidate_id}
POST /api/silent-release/candidates/{candidate_id}/verify
POST /api/silent-release/candidates/{candidate_id}/approve-runtime
POST /api/silent-release/candidates/{candidate_id}/deploy
GET /api/silent-release/candidates/{candidate_id}/acceptance
service
release_path / release_root
dropin_path / dropin_sha256
rollback_path / rollback_sha256
source_commit
artifact_sha256
manifest_sha256
policy_sha256
health_contract
sha256
```
Tag interface 必须位于独立 namespace:
### SignedReleasePlanApproval
```text
POST /api/tag-releases/{version}/approve
POST /api/tag-releases/{version}/execute
GET /api/tag-releases/{version}/verify
release_plan_id
candidate_id
source_commit
tree_oid
artifact_sha256
manifest_sha256
deployment_plan_sha256
policy_sha256
approval_plan_id
evidence_closure_oid
owner_approval_record_oid
capabilities
expires_at
nonce
key_id
signature
fingerprint
```
runtime approval 不得被 TagController 接受;tag approval 不得被
DeploymentController 接受。
签名内容使用 canonical JSON,`signature` 不参与被签名字段。验证包括 Ed25519 签名、过期时间、
公钥状态、approval revoke、candidate/plan/policy 逐字段绑定和 capability allowlist。
## 6. ReleaseAcceptanceEngine
`CommandReleaseProbe` 提供实际只读 adapter:
- local main:指定 local repository;
- bare main:指定 authoritative bare Git;
- Gitea main/identity:认证 API,只从 credential reference 读取 token;
- artifact/manifest:流式 SHA-256 和 JSON 绑定;
- tests/canary:无 shell 的 argv command;
- runtime without `.git`;
- rollback artifact;
- authoritative Git 路径与可读性。
任何命令、Git、Gitea 或证据异常均转为 `FAILED`,不会降级为空成功。
## 7. ApprovalEnvelope
Owner 的 runtime envelope 只授权:
## 5. API
```text
action=production_runtime_switch
POST /createReleaseCandidate
GET /getReleaseCandidate?candidate_id=...
GET /getApprovalEnvelope?candidate_id=...
POST /submitOwnerApproval
POST /startDeployment
GET /getReleaseSaga?candidate_id=...
```
Envelope 绑定:
写接口要求 Bearer scope。`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` 是默认值;此时可以
持久化 candidate、生成 approval envelope、保存经验证的 Owner approval 和读取 Saga,
但 `/startDeployment` 只返回 `SHADOW_MODE` 与空 `side_effects`。
- candidate_id/fingerprint;
- source commit/tree;
- artifact/manifest SHA;
- owner、approval ID、签发和过期时间。
## 6. PreSwitchValidator
`ApprovalLedger` 使用 `O_EXCL` 原子消费 approval ID,重放直接拒绝。
切换前重新收集并比较:
生产 tag 使用完全独立的 `TagApprovalEnvelope`:
- source commit 与 tree;
- artifact、manifest、policy 和 deployment plan SHA-256;
- rollback readiness;
- local、authoritative bare 与 Gitea refs;
- repository identity;
- task writer/production writer 边界;
- health contract。
任一变化都抛出:
```text
action=production_tag
version
tag_oid
target_oid
APPROVAL_STALE_REAPPROVAL_REQUIRED
```
## 8. DeploymentController
旧批准不会被“最佳努力”继续使用。
只接受 `READY_FOR_APPROVAL` 候选和完整 runtime envelope:
## 7. systemd 防线
1. 验证 Policy as Code;
2. 原子消费 approval;
3. 原子切换 versioned drop-in symlink;
4. 仅 restart 指定 GitOps runtime;
5. 验证 runtime commit 和 writer=false;
6. 任一异常切回旧 symlink并再次 restart;
7. rollback 后重新执行健康检查并确认 writer=false;
8. rollback 或 rollback health 失败时进入 `FAILED`,禁止伪装成功。
- service 与 drop-in 均为显式 allowlist;
- release 与 drop-in resolved path 必须位于 release root 内;
- 拒绝普通文件被替换为 symlink,拒绝 symlink escape;
- deployment lock 覆盖 switch、restart 和首次 health check;
- restart 前后记录 MainPID 与 ActiveEnterTimestampMonotonic;
- artifact、drop-in、rollback 和 manifest 均按 plan hash 复验;
- 子进程只接收固定 allowlist 环境,不继承 token、password、credential、askpass、
auth socket、`LD_PRELOAD`、`PYTHONPATH` 或数据库连接串。
Deployment adapter 的 interface 中没有 tag、Baseline 或 Registry 方法,因此 runtime
部署路径无法顺带调用这些能力。
## 8. TagController 分离
## 9. TagController 分离
TagController 必须独立完成 approval、execute、verify,并留下:
TagController 的三个动作严格分开:
```text
evidence_closure_oid
approval_plan_id
repository_identity
remote_tag_oid
remote_peeled_target_oid
gitea_api_tag_oid
gitea_listing_visible
```
1. `approve(envelope)`
2. `execute(approved)`
3. `verify(approved)`
同名 tag 的 tag OID 或 peeled target 不同,进入
`MANUAL_OWNER_REVIEW_REQUIRED`;自动流程不 push、不删除、不重写、不 force push。
同名 tag 已存在且 tag OID 或 target OID 不一致时抛出
`DuplicateTagConflict`。接口没有 delete、rewrite 或 force push。
## 9. Baseline / Registry 门禁
只有以下全部一致时,`baseline_registry_finalize` capability 才能完成:
```text
Registry = Baseline = remote tag = Gitea API = expected target
```
UNKNOWN、API 不可达、repository identity 不符或 listing 不可见均 fail closed。
## 10. Policy as Code
`config/gitops/silent-release-policy.json`:
```text
automatic: build, test, artifact, canary, evidence
owner: production_runtime_switch, production_tag
forbidden: force_push, tag_rewrite, direct_main_push,
baseline_mutation, registry_mutation
automatic:
build, test, artifact, canary, evidence
owner:
signed_release_plan
capabilities:
runtime_switch
tag_publish
baseline_registry_finalize
forbidden:
force_push
tag_rewrite
direct_main_push
baseline_mutation
registry_mutation
```
Policy 缺少任一部署禁止项时,DeploymentController 拒绝运行。
最后两个 forbidden 项指“脱离受治理 finalize controller 的直接 mutation”。
## 11. Security model
- fail-closed Git/Gitea/manifest/health adapter;
- credential 只从受控文件读取,不进入模型或持久化候选;
- candidate store 对 Authorization、token、credential、password、secret 字段脱敏;
- 错误文本统一清除 Authorization、token、URL userinfo 和 password;
- approval ID 一次性消费并有过期时间;
- candidate fingerprint 防止对其他 artifact 重放批准;
- runtime 默认 writer=false;
- systemd adapter 只允许管理 symlink,拒绝覆盖普通配置文件;
- no tag rewrite、force push、direct main push;
- runtime deployment 无 Baseline/Registry/tag capability。
- Ed25519 公钥 ring 支持 rotation 和 revoke,私钥不进入 runtime;
- approval plan 可撤销,过期、未知 key、错误签名均 fail closed;
- nonce 与 capability operation 持久化,重复相同请求幂等,不同 fingerprint 重放拒绝;
- Saga snapshot 原子写、journal append-only、CAS 防并发 writer;
- credential 只通过受控 reference 使用,不进入 Git、API 响应或异常;
- 子进程环境从空集合构建,不复制 `os.environ`;
- Shadow 模式默认开启,execution adapter 缺失时不能降级执行;
- runtime、tag、Baseline/Registry 分属三个能力最小化控制器。
## 12. Migration plan
### Phase 1:shadow
1. Shadow:生成 candidate、plan、acceptance、approval package,仅比较现有人工结果。
2. Signed approval:接入 Owner 公钥 ring、撤销源和审计 record;仍不接生产 executor。
3. Canary execution:使用非生产 service/release root 验证锁、PID、health、崩溃恢复和回滚。
4. Controlled production switch:另行 Owner 授权启用 execution flag,只开放 runtime capability。
5. Tag/finalize:分别完成 Gitea 与 Registry/Baseline 生产验收后再启用对应 capability。
- 只生成 ReleaseCandidate 和 acceptance evidence;
- 与现有人工报告并行比较;
- 不接入 production executor。
### Phase 2:approval envelope
- Owner UI/Action 只生成一次性 runtime envelope;
- production 仍由现有受控 executor 执行;
- 验证 approval replay 和过期处理。
### Phase 3:controlled deployment
- 接入 versioned drop-in symlink;
- 先在 canary service 完成自动 switch/restart/rollback;
- production writer 始终为 false。
### Phase 4:silent release
- 自动 build/test/artifact/canary/evidence;
- READY_FOR_APPROVAL 时只通知一次 Owner;
- 获得 runtime envelope 后自动部署和验收;
- TagController 始终保持独立授权。
每个阶段均可退回上一个阶段;不开启下一能力不会影响已经启用的控制器。
## 13. Rollback plan
- 切换前记录旧 symlink target;
- 新 runtime 任一健康、Git、hash、writer 或一致性门禁失败即恢复旧 target;
- 只 restart GitOps runtime;
- rollback 后再次验证旧 runtime、writer=false 和只读 Git-backed API;
- 保留失败候选、脱敏证据和 approval ledger;
- 不使用 Git reset、force push、tag rewrite;
- rollback 不修改 Baseline、Registry 或 production tag。
- switch 前保存旧 symlink target、PID、启动时间、artifact 与 plan fingerprint;
- restart 或 health 任一失败进入 `ROLLBACK_PENDING`;
- 原子恢复旧 target,只 restart 指定 allowlist service;
- 重新验证旧 runtime commit、writer=false 与 repository identity;
- 成功进入 `ROLLED_BACK`,失败进入 `MANUAL_OWNER_REVIEW_REQUIRED`;
- rollback 不修改 Git main、production tag、Baseline 或 Registry;
- 保留脱敏 journal、approval fingerprint 和失败证据。
## 14. 自动测试矩阵
## 14. 测试与外部依赖边界
实现测试覆盖:
自动测试覆盖签名伪造、字段篡改、过期/revoke/key rotation、nonce replay、CAS race、
崩溃恢复、artifact/policy/plan mutation、tag conflict、secret leakage、runtime without
`.git`、rollback 和 shadow no-side-effects。
- artifact mismatch;
- Gitea unavailable;
- authoritative Git unavailable;
- health failure rollback;
- approval replay;
- duplicate tag conflict;
- secret leakage;
- runtime without `.git`;
- state transition;
- policy separation;
- candidate persistence;
- automatic prepare 到单次 Owner gate。
本隔离实现不创建生产 KMS/key、不安装 systemd unit、不部署 Action runtime、不创建 tag,
也不修改 Baseline/Registry。上述动作必须在单独生产批准中执行。
+135
View File
@@ -135,7 +135,142 @@ components:
properties:
text: {type: string}
branch: {type: string}
DeploymentPlan:
type: object
required: [service, release_path, release_root, dropin_path, dropin_sha256, rollback_path, rollback_sha256, source_commit, artifact_sha256, manifest_sha256, policy_sha256, health_contract]
properties:
service: {type: string}
release_path: {type: string}
release_root: {type: string}
dropin_path: {type: string}
dropin_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
rollback_path: {type: string}
rollback_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
health_contract: {type: object, additionalProperties: true}
ReleaseCandidateInput:
type: object
required: [candidate_id, source_commit, tree_oid, artifact_sha256, manifest_sha256]
properties:
candidate_id: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$'}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
tests: {type: object}
canary: {type: object}
rollback_ready: {type: boolean}
git_consistency: {type: object}
gitea_consistency: {type: object}
deployment_plan: {$ref: '#/components/schemas/DeploymentPlan'}
SignedReleasePlanApproval:
type: object
required: [release_plan_id, candidate_id, source_commit, tree_oid, artifact_sha256, manifest_sha256, deployment_plan_sha256, policy_sha256, approval_plan_id, evidence_closure_oid, owner_approval_record_oid, capabilities, expires_at, nonce, key_id, signature]
properties:
release_plan_id: {type: string}
candidate_id: {type: string}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
deployment_plan_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
approval_plan_id: {type: string}
evidence_closure_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
owner_approval_record_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
capabilities:
type: array
uniqueItems: true
items:
type: string
enum: [runtime_switch, tag_publish, baseline_registry_finalize]
expires_at: {type: string, format: date-time}
nonce: {type: string, minLength: 16}
key_id: {type: string}
signature: {type: string, format: byte, writeOnly: true}
StartDeploymentRequest:
type: object
required: [candidate_id, approval]
properties:
candidate_id: {type: string}
approval: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
paths:
/createReleaseCandidate:
post:
operationId: createReleaseCandidate
summary: Persist an immutable release candidate and deployment plan; shadow-safe
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReleaseCandidateInput'}
responses:
'200': {description: Candidate created or idempotently returned}
'400': {description: Invalid or conflicting candidate evidence}
'401': {description: Unauthorized}
x-openai-isConsequential: true
/getReleaseCandidate:
get:
operationId: getReleaseCandidate
summary: Read a durable release candidate
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Candidate}
'404': {description: Candidate not found}
/getApprovalEnvelope:
get:
operationId: getApprovalEnvelope
summary: Generate the immutable owner-signature payload
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Approval envelope fingerprint inputs}
'404': {description: Candidate not found}
/submitOwnerApproval:
post:
operationId: submitOwnerApproval
summary: Verify and durably store a signed owner release plan
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
responses:
'200': {description: Signature verified and approval stored}
'400': {description: Invalid approval}
'403': {description: Signature, key, expiry, or revocation failure}
'409': {description: Conflicting release plan}
x-openai-isConsequential: true
/startDeployment:
post:
operationId: startDeployment
summary: Start the approved Saga; default shadow mode returns no side effects
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/StartDeploymentRequest'}
responses:
'200': {description: Shadow result or deployment Saga result}
'403': {description: Execution disabled or approval invalid}
'409': {description: Saga conflict}
x-openai-isConsequential: true
/getReleaseSaga:
get:
operationId: getReleaseSaga
summary: Read durable Saga state and event journal
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Saga snapshot}
'404': {description: Saga not found}
/api/base:
get:
operationId: getBaseline
+8 -1
View File
@@ -154,6 +154,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
ApprovalLedger(Path(td) / "approvals"),
SilentReleasePolicy.default(),
store,
allow_legacy_unsigned_approval=True,
),
store=store,
policy=SilentReleasePolicy.default(),
@@ -248,6 +249,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
deployment,
ApprovalLedger(Path(td) / "approvals"),
SilentReleasePolicy.default(),
allow_legacy_unsigned_approval=True,
)
result = controller.deploy(value, envelope)
self.assertEqual(value.state, ReleaseState.VERIFIED)
@@ -273,6 +275,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
deployment,
ApprovalLedger(Path(td) / "approvals"),
SilentReleasePolicy.default(),
allow_legacy_unsigned_approval=True,
).deploy(value, envelope)
self.assertFalse(result["verified"])
self.assertEqual(value.state, ReleaseState.ROLLED_BACK)
@@ -292,7 +295,10 @@ class SilentReleaseControllerTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as td:
ledger = ApprovalLedger(Path(td) / "approvals")
controller = DeploymentController(
FakeDeployment(), ledger, SilentReleasePolicy.default()
FakeDeployment(),
ledger,
SilentReleasePolicy.default(),
allow_legacy_unsigned_approval=True,
)
controller.deploy(value, envelope)
second = candidate()
@@ -309,6 +315,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
adapter,
SilentReleasePolicy.default(),
ledger=None,
allow_legacy_unsigned_approval=True,
)
envelope = TagApprovalEnvelope(
approval_id="tag-approval-001",
File diff suppressed because it is too large Load Diff