From 74707c4d5c0f742a94c0f1b87c74d74b8bbf98cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Jul 2026 12:55:33 +0800 Subject: [PATCH] fix(observability): provision SigNoz metadata credential safely --- agent99-bootstrap.ps1 | 2 + agent99-contract-check.ps1 | 4 +- agent99-deploy.ps1 | 2 + agent99-signoz-credential-provisioner.ps1 | 135 ++++++ agent99-signoz-credential-target.py | 443 ++++++++++++++++++ agent99-signoz-metadata-executor.ps1 | 2 +- ...t_agent99_signoz_credential_provisioner.py | 274 +++++++++++ ...oz_metadata_executor_runtime_entrypoint.py | 6 +- ..._db_bounded_executor_runtime_entrypoint.py | 8 +- .../agent99-live-preflight.ps1 | 2 +- .../agent99-remote-atomic-deploy-receiver.ps1 | 14 +- .../deploy-agent99-via-windows99-ssh.sh | 10 +- .../test_agent99_live_preflight_decision.py | 6 +- ...remote_atomic_deploy_transport_contract.py | 6 +- 14 files changed, 889 insertions(+), 25 deletions(-) create mode 100644 agent99-signoz-credential-provisioner.ps1 create mode 100644 agent99-signoz-credential-target.py create mode 100644 scripts/ops/tests/test_agent99_signoz_credential_provisioner.py diff --git a/agent99-bootstrap.ps1 b/agent99-bootstrap.ps1 index 44d7482d8..58dd712d6 100644 --- a/agent99-bootstrap.ps1 +++ b/agent99-bootstrap.ps1 @@ -224,6 +224,8 @@ $agentFiles = @( "agent99-control-plane.ps1", "agent99-db-bounded-executor.ps1", "agent99-signoz-metadata-executor.ps1", + "agent99-signoz-credential-provisioner.ps1", + "agent99-signoz-credential-target.py", "agent99-deploy.ps1", "agent99-register-tasks.ps1", "agent99-alertmanager-alertchain-poll.ps1", diff --git a/agent99-contract-check.ps1 b/agent99-contract-check.ps1 index 26515a026..8bc9a8ced 100644 --- a/agent99-contract-check.ps1 +++ b/agent99-contract-check.ps1 @@ -20,6 +20,8 @@ $requiredFiles = @( "agent99-control-plane.ps1", "agent99-db-bounded-executor.ps1", "agent99-signoz-metadata-executor.ps1", + "agent99-signoz-credential-provisioner.ps1", + "agent99-signoz-credential-target.py", "agent99-deploy.ps1", "agent99-register-tasks.ps1", "agent99-alertmanager-alertchain-poll.ps1", @@ -190,7 +192,7 @@ $signozMetadataExecutorContract = [bool]( $signozMetadataExecutor.Contains('agent99_active:') -and $signozMetadataExecutor.Contains('$RemoteResult.completed -ne $true') -and $signozMetadataExecutor.Contains('Get-Agent99RuntimeSourceBinding') -and - $signozMetadataExecutor.Contains('$result.fileCount -eq 18') -and + $signozMetadataExecutor.Contains('$result.fileCount -eq 20') -and $signozMetadataExecutor.Contains('pass_export_verified_restore_pending') -and $signozMetadataExecutor.Contains('productionRuntimeMutationPerformed = $false') -and $signozMetadataExecutor.Contains('rawSqliteRead = $false') -and diff --git a/agent99-deploy.ps1 b/agent99-deploy.ps1 index 8096d7cf9..f33412cc1 100644 --- a/agent99-deploy.ps1 +++ b/agent99-deploy.ps1 @@ -35,6 +35,8 @@ $runtimeFiles = @( "agent99-control-plane.ps1", "agent99-db-bounded-executor.ps1", "agent99-signoz-metadata-executor.ps1", + "agent99-signoz-credential-provisioner.ps1", + "agent99-signoz-credential-target.py", "agent99-deploy.ps1", "agent99-register-tasks.ps1", "agent99-alertmanager-alertchain-poll.ps1", diff --git a/agent99-signoz-credential-provisioner.ps1 b/agent99-signoz-credential-provisioner.ps1 new file mode 100644 index 000000000..a8f4a73c2 --- /dev/null +++ b/agent99-signoz-credential-provisioner.ps1 @@ -0,0 +1,135 @@ +param( + [ValidateSet("Check", "Apply", "Verify")] + [string]$Mode = "Check", + [Parameter(Mandatory = $true)][string]$TraceId, + [Parameter(Mandatory = $true)][string]$RunId, + [Parameter(Mandatory = $true)][string]$WorkItemId, + [Parameter(Mandatory = $true)][string]$SourceRevision, + [string]$AgentRoot = "C:\Wooo\Agent99" +) + +$ErrorActionPreference = "Stop" +$TargetHost = "192.168.0.110" +$RemoteUser = "wooo" +$IdentityFile = Join-Path $AgentRoot "keys\agent99_ed25519" +$EvidenceDir = Join-Path $AgentRoot "evidence" +$RuntimeManifestPath = Join-Path $AgentRoot "state\runtime-manifest.json" +$EntrypointPath = [string]$MyInvocation.MyCommand.Path +$TargetProgram = Join-Path (Split-Path -Parent ([string]$MyInvocation.MyCommand.Path)) "agent99-signoz-credential-target.py" +$SafeIdPattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" +$ExpectedWorkItemId = "P0-OBS-002" +$ExpectedRuntimeFileCount = 20 +$EvidenceRunId = if ($RunId -match $SafeIdPattern) { $RunId } else { "invalid-$PID" } +$EvidencePath = Join-Path $EvidenceDir "agent99-signoz-credential-$($Mode.ToLowerInvariant())-$EvidenceRunId.json" + +function Test-RuntimeBinding { + if (-not (Test-Path -LiteralPath $RuntimeManifestPath -PathType Leaf)) { return $false } + if (-not (Test-Path -LiteralPath $TargetProgram -PathType Leaf)) { return $false } + try { + $manifest = Get-Content -LiteralPath $RuntimeManifestPath -Raw | ConvertFrom-Json + if ( + -not [bool]$manifest.runtimeMatched -or + [int]$manifest.fileCount -ne $ExpectedRuntimeFileCount -or + [int]$manifest.mismatchCount -ne 0 -or + [string]$manifest.sourceRevision -ne $SourceRevision + ) { return $false } + foreach ($name in @("agent99-signoz-credential-provisioner.ps1", "agent99-signoz-credential-target.py")) { + $rows = @($manifest.files | Where-Object { [string]$_.name -eq $name }) + if ($rows.Count -ne 1) { return $false } + $path = if ($name -eq "agent99-signoz-credential-provisioner.ps1") { + $EntrypointPath + } else { $TargetProgram } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ([string]$rows[0].runtimeSha256 -ne $hash) { return $false } + } + return $true + } catch { return $false } +} + +function Invoke-FixedTargetProgram { + $sshArguments = @( + "-i", $IdentityFile, + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "NumberOfPasswordPrompts=0", + "-o", "ConnectTimeout=8", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=3", + "-l", $RemoteUser, + $TargetHost, + "sudo -n /usr/bin/python3 - --mode $($Mode.ToLowerInvariant())" + ) + $token = [Guid]::NewGuid().ToString("N") + $stdoutPath = Join-Path $env:TEMP "agent99-signoz-credential-$token.out" + $stderrPath = Join-Path $env:TEMP "agent99-signoz-credential-$token.err" + try { + $process = Start-Process -FilePath "ssh.exe" -ArgumentList $sshArguments ` + -RedirectStandardInput $TargetProgram -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath -NoNewWindow -PassThru + if (-not $process.WaitForExit(180000)) { + try { & taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null } catch {} + throw "target_timeout" + } + $process.WaitForExit() | Out-Null + $stdout = if (Test-Path -LiteralPath $stdoutPath) { + ([IO.File]::ReadAllText($stdoutPath, [Text.Encoding]::UTF8)).Trim() + } else { "" } + if ($stdout.Length -gt 8192) { throw "target_output_too_large" } + try { $result = $stdout | ConvertFrom-Json } catch { throw "target_receipt_invalid" } + if ($process.ExitCode -ne 0 -or [string]$result.terminal -eq "failed") { + $code = if ($result.PSObject.Properties["error_code"]) { [string]$result.error_code } else { "target_failed" } + throw $code + } + return $result + } finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $stdoutPath) -or (Test-Path -LiteralPath $stderrPath)) { + throw "transient_transport_cleanup_failed" + } + } +} + +New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null +$receipt = [ordered]@{ + schema = "awoooi_agent99_signoz_credential_receipt_v1" + trace_id = $TraceId + run_id = $RunId + work_item_id = $WorkItemId + source_revision = $SourceRevision + mode = $Mode.ToLowerInvariant() + target_host = $TargetHost + credential_path = "/etc/awoooi/secrets/signoz-metadata-api-key" + terminal = "failed_closed" + runtime_binding_verified = $false + secret_in_transport = $false + target = $null +} + +try { + if ($TraceId -notmatch $SafeIdPattern -or $RunId -notmatch $SafeIdPattern) { throw "invalid_identity" } + if ($WorkItemId -ne $ExpectedWorkItemId) { throw "invalid_work_item" } + if ($SourceRevision -notmatch "^[0-9a-f]{40}$") { throw "invalid_source_revision" } + if (-not (Test-RuntimeBinding)) { throw "runtime_source_binding_failed" } + $receipt.runtime_binding_verified = $true + $target = Invoke-FixedTargetProgram + $allowedTerminal = switch ($Mode) { + "Check" { "check_pass_no_write" } + "Apply" { "applied_verified" } + "Verify" { "verify_pass" } + } + if ([string]$target.terminal -ne $allowedTerminal) { throw "unexpected_target_terminal" } + $receipt.target = $target + $receipt.terminal = $allowedTerminal +} catch { + $receipt.error_code = [string]$_.Exception.Message +} + +if (Test-Path -LiteralPath $EvidencePath) { throw "evidence_destination_exists" } +$json = $receipt | ConvertTo-Json -Depth 8 +$bytes = [Text.Encoding]::UTF8.GetBytes($json) +$stream = [IO.File]::Open($EvidencePath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } +$json +if ([string]$receipt.terminal -notin @("check_pass_no_write", "applied_verified", "verify_pass")) { exit 1 } diff --git a/agent99-signoz-credential-target.py b/agent99-signoz-credential-target.py new file mode 100644 index 000000000..35e8fc573 --- /dev/null +++ b/agent99-signoz-credential-target.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Host110-only SigNoz v0.113 first-admin and metadata PAT provisioner. + +The program is streamed to ``python3 -`` by Agent99. Secrets are generated and +used only in this process; stdout contains a bounded metadata-only JSON object. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import secrets +import stat +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +BASE_URL = "http://127.0.0.1:8080" +CREDENTIAL_PATH = Path("/etc/awoooi/secrets/signoz-metadata-api-key") +RECOVERY_PASSWORD_PATH = Path( + "/etc/awoooi/secrets/signoz-bootstrap-admin-password.recovery" +) +ADMIN_EMAIL = "awoooi-signoz-admin@example.invalid" +ORG_NAME = "AWOOOI" +PAT_NAME = "awoooi-metadata-export" +MAX_RESPONSE_BYTES = 1024 * 1024 +EXPECTED_UID = 0 + + +class ProvisionError(RuntimeError): + pass + + +def request_json( + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + bearer: str | None = None, + api_key: str | None = None, +) -> dict[str, Any]: + headers = {"Accept": "application/json"} + body = None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if api_key: + headers["SIGNOZ-API-KEY"] = api_key + request = urllib.request.Request( + f"{BASE_URL}{path}", data=body, headers=headers, method=method + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: # nosec B310 + raw = response.read(MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as exc: + raise ProvisionError(f"http_{method.lower()}_{path.rsplit('/', 1)[-1]}_{exc.code}") from None + except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException): + raise ProvisionError(f"transport_{method.lower()}_{path.rsplit('/', 1)[-1]}_failed") from None + if len(raw) > MAX_RESPONSE_BYTES: + raise ProvisionError("response_too_large") + try: + value = json.loads(raw) + except (UnicodeError, json.JSONDecodeError): + raise ProvisionError("response_json_invalid") from None + if not isinstance(value, dict) or value.get("status") != "success": + raise ProvisionError("response_status_invalid") + return value + + +def request_version() -> dict[str, Any]: + request = urllib.request.Request(f"{BASE_URL}/api/v1/version", method="GET") + try: + with urllib.request.urlopen(request, timeout=15) as response: # nosec B310 + raw = response.read(MAX_RESPONSE_BYTES + 1) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + OSError, + http.client.HTTPException, + ): + raise ProvisionError("version_request_failed") from None + if len(raw) > MAX_RESPONSE_BYTES: + raise ProvisionError("version_response_too_large") + try: + value = json.loads(raw) + except (UnicodeError, json.JSONDecodeError): + raise ProvisionError("version_response_invalid") from None + if not isinstance(value, dict): + raise ProvisionError("version_shape_invalid") + return value + + +def request_no_content( + method: str, + path: str, + *, + bearer: str | None = None, + api_key: str | None = None, +) -> None: + headers = {"Accept": "application/json"} + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if api_key: + headers["SIGNOZ-API-KEY"] = api_key + request = urllib.request.Request( + f"{BASE_URL}{path}", headers=headers, method=method + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: # nosec B310 + status = response.status + raw = response.read(1) + except urllib.error.HTTPError as exc: + raise ProvisionError(f"http_{method.lower()}_{path.rsplit('/', 1)[-1]}_{exc.code}") from None + except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException): + raise ProvisionError(f"transport_{method.lower()}_{path.rsplit('/', 1)[-1]}_failed") from None + if status != 204 or raw: + raise ProvisionError("no_content_response_invalid") + + +def secret_state(path: Path, *, read_value: bool = False) -> tuple[str, str | None]: + try: + metadata = path.lstat() + except FileNotFoundError: + return "absent", None + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != EXPECTED_UID: + raise ProvisionError("credential_not_root_regular_file") + if stat.S_IMODE(metadata.st_mode) not in (0o400, 0o600): + raise ProvisionError("credential_mode_invalid") + if metadata.st_size <= 0 or metadata.st_size > 4096: + raise ProvisionError("credential_size_invalid") + if not read_value: + return "present", None + token = path.read_text(encoding="utf-8").strip() + if not token or len(token) > 4096 or any(ch.isspace() for ch in token): + raise ProvisionError("credential_value_invalid") + return "present", token + + +def credential_state(*, read_value: bool = False) -> tuple[str, str | None]: + return secret_state(CREDENTIAL_PATH, read_value=read_value) + + +def setup_completed() -> bool: + value = request_version() + data = value.get("data", value) + if not isinstance(data, dict): + raise ProvisionError("version_shape_invalid") + completed = data.get("setupCompleted") + if not isinstance(completed, bool): + raise ProvisionError("setup_state_missing") + return completed + + +def verify_key(token: str, *, expected_pat_id: str | None = None) -> None: + value = request_json("GET", "/api/v1/dashboards", api_key=token) + if not isinstance(value.get("data"), (list, dict)): + raise ProvisionError("credential_dashboard_readback_invalid") + roles = request_json("GET", "/api/v1/roles", api_key=token) + if not isinstance(roles.get("data"), list): + raise ProvisionError("credential_admin_role_readback_invalid") + if expected_pat_id: + pats = request_json("GET", "/api/v1/pats", api_key=token) + pat_rows = pats.get("data") + if not isinstance(pat_rows, list) or not any( + isinstance(row, dict) and row.get("id") == expected_pat_id + for row in pat_rows + ): + raise ProvisionError("credential_admin_pat_readback_invalid") + + +def write_secret_no_clobber(path: Path, value: str) -> None: + parent = path.parent + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent_metadata = parent.lstat() + if not stat.S_ISDIR(parent_metadata.st_mode) or stat.S_ISLNK(parent_metadata.st_mode): + raise ProvisionError("credential_parent_invalid") + temp = parent / f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp" + fd = -1 + try: + fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + os.write(fd, value.encode("utf-8")) + os.fsync(fd) + os.close(fd) + fd = -1 + os.chown(temp, 0, 0, follow_symlinks=False) + os.chmod(temp, 0o400, follow_symlinks=False) + os.link(temp, path, follow_symlinks=False) + os.unlink(temp) + except FileExistsError: + raise ProvisionError("credential_destination_exists") from None + finally: + if fd >= 0: + os.close(fd) + try: + temp.unlink() + except FileNotFoundError: + pass + + +def remove_secret_verified(path: Path) -> None: + try: + metadata = path.lstat() + except FileNotFoundError: + return + if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ProvisionError("secret_cleanup_target_invalid") + try: + path.unlink() + except OSError: + raise ProvisionError("secret_cleanup_delete_failed") from None + try: + path.lstat() + except FileNotFoundError: + return + raise ProvisionError("secret_cleanup_residue_present") + + +def organization_id_for_admin() -> str: + value = request_json( + "GET", "/api/v2/sessions/context?email=awoooi-signoz-admin%40example.invalid" + ) + data = value.get("data") + orgs = data.get("orgs") if isinstance(data, dict) else None + if not isinstance(orgs, list) or len(orgs) != 1 or not isinstance(orgs[0], dict): + raise ProvisionError("bootstrap_recovery_org_context_invalid") + org_id = orgs[0].get("id") or orgs[0].get("orgId") + if not isinstance(org_id, str) or not org_id: + raise ProvisionError("bootstrap_recovery_org_id_missing") + return org_id + + +def revoke_pat(access_token: str, pat_id: str, pat_token: str = "") -> bool: + if access_token: + try: + request_no_content( + "DELETE", f"/api/v1/pats/{pat_id}", bearer=access_token + ) + return True + except ProvisionError: + pass + if pat_token: + try: + request_no_content( + "DELETE", f"/api/v1/pats/{pat_id}", api_key=pat_token + ) + return True + except ProvisionError: + pass + return False + + +def close_session(access_token: str) -> bool: + try: + request_no_content("DELETE", "/api/v2/sessions", bearer=access_token) + return True + except ProvisionError: + return False + + +def rollback_created_pat( + access_token: str, + pat_id: str, + pat_token: str, + *, + file_created: bool, +) -> None: + revoked = revoke_pat(access_token, pat_id, pat_token) if pat_id else True + if not revoked: + raise ProvisionError("apply_failed_pat_revoke_unverified_credential_preserved") + if file_created: + remove_secret_verified(CREDENTIAL_PATH) + + +def apply() -> dict[str, Any]: + state, _ = credential_state() + if state != "absent": + raise ProvisionError("credential_already_present_no_clobber") + recovery_state, recovery_password = secret_state( + RECOVERY_PASSWORD_PATH, read_value=True + ) + completed = setup_completed() + if recovery_state == "absent" and completed: + raise ProvisionError("setup_already_completed_without_bootstrap_recovery") + if recovery_state == "absent": + recovery_password = secrets.token_urlsafe(48) + write_secret_no_clobber(RECOVERY_PASSWORD_PATH, recovery_password) + recovery_state = "present" + if recovery_password is None: + raise ProvisionError("bootstrap_recovery_password_missing") + + org_id = "" + if not completed: + try: + registration = request_json( + "POST", + "/api/v1/register", + { + "email": ADMIN_EMAIL, + "orgDisplayName": ORG_NAME, + "password": recovery_password, + }, + ) + registration_data = registration.get("data") + org_id = ( + registration_data.get("orgId") + if isinstance(registration_data, dict) + else None + ) + if not isinstance(org_id, str) or not org_id: + raise ProvisionError("registration_org_id_missing") + except ProvisionError: + if not setup_completed(): + raise + org_id = organization_id_for_admin() + else: + org_id = organization_id_for_admin() + + session = request_json( + "POST", + "/api/v2/sessions/email_password", + {"email": ADMIN_EMAIL, "password": recovery_password, "orgId": org_id}, + ) + recovery_password = "" + session_data = session.get("data") + access_token = session_data.get("accessToken") if isinstance(session_data, dict) else None + if not isinstance(access_token, str) or not access_token: + raise ProvisionError("session_access_token_missing") + + pat_id = "" + token = "" + file_created = False + try: + me = request_json("GET", "/api/v1/user/me", bearer=access_token) + me_data = me.get("data") + if not isinstance(me_data, dict) or me_data.get("role") != "ADMIN" or me_data.get("orgId") != org_id: + raise ProvisionError("bootstrap_admin_identity_invalid") + pat = request_json( + "POST", + "/api/v1/pats", + {"name": PAT_NAME, "expiresInDays": 30, "role": "ADMIN"}, + bearer=access_token, + ) + pat_data = pat.get("data") + pat_id = pat_data.get("id") if isinstance(pat_data, dict) else None + token = pat_data.get("token") if isinstance(pat_data, dict) else None + if not isinstance(pat_id, str) or not pat_id or not isinstance(token, str) or not token: + raise ProvisionError("pat_response_invalid") + verify_key(token, expected_pat_id=pat_id) + write_secret_no_clobber(CREDENTIAL_PATH, token) + file_created = True + _, persisted = credential_state(read_value=True) + if persisted is None: + raise ProvisionError("credential_persist_readback_missing") + verify_key(persisted, expected_pat_id=pat_id) + if not close_session(access_token): + raise ProvisionError("session_cleanup_unverified") + access_token = "" + remove_secret_verified(RECOVERY_PASSWORD_PATH) + token = "" + except Exception as original: + try: + rollback_created_pat( + access_token, + pat_id, + token, + file_created=file_created, + ) + except ProvisionError as rollback_error: + raise rollback_error from None + finally: + close_session(access_token) + access_token = "" + raise original + return { + "terminal": "applied_verified", + "setup_completed": True, + "admin_identity_verified": True, + "credential_file_created": True, + "credential_api_verified": True, + "session_cleanup_verified": True, + "bootstrap_recovery_removed": True, + "bootstrap_is_permanent": True, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("check", "apply", "verify"), required=True) + args = parser.parse_args() + try: + if args.mode == "check": + state, _ = credential_state() + recovery_state, _ = secret_state(RECOVERY_PASSWORD_PATH) + result = { + "terminal": "check_pass_no_write", + "setup_completed": setup_completed(), + "credential_state": state, + "bootstrap_recovery_state": recovery_state, + "production_write": False, + } + elif args.mode == "apply": + result = apply() + else: + state, token = credential_state(read_value=True) + recovery_state, _ = secret_state(RECOVERY_PASSWORD_PATH) + if ( + state != "present" + or token is None + or not setup_completed() + or recovery_state != "absent" + ): + raise ProvisionError("verify_precondition_failed") + verify_key(token) + token = "" + result = { + "terminal": "verify_pass", + "setup_completed": True, + "credential_state": "present", + "credential_api_verified": True, + } + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 + except ProvisionError as exc: + print( + json.dumps( + {"terminal": "failed", "error_code": str(exc)}, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent99-signoz-metadata-executor.ps1 b/agent99-signoz-metadata-executor.ps1 index 57d328b27..6530188ab 100644 --- a/agent99-signoz-metadata-executor.ps1 +++ b/agent99-signoz-metadata-executor.ps1 @@ -220,7 +220,7 @@ function Get-Agent99RuntimeSourceBinding { } $result.ok = [bool]( $result.runtimeMatched -and - $result.fileCount -eq 18 -and + $result.fileCount -eq 20 -and $result.mismatchCount -eq 0 -and $result.sourceRevision -eq $SourceRevision -and $result.entrypointTracked -and diff --git a/scripts/ops/tests/test_agent99_signoz_credential_provisioner.py b/scripts/ops/tests/test_agent99_signoz_credential_provisioner.py new file mode 100644 index 000000000..d8907b18f --- /dev/null +++ b/scripts/ops/tests/test_agent99_signoz_credential_provisioner.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import stat +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +TARGET = ROOT / "agent99-signoz-credential-target.py" +PROVISIONER = ROOT / "agent99-signoz-credential-provisioner.ps1" + + +def load_target(): + spec = importlib.util.spec_from_file_location("signoz_credential_target", TARGET) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class SignozState: + setup_completed = False + fail_dashboard = False + fail_session_cleanup = False + lose_register_response_once = False + deny_pat_admin_readback = False + requests: list[tuple[str, str, dict[str, Any] | None]] + + def __init__(self) -> None: + self.setup_completed = False + self.fail_dashboard = False + self.fail_session_cleanup = False + self.lose_register_response_once = False + self.deny_pat_admin_readback = False + self.requests = [] + + +def start_server(state: SignozState) -> tuple[ThreadingHTTPServer, str]: + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args: object) -> None: + return + + def _send(self, status: int, value: dict[str, Any]) -> None: + raw = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def _no_content(self) -> None: + self.send_response(204) + self.end_headers() + + def _body(self) -> dict[str, Any] | None: + length = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(length)) if length else None + + def do_GET(self) -> None: # noqa: N802 + state.requests.append(("GET", self.path, None)) + if self.path == "/api/v1/version": + self._send( + 200, + {"version": "v0.113.0", "ee": "Y", "setupCompleted": state.setup_completed}, + ) + elif self.path == "/api/v1/user/me": + self._send(200, {"status": "success", "data": {"role": "ADMIN", "orgId": "org-1"}}) + elif self.path.startswith("/api/v2/sessions/context?"): + self._send( + 200, + { + "status": "success", + "data": {"exists": True, "orgs": [{"id": "org-1"}]}, + }, + ) + elif self.path == "/api/v1/dashboards" and state.fail_dashboard: + self._send(401, {"status": "error"}) + elif self.path == "/api/v1/dashboards": + assert self.headers.get("SIGNOZ-API-KEY") == "opaque-pat-value" + self._send(200, {"status": "success", "data": []}) + elif self.path == "/api/v1/roles": + self._send(200, {"status": "success", "data": [{"name": "ADMIN"}]}) + elif self.path == "/api/v1/pats" and state.deny_pat_admin_readback: + self._send(403, {"status": "error"}) + elif self.path == "/api/v1/pats": + self._send(200, {"status": "success", "data": [{"id": "pat-1"}]}) + else: + self._send(404, {"status": "error"}) + + def do_POST(self) -> None: # noqa: N802 + body = self._body() + state.requests.append(("POST", self.path, body)) + if self.path == "/api/v1/register": + state.setup_completed = True + if state.lose_register_response_once: + state.lose_register_response_once = False + self.connection.shutdown(2) + self.connection.close() + return + self._send(200, {"status": "success", "data": {"orgId": "org-1"}}) + elif self.path == "/api/v2/sessions/email_password": + self._send( + 200, + {"status": "success", "data": {"accessToken": "session-jwt", "refreshToken": "refresh"}}, + ) + elif self.path == "/api/v1/pats": + self._send( + 201, + {"status": "success", "data": {"id": "pat-1", "token": "opaque-pat-value"}}, + ) + else: + self._send(404, {"status": "error"}) + + def do_DELETE(self) -> None: # noqa: N802 + state.requests.append(("DELETE", self.path, None)) + if self.path == "/api/v2/sessions" and state.fail_session_cleanup: + self._send(500, {"status": "error"}) + else: + self._no_content() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + return server, f"http://{host}:{port}" + + +def configure(module: Any, tmp_path: Path, base_url: str) -> Path: + credential = tmp_path / "secrets" / "signoz-metadata-api-key" + module.BASE_URL = base_url + module.CREDENTIAL_PATH = credential + module.RECOVERY_PASSWORD_PATH = tmp_path / "secrets" / "bootstrap.recovery" + module.EXPECTED_UID = os.geteuid() + module.os.chown = lambda *_args, **_kwargs: None + return credential + + +def test_agent99_entrypoint_is_fixed_and_never_transports_secret() -> None: + source = PROVISIONER.read_text(encoding="utf-8") + target = TARGET.read_text(encoding="utf-8") + + assert '[ValidateSet("Check", "Apply", "Verify")]' in source + assert '$TargetHost = "192.168.0.110"' in source + assert '$ExpectedWorkItemId = "P0-OBS-002"' in source + assert '$ExpectedRuntimeFileCount = 20' in source + assert '"sudo -n /usr/bin/python3 - --mode $($Mode.ToLowerInvariant())"' in source + assert "-RedirectStandardInput $TargetProgram" in source + assert "secret_in_transport = $false" in source + assert "Invoke-Expression" not in source + assert "-EncodedCommand" not in source + assert '/etc/awoooi/secrets/signoz-metadata-api-key' in target + assert "os.O_EXCL" in target and "os.O_NOFOLLOW" in target + assert '"role": "ADMIN"' in target + assert '"expiresInDays": 30' in target + assert "SIGNOZ-API-KEY" in target + assert "status != 204" in target + assert "token_urlsafe" in target + assert "setup_already_completed_without_bootstrap_recovery" in target + assert "bootstrap_recovery_removed" in target + assert "print(password" not in target + assert "print(token" not in target + + +def test_apply_bootstraps_admin_persists_only_pat_and_verifies(tmp_path: Path) -> None: + module = load_target() + state = SignozState() + server, base_url = start_server(state) + credential = configure(module, tmp_path, base_url) + try: + result = module.apply() + finally: + server.shutdown() + server.server_close() + + assert result["terminal"] == "applied_verified" + assert result["bootstrap_is_permanent"] is True + assert credential.read_text() == "opaque-pat-value" + assert stat.S_IMODE(credential.stat().st_mode) == 0o400 + assert not module.RECOVERY_PASSWORD_PATH.exists() + serialized = json.dumps(result) + assert "opaque-pat-value" not in serialized + assert "session-jwt" not in serialized + assert any(method == "DELETE" and path == "/api/v2/sessions" for method, path, _ in state.requests) + + +def test_apply_failure_revokes_pat_and_removes_credential(tmp_path: Path) -> None: + module = load_target() + state = SignozState() + state.fail_dashboard = True + server, base_url = start_server(state) + credential = configure(module, tmp_path, base_url) + try: + with pytest.raises(module.ProvisionError, match="http_get_dashboards_401"): + module.apply() + finally: + server.shutdown() + server.server_close() + + assert not credential.exists() + assert module.RECOVERY_PASSWORD_PATH.exists() + assert ("DELETE", "/api/v1/pats/pat-1", None) in state.requests + assert ("DELETE", "/api/v2/sessions", None) in state.requests + + +def test_lost_registration_response_recovers_with_root_only_secret(tmp_path: Path) -> None: + module = load_target() + state = SignozState() + state.lose_register_response_once = True + server, base_url = start_server(state) + credential = configure(module, tmp_path, base_url) + try: + result = module.apply() + finally: + server.shutdown() + server.server_close() + + assert result["terminal"] == "applied_verified" + assert credential.exists() + assert not module.RECOVERY_PASSWORD_PATH.exists() + assert any(path.startswith("/api/v2/sessions/context?") for _, path, _ in state.requests) + + +def test_session_cleanup_failure_revokes_pat_and_preserves_recovery(tmp_path: Path) -> None: + module = load_target() + state = SignozState() + state.fail_session_cleanup = True + server, base_url = start_server(state) + credential = configure(module, tmp_path, base_url) + try: + with pytest.raises(module.ProvisionError, match="session_cleanup_unverified"): + module.apply() + finally: + server.shutdown() + server.server_close() + + assert not credential.exists() + assert module.RECOVERY_PASSWORD_PATH.exists() + assert ("DELETE", "/api/v1/pats/pat-1", None) in state.requests + + +def test_admin_pat_readback_failure_revokes_before_persist(tmp_path: Path) -> None: + module = load_target() + state = SignozState() + state.deny_pat_admin_readback = True + server, base_url = start_server(state) + credential = configure(module, tmp_path, base_url) + try: + with pytest.raises(module.ProvisionError, match="http_get_pats_403"): + module.apply() + finally: + server.shutdown() + server.server_close() + + assert not credential.exists() + assert module.RECOVERY_PASSWORD_PATH.exists() + assert ("DELETE", "/api/v1/pats/pat-1", None) in state.requests + + +def test_verify_rejects_unsafe_credential_metadata(tmp_path: Path) -> None: + module = load_target() + credential = tmp_path / "credential" + credential.write_text("opaque-pat-value") + credential.chmod(0o644) + module.CREDENTIAL_PATH = credential + module.EXPECTED_UID = os.geteuid() + with pytest.raises(module.ProvisionError, match="credential_mode_invalid"): + module.credential_state(read_value=True) diff --git a/scripts/ops/tests/test_agent99_signoz_metadata_executor_runtime_entrypoint.py b/scripts/ops/tests/test_agent99_signoz_metadata_executor_runtime_entrypoint.py index 1109d1197..e56ee4e4d 100644 --- a/scripts/ops/tests/test_agent99_signoz_metadata_executor_runtime_entrypoint.py +++ b/scripts/ops/tests/test_agent99_signoz_metadata_executor_runtime_entrypoint.py @@ -26,7 +26,7 @@ def test_entrypoint_is_fixed_to_windows99_host110_and_p0_obs_002() -> None: assert "._+" not in source assert '$EvidenceRunId = if ($RunId -match $SafeIdPattern)' in source assert "Get-Agent99RuntimeSourceBinding" in source - assert "$result.fileCount -eq 18" in source + assert "$result.fileCount -eq 20" in source assert "$result.sourceRevision -eq $SourceRevision" in source assert "$result.entrypointHashMatched" in source assert '$CredentialFile = "/etc/awoooi/secrets/signoz-metadata-api-key"' in source @@ -293,8 +293,8 @@ def test_entrypoint_is_in_exact_agent99_runtime_bundle() -> None: assert expected in _quoted_array(contract, "$requiredFiles = @(", ")") assert expected in _quoted_array(sender, "RUNTIME_FILES=(", ")") assert expected in _quoted_array(receiver, "$FixedRuntimeFiles = @(", ")") - assert 'expectedRuntimeFileCount": 18' in sender - assert '$runtimeManifest.fileCount -eq 18' in receiver + assert 'expectedRuntimeFileCount": 20' in sender + assert '$runtimeManifest.fileCount -eq 20' in receiver def test_runtime_contract_check_requires_typed_signoz_transport_readback() -> None: diff --git a/scripts/ops/tests/test_db_bounded_executor_runtime_entrypoint.py b/scripts/ops/tests/test_db_bounded_executor_runtime_entrypoint.py index f453ca371..54587138c 100644 --- a/scripts/ops/tests/test_db_bounded_executor_runtime_entrypoint.py +++ b/scripts/ops/tests/test_db_bounded_executor_runtime_entrypoint.py @@ -122,7 +122,7 @@ def test_agent99_atomic_bundle_owns_the_dispatch_entrypoint() -> None: assert "$dbExecutorRecoveryContract" in contract assert "awoooi_db_bounded_executor_receipt_v2" in contract assert "Receipt.database_sqlstate" in contract - assert 'expectedRuntimeFileCount": 18' in transport - assert "expectedRuntimeFileCount=18" in transport - assert "expectedRuntimeFileCount -ne 18" in receiver - assert "fileCount = 18" in receiver + assert 'expectedRuntimeFileCount": 20' in transport + assert "expectedRuntimeFileCount=20" in transport + assert "expectedRuntimeFileCount -ne 20" in receiver + assert "fileCount = 20" in receiver diff --git a/scripts/reboot-recovery/agent99-live-preflight.ps1 b/scripts/reboot-recovery/agent99-live-preflight.ps1 index 14b387c08..7159a7474 100644 --- a/scripts/reboot-recovery/agent99-live-preflight.ps1 +++ b/scripts/reboot-recovery/agent99-live-preflight.ps1 @@ -984,7 +984,7 @@ $decision = Get-AgentLivePreflightDecision ` -AgentRootPresent ([bool](Test-Path $AgentRoot)) ` -Manifest $manifest ` -ManifestCheckRequired ([bool]($ReadinessScope -eq "FullRuntime")) ` - -ExpectedRuntimeFileCount 18 ` + -ExpectedRuntimeFileCount 20 ` -TaskFailureCount $taskFailures.Count ` -RelayListenerCount $relayListeners.Count ` -RequiredEvidenceStaleCount $requiredEvidenceStale.Count ` diff --git a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 index 2b1e003e0..b8c9af3f4 100644 --- a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 +++ b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 @@ -36,6 +36,8 @@ $FixedRuntimeFiles = @( "agent99-control-plane.ps1", "agent99-db-bounded-executor.ps1", "agent99-signoz-metadata-executor.ps1", + "agent99-signoz-credential-provisioner.ps1", + "agent99-signoz-credential-target.py", "agent99-deploy.ps1", "agent99-register-tasks.ps1", "agent99-alertmanager-alertchain-poll.ps1", @@ -1064,7 +1066,7 @@ try { } $sourceRevision = ([string]$envelope.sourceRevision).ToLowerInvariant() if ($sourceRevision -notmatch "^[0-9a-f]{40}$") { throw "invalid_source_revision" } - if ([int]$envelope.expectedRuntimeFileCount -ne 18) { throw "invalid_expected_runtime_file_count" } + if ([int]$envelope.expectedRuntimeFileCount -ne 20) { throw "invalid_expected_runtime_file_count" } $traceId = [string]$envelope.traceId $runId = [string]$envelope.runId @@ -1099,7 +1101,7 @@ try { $sourceManifest = [pscustomobject]@{ schemaVersion = "agent99_remote_source_manifest_v1" sourceRevision = $sourceRevision - fileCount = 18 + fileCount = 20 manifestSha256 = $manifestDigest files = @($FixedRuntimeFiles | ForEach-Object { [pscustomobject]@{ name = $_; sha256 = ([string]($filesByName[$_].sha256)).ToLowerInvariant() } @@ -1136,7 +1138,7 @@ try { status = "check_ready" mode = "check" sourceRevision = $sourceRevision - expectedRuntimeFileCount = 18 + expectedRuntimeFileCount = 20 manifestSha256 = $manifestDigest plannedStagingPath = $stagePath runtimeManifest = $currentManifest @@ -1223,7 +1225,7 @@ try { if ( [string]$existingSourceManifest.schemaVersion -ne "agent99_remote_source_manifest_v1" -or [string]$existingSourceManifest.sourceRevision -ne $sourceRevision -or - [int]$existingSourceManifest.fileCount -ne 18 -or + [int]$existingSourceManifest.fileCount -ne 20 -or [string]$existingSourceManifest.manifestSha256 -ne $manifestDigest ) { throw "existing_staging_manifest_mismatch" } } catch { @@ -1247,7 +1249,7 @@ try { [string]$prior.launcherContract.sha256 -match "^[0-9a-f]{64}$" -and $live.runtimeMatched -and $live.sourceRevision -eq $sourceRevision -and - $live.fileCount -eq 18 -and + $live.fileCount -eq 20 -and $live.mismatchCount -eq 0 -and $liveLauncherContract.ok -and [string]$liveLauncherContract.sha256 -eq [string]$prior.launcherContract.sha256 @@ -1565,7 +1567,7 @@ try { $runtimeManifest.exists -and $runtimeManifest.runtimeMatched -and $runtimeManifest.sourceRevision -eq $sourceRevision -and - $runtimeManifest.fileCount -eq 18 -and + $runtimeManifest.fileCount -eq 20 -and $runtimeManifest.mismatchCount -eq 0 -and $launcherContract.ok -and [string]$launcherContract.sha256 -eq [string]$deploy.payload.launcherContract.sha256 -and diff --git a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh index 658e5f375..5fcce3a39 100755 --- a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh +++ b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh @@ -18,6 +18,8 @@ RUNTIME_FILES=( "agent99-control-plane.ps1" "agent99-db-bounded-executor.ps1" "agent99-signoz-metadata-executor.ps1" + "agent99-signoz-credential-provisioner.ps1" + "agent99-signoz-credential-target.py" "agent99-deploy.ps1" "agent99-register-tasks.ps1" "agent99-alertmanager-alertchain-poll.ps1" @@ -227,7 +229,7 @@ trace_id, run_id, work_item_id = sys.argv[4:7] output_path = Path(sys.argv[7]) runtime_names = sys.argv[8:] -if len(runtime_names) != 18 or len(set(runtime_names)) != 18: +if len(runtime_names) != 20 or len(set(runtime_names)) != 20: raise SystemExit("fixed_runtime_file_contract_failed") files: list[dict[str, str]] = [] @@ -267,7 +269,7 @@ envelope = { "runId": run_id, "workItemId": work_item_id, "sourceRevision": source_revision, - "expectedRuntimeFileCount": 18, + "expectedRuntimeFileCount": 20, "manifestSha256": manifest_sha256, "files": files, "livePreflight": { @@ -419,14 +421,14 @@ stage_token = hashlib.sha256(stage_identity.encode("utf-8")).hexdigest()[:20] script = r'''$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue' $a='C:\Wooo\Agent99';$b=Join-Path $a 'bin';$p=Join-Path $a 'state\runtime-manifest.json' $r=[ordered]@{exists=$false;sourceRevision='';runtimeMatched=$false;recordedRuntimeMatched=$false;fileCount=0;mismatchCount=-1;recordedMismatchCount=-1;parseError=''} -if(Test-Path -LiteralPath $p -PathType Leaf){try{$m=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json;$rows=@($m.files);$seen=@{};$bad=0;foreach($x in $rows){$n=[string]$x.name;if(!$n-or[IO.Path]::GetFileName($n)-ne$n-or$seen.ContainsKey($n)){$bad++;continue};$seen[$n]=1;$q=Join-Path $b $n;$h=if(Test-Path -LiteralPath $q -PathType Leaf){(Get-FileHash -LiteralPath $q -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'};if(([string]$x.runtimeSha256).ToLowerInvariant()-ne$h){$bad++}};$r.exists=$true;$r.sourceRevision=[string]$m.sourceRevision;$r.recordedRuntimeMatched=[bool]$m.runtimeMatched;$r.fileCount=[int]$m.fileCount;$r.mismatchCount=$bad;$r.recordedMismatchCount=[int]$m.mismatchCount;$r.runtimeMatched=[bool]($m.runtimeMatched-and$m.fileCount-eq 18-and$m.mismatchCount-eq 0-and$rows.Count-eq 18-and$seen.Count-eq 18-and$bad-eq 0)}catch{$r.exists=$true;$r.parseError=$_.Exception.GetType().Name}} +if(Test-Path -LiteralPath $p -PathType Leaf){try{$m=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json;$rows=@($m.files);$seen=@{};$bad=0;foreach($x in $rows){$n=[string]$x.name;if(!$n-or[IO.Path]::GetFileName($n)-ne$n-or$seen.ContainsKey($n)){$bad++;continue};$seen[$n]=1;$q=Join-Path $b $n;$h=if(Test-Path -LiteralPath $q -PathType Leaf){(Get-FileHash -LiteralPath $q -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'};if(([string]$x.runtimeSha256).ToLowerInvariant()-ne$h){$bad++}};$r.exists=$true;$r.sourceRevision=[string]$m.sourceRevision;$r.recordedRuntimeMatched=[bool]$m.runtimeMatched;$r.fileCount=[int]$m.fileCount;$r.mismatchCount=$bad;$r.recordedMismatchCount=[int]$m.mismatchCount;$r.runtimeMatched=[bool]($m.runtimeMatched-and$m.fileCount-eq 20-and$m.mismatchCount-eq 0-and$rows.Count-eq 20-and$seen.Count-eq 20-and$bad-eq 0)}catch{$r.exists=$true;$r.parseError=$_.Exception.GetType().Name}} $c=[ordered]@{executed=$false;ok=$false;exitCode=-1;errorType=''} try{& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path $b 'agent99-contract-check.ps1') -SourceRoot $b 2>$null|Out-Null;$c.executed=$true;$c.exitCode=$LASTEXITCODE;$c.ok=[bool]($c.exitCode-eq 0)}catch{$c.executed=$true;$c.errorType=$_.Exception.GetType().Name} $z=[ordered]@{};foreach($n in 'remoteWritePerformed,liveRuntimeWritePerformed,livePromotionAttempted,livePromotionPerformed,secretValueRead,privateKeyValueRead,tokenValueRead,environmentSecretRead,uiInteraction,vmPowerChange,hostReboot,serviceRestart,scheduledTaskRestart,scheduledTaskModification'.Split(',')){$z[$n]=$false} $ready=[bool]($c.executed-and$c.ok-and$c.exitCode-eq 0) $status=if($ready){'check_ready'}else{'check_failed_no_apply'} $next=if($ready){'rerun_with_apply_and_trace_run_work_item_after_source_commit_and_transport_check'}else{'repair_runtime_contract_before_apply'} -[ordered]@{schemaVersion='agent99_remote_atomic_deploy_receipt_v1';status=$status;mode='check';sourceRevision='__SOURCE_REVISION__';expectedRuntimeFileCount=18;manifestSha256='__MANIFEST_SHA256__';plannedStagingPath='C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__';runtimeManifest=[pscustomobject]$r;runtimeContract=[pscustomobject]$c;livePreflight=[ordered]@{executed=$false;reason='apply_only_after_validate_only'};transportPayloadMode='control_command_no_stdin';transientTransportPayloadStored=$false;transientTransportPayloadDeletedBeforeReceiver=$false;transportPayloadContainsSecrets=$false;operationBoundaries=$z;nextSafeAction=$next}|ConvertTo-Json -Compress -Depth 8 +[ordered]@{schemaVersion='agent99_remote_atomic_deploy_receipt_v1';status=$status;mode='check';sourceRevision='__SOURCE_REVISION__';expectedRuntimeFileCount=20;manifestSha256='__MANIFEST_SHA256__';plannedStagingPath='C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__';runtimeManifest=[pscustomobject]$r;runtimeContract=[pscustomobject]$c;livePreflight=[ordered]@{executed=$false;reason='apply_only_after_validate_only'};transportPayloadMode='control_command_no_stdin';transientTransportPayloadStored=$false;transientTransportPayloadDeletedBeforeReceiver=$false;transportPayloadContainsSecrets=$false;operationBoundaries=$z;nextSafeAction=$next}|ConvertTo-Json -Compress -Depth 8 if(-not$ready){exit 65}''' script = ( script.replace("__SOURCE_REVISION__", source_revision) diff --git a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py index f63812070..17cf2cfd3 100644 --- a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py +++ b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py @@ -37,7 +37,7 @@ def _base_case() -> dict[str, Any]: "exists": True, "sourceRevision": "a" * 40, "runtimeMatched": True, - "fileCount": 18, + "fileCount": 20, "mismatchCount": 0, "parseError": "", }, @@ -86,7 +86,7 @@ $parameters = @{{ AgentRootPresent = [bool]$case.agentRootPresent Manifest = $case.manifest ManifestCheckRequired = [bool]$case.manifestCheckRequired - ExpectedRuntimeFileCount = 18 + ExpectedRuntimeFileCount = 20 TaskFailureCount = [int]$case.taskFailureCount RelayListenerCount = [int]$case.relayListenerCount RequiredEvidenceStaleCount = [int]$case.requiredEvidenceStaleCount @@ -140,7 +140,7 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No ) assert "deploymentEligible = [bool]($blockingReasons.Count -eq 0)" in decision assert "preflightGreen = [bool]$decision.deploymentEligible" in source - assert "-ExpectedRuntimeFileCount 18" in source + assert "-ExpectedRuntimeFileCount 20" in source assert "warningReasons = $warningReasons" in source assert "selfHealthPerformanceIssues = @(" in source assert 'if ($safeHost -notmatch "^[A-Za-z0-9.:-]{1,128}$")' in source diff --git a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py index aee8f27e2..a00284d64 100644 --- a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py +++ b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py @@ -26,6 +26,8 @@ EXPECTED_RUNTIME_FILES = ( "agent99-control-plane.ps1", "agent99-db-bounded-executor.ps1", "agent99-signoz-metadata-executor.ps1", + "agent99-signoz-credential-provisioner.ps1", + "agent99-signoz-credential-target.py", "agent99-deploy.ps1", "agent99-register-tasks.ps1", "agent99-alertmanager-alertchain-poll.ps1", @@ -88,7 +90,7 @@ def test_sender_and_receiver_allow_exactly_the_deployer_runtime_bundle() -> None ) assert preflight_count is not None assert int(preflight_count.group(1)) == len(EXPECTED_RUNTIME_FILES) - assert "expectedRuntimeFileCount\": 18" in sender + assert "expectedRuntimeFileCount\": 20" in sender assert "$filesByName.Count -ne $FixedRuntimeFiles.Count" in receiver assert "required_runtime_file_missing" in receiver assert "duplicate_runtime_file" in receiver @@ -263,7 +265,7 @@ def test_receiver_rolls_back_failed_deploy_or_failed_independent_post_verifier() ) assert "$livePreflight.sourceRevision -eq $sourceRevision" in source assert "$runtimeManifest.sourceRevision -eq $sourceRevision" in source - assert '$runtimeManifest.fileCount -eq 18' in source + assert '$runtimeManifest.fileCount -eq 20' in source assert "function Invoke-AgentWindowsControlBaseline" in source assert "function Get-AgentRunEvidenceToken" in source assert "$evidenceRunToken = Get-AgentRunEvidenceToken -Value $RunId" in source