Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2525ecd51 |
@@ -24,6 +24,15 @@ from gitea_tag_publication import (
|
|||||||
validate_version,
|
validate_version,
|
||||||
)
|
)
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
|
from silent_release_hardening import (
|
||||||
|
ApprovalKeyRing,
|
||||||
|
ApprovalRevocationStore,
|
||||||
|
CapabilityAuthorizationError,
|
||||||
|
SagaConflict,
|
||||||
|
SignedApprovalVerifier,
|
||||||
|
SignedReleasePlanApproval,
|
||||||
|
SilentReleaseActionService,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
from authoritative_git import (
|
from authoritative_git import (
|
||||||
AuthoritativeGitError,
|
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')))
|
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
|
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', '')
|
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()
|
monitor = ActionMonitor()
|
||||||
|
|
||||||
BASE_MODELS = [
|
BASE_MODELS = [
|
||||||
@@ -80,11 +100,19 @@ READ_ONLY_GET_PATHS = frozenset({
|
|||||||
'/admin/ai-evolution/api/data',
|
'/admin/ai-evolution/api/data',
|
||||||
'/api/release-consistency', '/api/tag-publication-status',
|
'/api/release-consistency', '/api/tag-publication-status',
|
||||||
'/api/release-acceptance',
|
'/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:
|
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
return False
|
return False
|
||||||
|
if path in SILENT_RELEASE_POST_PATHS:
|
||||||
|
return False
|
||||||
decision = evaluate_git_write(WS)
|
decision = evaluate_git_write(WS)
|
||||||
if decision.allowed:
|
if decision.allowed:
|
||||||
return False
|
return False
|
||||||
@@ -101,6 +129,32 @@ def sh(args, check=False):
|
|||||||
def jload(p: Path, default):
|
def jload(p: Path, default):
|
||||||
try: return json.loads(p.read_text(encoding='utf-8'))
|
try: return json.loads(p.read_text(encoding='utf-8'))
|
||||||
except Exception: return default
|
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:
|
def relpath(path: str) -> str:
|
||||||
x = str(path or '').replace('\\','/').strip()
|
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')
|
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':
|
if path == '/api/release-acceptance':
|
||||||
request=publication_request_from_query(q)
|
request=publication_request_from_query(q)
|
||||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
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
|
# Admin AI Evolution Dashboard
|
||||||
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
||||||
return self.ai_evolution_page()
|
return self.ai_evolution_page()
|
||||||
if path in ('/admin/ai-evolution/api/data'):
|
if path in ('/admin/ai-evolution/api/data'):
|
||||||
return self.ai_evolution_data()
|
return self.ai_evolution_data()
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
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:
|
except Exception as e:
|
||||||
error=redact(e)
|
error=redact(e)
|
||||||
monitor.record_failure('do_GET', error, {'path':path})
|
monitor.record_failure('do_GET', error, {'path':path})
|
||||||
@@ -489,6 +567,32 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if reject_non_read_http(self, 'POST', path): return
|
if reject_non_read_http(self, 'POST', path): return
|
||||||
data=self.body()
|
data=self.body()
|
||||||
try:
|
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/branch','/api/create-branch'): return self.branch(data)
|
||||||
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
||||||
if path == '/api/commit': return self.commit(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/model-priority': return self.model_priority(data)
|
||||||
if path == '/api/nl': return self.nl(data)
|
if path == '/api/nl': return self.nl(data)
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
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:
|
except Exception as e:
|
||||||
monitor.record_failure('do_POST', str(e), {'path':path})
|
error=redact(e)
|
||||||
return self.sendj({'error':str(e)},500)
|
monitor.record_failure('do_POST', error, {'path':path})
|
||||||
|
return self.sendj({'error':error},500)
|
||||||
def do_PATCH(self):
|
def do_PATCH(self):
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||||
if reject_non_read_http(self, 'PATCH', path): return
|
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():
|
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():
|
def main():
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
|
|||||||
@@ -46,6 +46,57 @@ def redact(value: Any) -> str:
|
|||||||
return text
|
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:
|
def _sanitize(value: Any) -> Any:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {
|
return {
|
||||||
@@ -510,11 +561,16 @@ class DeploymentController:
|
|||||||
ledger: ApprovalLedger,
|
ledger: ApprovalLedger,
|
||||||
policy: SilentReleasePolicy,
|
policy: SilentReleasePolicy,
|
||||||
store: ReleaseCandidateStore | None = None,
|
store: ReleaseCandidateStore | None = None,
|
||||||
|
*,
|
||||||
|
allow_legacy_unsigned_approval: bool = False,
|
||||||
):
|
):
|
||||||
self.deployment = deployment
|
self.deployment = deployment
|
||||||
self.ledger = ledger
|
self.ledger = ledger
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.allow_legacy_unsigned_approval = (
|
||||||
|
allow_legacy_unsigned_approval
|
||||||
|
)
|
||||||
|
|
||||||
def _save(self, candidate: ReleaseCandidate) -> None:
|
def _save(self, candidate: ReleaseCandidate) -> None:
|
||||||
if self.store:
|
if self.store:
|
||||||
@@ -525,6 +581,11 @@ class DeploymentController:
|
|||||||
candidate: ReleaseCandidate,
|
candidate: ReleaseCandidate,
|
||||||
approval: ApprovalEnvelope,
|
approval: ApprovalEnvelope,
|
||||||
) -> dict[str, Any]:
|
) -> 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:
|
if candidate.state != ReleaseState.READY_FOR_APPROVAL:
|
||||||
raise ApprovalError("candidate is not ready for approval")
|
raise ApprovalError("candidate is not ready for approval")
|
||||||
if not self.policy.requires_owner("production_runtime_switch"):
|
if not self.policy.requires_owner("production_runtime_switch"):
|
||||||
@@ -675,7 +736,7 @@ class CommandReleaseBuilder:
|
|||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=self.config.timeout,
|
timeout=self.config.timeout,
|
||||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
env=dict(SAFE_PROCESS_ENVIRONMENT),
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
raise RuntimeError("release build unavailable") from exc
|
raise RuntimeError("release build unavailable") from exc
|
||||||
@@ -741,12 +802,25 @@ class TagController:
|
|||||||
adapter: TagAdapter,
|
adapter: TagAdapter,
|
||||||
policy: SilentReleasePolicy,
|
policy: SilentReleasePolicy,
|
||||||
ledger: ApprovalLedger | None,
|
ledger: ApprovalLedger | None,
|
||||||
|
*,
|
||||||
|
allow_legacy_unsigned_approval: bool = False,
|
||||||
):
|
):
|
||||||
self.adapter = adapter
|
self.adapter = adapter
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.ledger = ledger
|
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:
|
def approve(self, envelope: TagApprovalEnvelope) -> TagApprovalEnvelope:
|
||||||
|
self._require_legacy_test_mode()
|
||||||
if not self.policy.requires_owner("production_tag"):
|
if not self.policy.requires_owner("production_tag"):
|
||||||
raise ApprovalError("production tag is not owner governed")
|
raise ApprovalError("production tag is not owner governed")
|
||||||
if not self.policy.is_forbidden("tag_rewrite"):
|
if not self.policy.is_forbidden("tag_rewrite"):
|
||||||
@@ -757,6 +831,7 @@ class TagController:
|
|||||||
def execute(
|
def execute(
|
||||||
self, approved: TagApprovalEnvelope
|
self, approved: TagApprovalEnvelope
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
self._require_legacy_test_mode()
|
||||||
approved.validate()
|
approved.validate()
|
||||||
existing = self.adapter.lookup(approved.version)
|
existing = self.adapter.lookup(approved.version)
|
||||||
expected = (approved.tag_oid, approved.target_oid)
|
expected = (approved.tag_oid, approved.target_oid)
|
||||||
@@ -793,6 +868,7 @@ class TagController:
|
|||||||
def verify(
|
def verify(
|
||||||
self, approved: TagApprovalEnvelope
|
self, approved: TagApprovalEnvelope
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
self._require_legacy_test_mode()
|
||||||
actual = self.adapter.verify(approved.version)
|
actual = self.adapter.verify(approved.version)
|
||||||
expected = (approved.tag_oid, approved.target_oid)
|
expected = (approved.tag_oid, approved.target_oid)
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
@@ -837,7 +913,7 @@ class CommandReleaseProbe:
|
|||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
env=dict(SAFE_PROCESS_ENVIRONMENT),
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
raise RuntimeError("COMMAND_UNAVAILABLE") from exc
|
raise RuntimeError("COMMAND_UNAVAILABLE") from exc
|
||||||
@@ -1000,6 +1076,13 @@ class SystemdDeploymentConfig:
|
|||||||
health_url: str
|
health_url: str
|
||||||
command_prefix: tuple[str, ...] = ()
|
command_prefix: tuple[str, ...] = ()
|
||||||
dropin_name: str = "aios-gitops-model-api-production.conf"
|
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:
|
class SystemdDeploymentAdapter:
|
||||||
@@ -1007,39 +1090,134 @@ class SystemdDeploymentAdapter:
|
|||||||
|
|
||||||
def __init__(self, config: SystemdDeploymentConfig):
|
def __init__(self, config: SystemdDeploymentConfig):
|
||||||
self.config = config
|
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(
|
def atomic_switch(
|
||||||
self, candidate: ReleaseCandidate
|
self, candidate: ReleaseCandidate
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
target = (
|
self._acquire_lock()
|
||||||
self.config.release_root
|
try:
|
||||||
/ candidate.source_commit
|
release_root = self.config.release_root.resolve(strict=True)
|
||||||
/ "deployment"
|
raw_target = (
|
||||||
/ self.config.dropin_name
|
release_root
|
||||||
).resolve(strict=True)
|
/ candidate.source_commit
|
||||||
link = self.config.cutover_link
|
/ "deployment"
|
||||||
if link.exists() and not link.is_symlink():
|
/ self.config.dropin_name
|
||||||
raise RuntimeError("cutover path is not a managed symlink")
|
)
|
||||||
previous = os.readlink(link) if link.is_symlink() else None
|
if raw_target.is_symlink():
|
||||||
link.parent.mkdir(parents=True, exist_ok=True)
|
raise RuntimeError("release drop-in must not be a symlink")
|
||||||
temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp")
|
target = raw_target.resolve(strict=True)
|
||||||
if temporary.exists() or temporary.is_symlink():
|
if target.parent.parent.parent != release_root:
|
||||||
temporary.unlink()
|
raise RuntimeError("release drop-in escapes release root")
|
||||||
os.symlink(str(target), temporary)
|
link = self.config.cutover_link
|
||||||
os.replace(temporary, link)
|
if link.exists() and not link.is_symlink():
|
||||||
return {
|
raise RuntimeError("cutover path is not a managed symlink")
|
||||||
"previous_target": previous,
|
previous = os.readlink(link) if link.is_symlink() else None
|
||||||
"new_target": str(target),
|
if previous:
|
||||||
"cutover_link": str(link),
|
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:
|
def restart(self) -> None:
|
||||||
|
if self._lock_handle is None:
|
||||||
|
raise RuntimeError("deployment lock is not held")
|
||||||
|
before = self._service_identity()
|
||||||
command = [
|
command = [
|
||||||
*self.config.command_prefix,
|
*self.config.command_prefix,
|
||||||
"systemctl",
|
"systemctl",
|
||||||
"daemon-reload",
|
"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(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
*self.config.command_prefix,
|
*self.config.command_prefix,
|
||||||
@@ -1050,7 +1228,18 @@ class SystemdDeploymentAdapter:
|
|||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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(
|
def health_check(
|
||||||
self, candidate: ReleaseCandidate
|
self, candidate: ReleaseCandidate
|
||||||
@@ -1062,44 +1251,58 @@ class SystemdDeploymentAdapter:
|
|||||||
payload = json.loads(response.read().decode("utf-8"))
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError("runtime health check unavailable") from exc
|
raise RuntimeError("runtime health check unavailable") from exc
|
||||||
return {
|
try:
|
||||||
"passed": response.status == 200,
|
return {
|
||||||
"runtime_commit": payload.get("runtime_release_commit"),
|
"passed": response.status == 200,
|
||||||
"writes_enabled": payload.get("writes_enabled"),
|
"runtime_commit": payload.get("runtime_release_commit"),
|
||||||
"authoritative_head_oid": payload.get(
|
"writes_enabled": payload.get("writes_enabled"),
|
||||||
"authoritative_head_oid"
|
"authoritative_head_oid": payload.get(
|
||||||
),
|
"authoritative_head_oid"
|
||||||
"repository_identity": payload.get("repository_identity"),
|
),
|
||||||
}
|
"repository_identity": payload.get("repository_identity"),
|
||||||
|
"systemd_restart": _sanitize(self._restart_evidence),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
self._release_lock()
|
||||||
|
|
||||||
def rollback(
|
def rollback(
|
||||||
self, switch_evidence: dict[str, Any]
|
self, switch_evidence: dict[str, Any]
|
||||||
) -> 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:
|
try:
|
||||||
with urllib.request.urlopen(
|
if self._lock_handle is None:
|
||||||
self.config.health_url, timeout=15
|
self._acquire_lock()
|
||||||
) as response:
|
link = self.config.cutover_link
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
previous = switch_evidence.get("previous_target")
|
||||||
except Exception as exc:
|
temporary = link.with_name(
|
||||||
raise RuntimeError(
|
f".{link.name}.{os.getpid()}.rollback"
|
||||||
"rollback health check unavailable"
|
)
|
||||||
) from exc
|
if previous:
|
||||||
return {
|
if temporary.exists() or temporary.is_symlink():
|
||||||
"passed": response.status == 200,
|
temporary.unlink()
|
||||||
"writes_enabled": payload.get("writes_enabled"),
|
os.symlink(str(previous), temporary)
|
||||||
"runtime_commit": payload.get("runtime_release_commit"),
|
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:
|
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": [
|
"automatic": [
|
||||||
"build",
|
"build",
|
||||||
"test",
|
"test",
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
"evidence"
|
"evidence"
|
||||||
],
|
],
|
||||||
"owner": [
|
"owner": [
|
||||||
"production_runtime_switch",
|
"signed_release_plan"
|
||||||
"production_tag"
|
|
||||||
],
|
],
|
||||||
"forbidden": [
|
"forbidden": [
|
||||||
"force_push",
|
"force_push",
|
||||||
@@ -19,7 +18,8 @@
|
|||||||
"registry_mutation"
|
"registry_mutation"
|
||||||
],
|
],
|
||||||
"runtime_deployment": {
|
"runtime_deployment": {
|
||||||
"approval_action": "production_runtime_switch",
|
"approval_action": "signed_release_plan",
|
||||||
|
"required_capability": "runtime_switch",
|
||||||
"tag_operations_allowed": false,
|
"tag_operations_allowed": false,
|
||||||
"baseline_mutation_allowed": false,
|
"baseline_mutation_allowed": false,
|
||||||
"registry_mutation_allowed": false,
|
"registry_mutation_allowed": false,
|
||||||
@@ -27,10 +27,37 @@
|
|||||||
"rollback_required": true
|
"rollback_required": true
|
||||||
},
|
},
|
||||||
"tag_release": {
|
"tag_release": {
|
||||||
"approval_action": "production_tag",
|
"approval_action": "signed_release_plan",
|
||||||
|
"required_capability": "tag_publish",
|
||||||
"separate_controller": true,
|
"separate_controller": true,
|
||||||
"duplicate_conflict": "fail_closed",
|
"duplicate_conflict": "fail_closed",
|
||||||
"rewrite_allowed": false,
|
"rewrite_allowed": false,
|
||||||
"force_push_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
|
# AIOS Silent Release Controller Design Report
|
||||||
|
|
||||||
状态:isolated branch implementation
|
状态:隔离分支实现;生产 mutation 禁止
|
||||||
Production mutation:forbidden
|
目标:自动准备 → 自动验证 → 单次 Owner 签名授权 → 分控制器自动执行与验收
|
||||||
目标流程:自动准备 → 自动验证 → 单次 Owner runtime 授权 → 自动部署/失败回滚
|
|
||||||
|
|
||||||
## 1. 当前痛点
|
## 1. 当前痛点
|
||||||
|
|
||||||
当前发布依赖多轮人工传递 commit、tree、archive、manifest、drop-in、测试和
|
现有流程需要多轮人工传递 commit、tree、artifact、manifest、测试、canary、drop-in
|
||||||
canary 证据。证据在不同消息和报告中重复抄写,容易出现:
|
和回滚证据。证据在不同消息里重复抄写,容易出现:
|
||||||
|
|
||||||
- artifact 在测试后被 cache 文件污染,closure 数量漂移;
|
- 构建后 artifact、manifest 或 policy 已变化,但旧批准仍被使用;
|
||||||
- canary 通过,但批准文本与 runtime 实际字段契约不同;
|
- Git、Gitea、systemd 与回滚结果由人工拼接,缺少同一一致性快照;
|
||||||
- Git、Gitea、artifact、systemd 和 rollback 门禁由人手工拼接;
|
- runtime switch、tag、Baseline、Registry 的授权边界不清;
|
||||||
- runtime deployment 与 production tag 容易被误认为同一授权;
|
- 重启后只剩工作树内临时 JSON,无法可靠恢复;
|
||||||
- 授权重放、失败回滚和持久化状态缺少统一接口。
|
- approval replay、并发 writer、key revoke 和 secret inheritance 缺少统一防线。
|
||||||
|
|
||||||
## 2. 新架构
|
## 2. 新架构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ReleaseBuilder adapter
|
Build / Test / Canary
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
SilentReleaseController.prepare(source_commit)
|
ReleaseCandidate + DeploymentPlan
|
||||||
|
|
|
||||||
+--> ReleaseCandidateStore
|
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
ReleaseAcceptanceEngine.evaluate(candidate)
|
ReleaseAcceptanceEngine
|
||||||
|
|
|
||||||
+--> ReleaseProbe adapter
|
|
||||||
- local/bare/Gitea main
|
|
||||||
- repository identity
|
|
||||||
- artifact/manifest
|
|
||||||
- tests/canary
|
|
||||||
- rollback
|
|
||||||
- authoritative Git binding
|
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
READY_FOR_APPROVAL
|
READY_FOR_APPROVAL
|
||||||
|
|
|
|
||||||
v one ApprovalEnvelope: production_runtime_switch
|
v
|
||||||
DeploymentController.deploy(candidate, approval)
|
SignedReleasePlanApproval (Ed25519)
|
||||||
|
|
|
|
||||||
+--> atomic switch
|
+--> runtime_switch capability
|
||||||
+--> restart only GitOps runtime
|
| DeploymentController
|
||||||
+--> health verification
|
|
|
||||||
+--> rollback on any failure
|
+--> tag_publish capability
|
||||||
|
| TagController
|
||||||
|
|
|
||||||
|
+--> baseline_registry_finalize capability
|
||||||
|
BaselineRegistryFinalizeController
|
||||||
|
|
||||||
TagController (separate seam)
|
Durable ReleaseSagaStore + CapabilityLedger + Event Journal
|
||||||
|
|
贯穿三个控制器;控制器互相不拥有对方的 mutation API。
|
||||||
+--> separate production_tag approval
|
|
||||||
+--> execute
|
|
||||||
+--> verify
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`SilentReleaseController` 是外部深模块,只暴露 `prepare()` 和 `deploy()`。
|
一次 Owner 批准是同一个不可变 release plan 的签名,不是一个万能执行入口。三个 capability
|
||||||
构建、探针、systemd 与 tag provider 都是 adapter;复杂实现保持在模块内部。
|
分别消费、分别留痕、分别幂等恢复。`force_push`、`tag_rewrite`、`direct_main_push`
|
||||||
`CommandReleaseBuilder` 使用无 shell 的 argv 调用确定性构建流水线,并只接受
|
永远不是可签发 capability。
|
||||||
与请求 source commit 一致的候选 JSON。
|
|
||||||
|
|
||||||
## 3. 状态机
|
## 3. 状态机
|
||||||
|
|
||||||
```text
|
```text
|
||||||
BUILDING
|
PREPARING
|
||||||
|
|
-> VERIFYING
|
||||||
v
|
-> READY_FOR_APPROVAL
|
||||||
VERIFYING ------------------------> FAILED
|
-> APPROVED
|
||||||
|
|
-> PRE_SWITCH_REVALIDATING
|
||||||
v
|
-> SWITCH_PENDING
|
||||||
READY_FOR_APPROVAL --------------> FAILED
|
-> SWITCHED
|
||||||
|
|
-> RUNTIME_VERIFYING
|
||||||
v
|
-> TAG_PENDING
|
||||||
APPROVED ------------------------> FAILED
|
-> TAG_VERIFIED
|
||||||
|
|
-> BASELINE_REGISTRY_PENDING
|
||||||
v
|
-> COMPLETED
|
||||||
DEPLOYING -------> VERIFIED
|
|
||||||
| |
|
任一可恢复故障 -> RECONCILIATION_REQUIRED
|
||||||
+-------------> ROLLED_BACK
|
对象冲突 -> MANUAL_OWNER_REVIEW_REQUIRED
|
||||||
|
|
切换后失败 -> ROLLBACK_PENDING -> ROLLED_BACK
|
||||||
+-------------> FAILED (rollback itself failed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
状态不可跳跃。`VERIFIED` 只允许显式进入 `ROLLED_BACK`,终态不能自动回到准备态。
|
状态快照使用 revision compare-and-set;事件同时写入 append-only journal。writer 崩溃后根据
|
||||||
|
状态确定唯一恢复动作。并发控制器中只有一个 CAS winner。
|
||||||
|
|
||||||
## 4. 数据模型
|
## 4. 数据模型
|
||||||
|
|
||||||
`ReleaseCandidate`:
|
### ReleaseCandidate
|
||||||
|
|
||||||
```text
|
```text
|
||||||
candidate_id
|
candidate_id
|
||||||
@@ -99,201 +88,181 @@ rollback_ready
|
|||||||
git_consistency
|
git_consistency
|
||||||
gitea_consistency
|
gitea_consistency
|
||||||
approval_state
|
approval_state
|
||||||
state
|
|
||||||
failure_reason
|
|
||||||
created_at
|
|
||||||
updated_at
|
|
||||||
```
|
```
|
||||||
|
|
||||||
核心 OID 和 SHA 在构造时验证。候选 fingerprint 绑定 candidate、commit、tree、
|
### DeploymentPlan
|
||||||
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:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/silent-release/candidates
|
service
|
||||||
GET /api/silent-release/candidates/{candidate_id}
|
release_path / release_root
|
||||||
POST /api/silent-release/candidates/{candidate_id}/verify
|
dropin_path / dropin_sha256
|
||||||
POST /api/silent-release/candidates/{candidate_id}/approve-runtime
|
rollback_path / rollback_sha256
|
||||||
POST /api/silent-release/candidates/{candidate_id}/deploy
|
source_commit
|
||||||
GET /api/silent-release/candidates/{candidate_id}/acceptance
|
artifact_sha256
|
||||||
|
manifest_sha256
|
||||||
|
policy_sha256
|
||||||
|
health_contract
|
||||||
|
sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Tag interface 必须位于独立 namespace:
|
### SignedReleasePlanApproval
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/tag-releases/{version}/approve
|
release_plan_id
|
||||||
POST /api/tag-releases/{version}/execute
|
candidate_id
|
||||||
GET /api/tag-releases/{version}/verify
|
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 不得被
|
签名内容使用 canonical JSON,`signature` 不参与被签名字段。验证包括 Ed25519 签名、过期时间、
|
||||||
DeploymentController 接受。
|
公钥状态、approval revoke、candidate/plan/policy 逐字段绑定和 capability allowlist。
|
||||||
|
|
||||||
## 6. ReleaseAcceptanceEngine
|
## 5. API
|
||||||
|
|
||||||
`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 只授权:
|
|
||||||
|
|
||||||
```text
|
```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;
|
## 6. PreSwitchValidator
|
||||||
- source commit/tree;
|
|
||||||
- artifact/manifest SHA;
|
|
||||||
- owner、approval ID、签发和过期时间。
|
|
||||||
|
|
||||||
`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
|
```text
|
||||||
action=production_tag
|
APPROVAL_STALE_REAPPROVAL_REQUIRED
|
||||||
version
|
|
||||||
tag_oid
|
|
||||||
target_oid
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. DeploymentController
|
旧批准不会被“最佳努力”继续使用。
|
||||||
|
|
||||||
只接受 `READY_FOR_APPROVAL` 候选和完整 runtime envelope:
|
## 7. systemd 防线
|
||||||
|
|
||||||
1. 验证 Policy as Code;
|
- service 与 drop-in 均为显式 allowlist;
|
||||||
2. 原子消费 approval;
|
- release 与 drop-in resolved path 必须位于 release root 内;
|
||||||
3. 原子切换 versioned drop-in symlink;
|
- 拒绝普通文件被替换为 symlink,拒绝 symlink escape;
|
||||||
4. 仅 restart 指定 GitOps runtime;
|
- deployment lock 覆盖 switch、restart 和首次 health check;
|
||||||
5. 验证 runtime commit 和 writer=false;
|
- restart 前后记录 MainPID 与 ActiveEnterTimestampMonotonic;
|
||||||
6. 任一异常切回旧 symlink并再次 restart;
|
- artifact、drop-in、rollback 和 manifest 均按 plan hash 复验;
|
||||||
7. rollback 后重新执行健康检查并确认 writer=false;
|
- 子进程只接收固定 allowlist 环境,不继承 token、password、credential、askpass、
|
||||||
8. rollback 或 rollback health 失败时进入 `FAILED`,禁止伪装成功。
|
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)`
|
同名 tag 的 tag OID 或 peeled target 不同,进入
|
||||||
2. `execute(approved)`
|
`MANUAL_OWNER_REVIEW_REQUIRED`;自动流程不 push、不删除、不重写、不 force push。
|
||||||
3. `verify(approved)`
|
|
||||||
|
|
||||||
同名 tag 已存在且 tag OID 或 target OID 不一致时抛出
|
## 9. Baseline / Registry 门禁
|
||||||
`DuplicateTagConflict`。接口没有 delete、rewrite 或 force push。
|
|
||||||
|
只有以下全部一致时,`baseline_registry_finalize` capability 才能完成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Registry = Baseline = remote tag = Gitea API = expected target
|
||||||
|
```
|
||||||
|
|
||||||
|
UNKNOWN、API 不可达、repository identity 不符或 listing 不可见均 fail closed。
|
||||||
|
|
||||||
## 10. Policy as Code
|
## 10. Policy as Code
|
||||||
|
|
||||||
`config/gitops/silent-release-policy.json`:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
automatic: build, test, artifact, canary, evidence
|
automatic:
|
||||||
owner: production_runtime_switch, production_tag
|
build, test, artifact, canary, evidence
|
||||||
forbidden: force_push, tag_rewrite, direct_main_push,
|
|
||||||
baseline_mutation, registry_mutation
|
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
|
## 11. Security model
|
||||||
|
|
||||||
- fail-closed Git/Gitea/manifest/health adapter;
|
- Ed25519 公钥 ring 支持 rotation 和 revoke,私钥不进入 runtime;
|
||||||
- credential 只从受控文件读取,不进入模型或持久化候选;
|
- approval plan 可撤销,过期、未知 key、错误签名均 fail closed;
|
||||||
- candidate store 对 Authorization、token、credential、password、secret 字段脱敏;
|
- nonce 与 capability operation 持久化,重复相同请求幂等,不同 fingerprint 重放拒绝;
|
||||||
- 错误文本统一清除 Authorization、token、URL userinfo 和 password;
|
- Saga snapshot 原子写、journal append-only、CAS 防并发 writer;
|
||||||
- approval ID 一次性消费并有过期时间;
|
- credential 只通过受控 reference 使用,不进入 Git、API 响应或异常;
|
||||||
- candidate fingerprint 防止对其他 artifact 重放批准;
|
- 子进程环境从空集合构建,不复制 `os.environ`;
|
||||||
- runtime 默认 writer=false;
|
- Shadow 模式默认开启,execution adapter 缺失时不能降级执行;
|
||||||
- systemd adapter 只允许管理 symlink,拒绝覆盖普通配置文件;
|
- runtime、tag、Baseline/Registry 分属三个能力最小化控制器。
|
||||||
- no tag rewrite、force push、direct main push;
|
|
||||||
- runtime deployment 无 Baseline/Registry/tag capability。
|
|
||||||
|
|
||||||
## 12. Migration plan
|
## 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
|
## 13. Rollback plan
|
||||||
|
|
||||||
- 切换前记录旧 symlink target;
|
- switch 前保存旧 symlink target、PID、启动时间、artifact 与 plan fingerprint;
|
||||||
- 新 runtime 任一健康、Git、hash、writer 或一致性门禁失败即恢复旧 target;
|
- restart 或 health 任一失败进入 `ROLLBACK_PENDING`;
|
||||||
- 只 restart GitOps runtime;
|
- 原子恢复旧 target,只 restart 指定 allowlist service;
|
||||||
- rollback 后再次验证旧 runtime、writer=false 和只读 Git-backed API;
|
- 重新验证旧 runtime commit、writer=false 与 repository identity;
|
||||||
- 保留失败候选、脱敏证据和 approval ledger;
|
- 成功进入 `ROLLED_BACK`,失败进入 `MANUAL_OWNER_REVIEW_REQUIRED`;
|
||||||
- 不使用 Git reset、force push、tag rewrite;
|
- rollback 不修改 Git main、production tag、Baseline 或 Registry;
|
||||||
- rollback 不修改 Baseline、Registry 或 production tag。
|
- 保留脱敏 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;
|
本隔离实现不创建生产 KMS/key、不安装 systemd unit、不部署 Action runtime、不创建 tag,
|
||||||
- Gitea unavailable;
|
也不修改 Baseline/Registry。上述动作必须在单独生产批准中执行。
|
||||||
- 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。
|
|
||||||
|
|||||||
@@ -135,7 +135,142 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
text: {type: string}
|
text: {type: string}
|
||||||
branch: {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:
|
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:
|
/api/base:
|
||||||
get:
|
get:
|
||||||
operationId: getBaseline
|
operationId: getBaseline
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
|
|||||||
ApprovalLedger(Path(td) / "approvals"),
|
ApprovalLedger(Path(td) / "approvals"),
|
||||||
SilentReleasePolicy.default(),
|
SilentReleasePolicy.default(),
|
||||||
store,
|
store,
|
||||||
|
allow_legacy_unsigned_approval=True,
|
||||||
),
|
),
|
||||||
store=store,
|
store=store,
|
||||||
policy=SilentReleasePolicy.default(),
|
policy=SilentReleasePolicy.default(),
|
||||||
@@ -248,6 +249,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
|
|||||||
deployment,
|
deployment,
|
||||||
ApprovalLedger(Path(td) / "approvals"),
|
ApprovalLedger(Path(td) / "approvals"),
|
||||||
SilentReleasePolicy.default(),
|
SilentReleasePolicy.default(),
|
||||||
|
allow_legacy_unsigned_approval=True,
|
||||||
)
|
)
|
||||||
result = controller.deploy(value, envelope)
|
result = controller.deploy(value, envelope)
|
||||||
self.assertEqual(value.state, ReleaseState.VERIFIED)
|
self.assertEqual(value.state, ReleaseState.VERIFIED)
|
||||||
@@ -273,6 +275,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
|
|||||||
deployment,
|
deployment,
|
||||||
ApprovalLedger(Path(td) / "approvals"),
|
ApprovalLedger(Path(td) / "approvals"),
|
||||||
SilentReleasePolicy.default(),
|
SilentReleasePolicy.default(),
|
||||||
|
allow_legacy_unsigned_approval=True,
|
||||||
).deploy(value, envelope)
|
).deploy(value, envelope)
|
||||||
self.assertFalse(result["verified"])
|
self.assertFalse(result["verified"])
|
||||||
self.assertEqual(value.state, ReleaseState.ROLLED_BACK)
|
self.assertEqual(value.state, ReleaseState.ROLLED_BACK)
|
||||||
@@ -292,7 +295,10 @@ class SilentReleaseControllerTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
ledger = ApprovalLedger(Path(td) / "approvals")
|
ledger = ApprovalLedger(Path(td) / "approvals")
|
||||||
controller = DeploymentController(
|
controller = DeploymentController(
|
||||||
FakeDeployment(), ledger, SilentReleasePolicy.default()
|
FakeDeployment(),
|
||||||
|
ledger,
|
||||||
|
SilentReleasePolicy.default(),
|
||||||
|
allow_legacy_unsigned_approval=True,
|
||||||
)
|
)
|
||||||
controller.deploy(value, envelope)
|
controller.deploy(value, envelope)
|
||||||
second = candidate()
|
second = candidate()
|
||||||
@@ -309,6 +315,7 @@ class SilentReleaseControllerTests(unittest.TestCase):
|
|||||||
adapter,
|
adapter,
|
||||||
SilentReleasePolicy.default(),
|
SilentReleasePolicy.default(),
|
||||||
ledger=None,
|
ledger=None,
|
||||||
|
allow_legacy_unsigned_approval=True,
|
||||||
)
|
)
|
||||||
envelope = TagApprovalEnvelope(
|
envelope = TagApprovalEnvelope(
|
||||||
approval_id="tag-approval-001",
|
approval_id="tag-approval-001",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user