Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2525ecd51 |
@@ -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}')
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user