Harden external release preparation workflow
This commit is contained in:
1 parent
f19faf628e
commit
710c483f34
19 files changed
+1145
-154
No files matched your search
@@ -1,28 +1,52 @@
|
||||
# Infrastructure current state
|
||||
|
||||
## Proposed external deployment runtime binding — 2026-11-01
|
||||
## External deployment runtime binding — 2026-11-03
|
||||
|
||||
- PR #59 now proposes a root-only read-only status endpoint and exact-literal
|
||||
SSH dispatcher for `status` and `deploy`. The status path bounds the Gitea
|
||||
run list to ten entries, verifies the selected run/workflow/current `main`,
|
||||
- PR #59 is merged to `main`; it contains the root-only read-only status
|
||||
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
|
||||
status path bounds the Gitea run list to ten entries, verifies the selected
|
||||
run/workflow/current `main`,
|
||||
validates artifact metadata and ZIP bytes, and returns only a release tuple.
|
||||
It fails closed for a stale host checkout, recovery journal, incomplete
|
||||
listing, or the exact release already recorded as applied. The deploy
|
||||
receiver still independently revalidates and uses the existing lock,
|
||||
recovery journal, cold-backup ACK, cutover, and rollback protections.
|
||||
- The Windows client proposes a disabled-by-default `--once` mode with a
|
||||
fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
|
||||
config and Task Scheduler XML are both disabled; the template uses an
|
||||
interactive least-privilege owner, ignores overlapping invocations, and
|
||||
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
|
||||
compares protected Compose/Caddy bytes and installed release policy with
|
||||
Gitea content pinned to the release SHA, verifying each Git blob hash and
|
||||
size. This permits application-only release B after bootstrap A without
|
||||
advancing the root checkout or executing new root code. A runtime config or
|
||||
policy change fails closed and requires a reviewed bootstrap update. The
|
||||
Windows response state is `prepared`, never `candidate`; the `--once` task
|
||||
stores a protected local tuple and cannot call deploy. A separate disabled
|
||||
auto-apply task and config gate require the tuple to match a fresh status
|
||||
response from a previous poll. Status and apply retain current-main,
|
||||
successful CI/release provenance, descriptor digest, external backup,
|
||||
cutover, canary, and rollback checks. The receiver repeats the config check
|
||||
before journaling/stopping services and again after backup immediately before
|
||||
switching refs.
|
||||
- The Windows client has disabled-by-default preparation and auto-apply tasks
|
||||
with a fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
|
||||
config files and Task Scheduler XML are disabled; the templates use an
|
||||
interactive least-privilege owner, ignore overlapping invocations, and
|
||||
does not wake the computer. No Windows task, SSH key, VPS command, helper,
|
||||
or trigger config has been installed or exercised. Production CD remains
|
||||
inactive.
|
||||
- Remaining owner prerequisites are exact root forced-command/SSH host-key
|
||||
setup for the dispatcher, installation of the reviewed Windows client under
|
||||
a non-user-writable path, private ACLs for its config and D: backups, a
|
||||
root-controlled checkout matching current `main`, and all previously listed
|
||||
backup/restore and production readiness gates. The status endpoint does not
|
||||
advance the host checkout and refuses to offer a stale release.
|
||||
or trigger action has been installed or exercised. The canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` path is known, but its
|
||||
contents/enabled state have not been read. Windows metadata confirms
|
||||
`C:\Python313\python.exe`, valid Python Software Foundation Authenticode,
|
||||
and protected interpreter ACLs. Linux fixtures pass for Python import and
|
||||
command-path poisoning, including inherited `GIT_*` removal and fixed
|
||||
`ssh.exe`/`git.exe`/`powershell.exe` resolution. The installer also checks
|
||||
protected ownership/write access across each tool's parent path. These Windows
|
||||
ACL checks are code-reviewed only; the installer and launcher have not run on
|
||||
Windows. Production CD remains inactive.
|
||||
- Remaining owner prerequisites are installation of the reviewed root helper,
|
||||
exact forced-command/SSH host-key setup, a protected Windows client and its
|
||||
private state/config ACLs, private D: backup ACLs, and all previously listed
|
||||
backup/restore and production readiness gates. Neither the status endpoint nor client advances
|
||||
the host checkout or updates root helper code.
|
||||
|
||||
- Read-only Gitea check for fresh `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
|
||||
run #708 failed at the explicit registry credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
|
||||
skipped. No secret values were read. Image publication remains owner-blocked.
|
||||
|
||||
Owner decision recorded 2026-10-26: a complete backup must not be stored on
|
||||
the VPS, including temporarily. The local archive/full cold-backup commands in
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
## Status and authorization
|
||||
|
||||
The implementation in PR #59 is not merged, installed, or exercised against
|
||||
the VPS. No release, backup, restore, or production service operation has been
|
||||
performed by this change. Production deployment remains blocked until the
|
||||
owner has verified host state, registry access, protected configuration,
|
||||
The release and fixed-receiver code from PR #59 is on `main`, but has not been
|
||||
installed or exercised against the VPS. This PR's compatibility and Windows
|
||||
bootstrap changes are still proposed. No release, backup, restore, or
|
||||
production service operation has been performed by this change. Production
|
||||
deployment remains blocked until the owner has verified host state, registry
|
||||
access, protected configuration,
|
||||
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
|
||||
separate target. See [current state](CURRENT_STATE.md) and the
|
||||
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
|
||||
@@ -26,6 +28,13 @@ The descriptor records both the release workflow run ID and the successful CI
|
||||
source run ID. PR builds do not publish images. Production runtime secrets are
|
||||
not supplied to CI.
|
||||
|
||||
Read-only Gitea check on 2026-11-03 for current `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
|
||||
run #708 failed at the explicit registry-credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
|
||||
#709 was skipped. No secret values were read. Image publication therefore
|
||||
remains blocked until the owner provisions the required registry secrets.
|
||||
|
||||
On the host, the fixed root receiver uses the installed `gitea-api` helper to
|
||||
re-fetch and verify the exact successful CI and release runs, current `main`,
|
||||
artifact name, checksum, manifest schema, builder pin, image policy, and commit
|
||||
@@ -35,46 +44,77 @@ the response, and refuses to overwrite an existing file. The installer checks
|
||||
the exact audited helper fixture hash before applying its patch. The helper
|
||||
and source code are not installed on the VPS by this PR.
|
||||
|
||||
## One-shot runtime binding proposed by PR #59
|
||||
## One-shot runtime binding and bootstrap compatibility
|
||||
|
||||
PR #59 adds a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
The dispatcher accepts only the literal status or deploy command in
|
||||
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
|
||||
evaluation. Status fails closed on a stale root checkout, recovery journal,
|
||||
incomplete Gitea run listing, failed source/workflow checks, invalid artifact
|
||||
metadata/ZIP, or a release already applied. The candidate response contains
|
||||
only a run ID, commit, and descriptor digest. The root receiver independently
|
||||
repeats release verification and the existing backup/ACK/cutover guards.
|
||||
evaluation. Status verifies successful current-main provenance, artifact
|
||||
metadata/ZIP, and host runtime compatibility before returning only a run ID,
|
||||
commit, and descriptor digest in a `prepared` response. It fails closed on a
|
||||
recovery journal, incomplete Gitea run listing, invalid release data, changed
|
||||
host runtime configuration, or an already applied release. The root receiver
|
||||
independently repeats release verification and the existing backup/ACK/cutover
|
||||
guards.
|
||||
|
||||
The Windows client adds a one-shot `receive_external.py --once` mode. Its
|
||||
configuration is fixed to `D:\Dev\akyldash` and the `akyldash_release` SSH
|
||||
alias, and is disabled by default. It uses BatchMode, disables forwarding,
|
||||
uses the existing host-key checking, bounds status output and time, then calls
|
||||
the existing `run_release` path. Rollback remains a separate explicit
|
||||
owner-run command. The example Task Scheduler XML is disabled, runs as the
|
||||
interactive owner at least privilege, ignores overlapping instances, and
|
||||
never wakes the computer. No task has been registered or enabled by this PR.
|
||||
The Windows `--once` mode only polls the fixed read-only status endpoint and
|
||||
stores a protected local `PREPARED` tuple. It never sends a deploy request.
|
||||
Automatic deployment uses a second task and a separate
|
||||
`release-auto-apply.json` gate; both config files and both scheduler XML
|
||||
templates default to disabled. The apply task acts only when its current
|
||||
verified tuple exactly matches a tuple saved by a previous poll. An owner can
|
||||
also use `--apply-prepared`, which requires an interactive exact-SHA
|
||||
confirmation. Both deploy paths re-enter the existing root receiver and all
|
||||
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
|
||||
command. The scheduler templates run as the interactive owner at least
|
||||
privilege, ignore overlapping instances, and never wake the computer. No task
|
||||
has been registered or enabled by this PR.
|
||||
|
||||
Before any activation, the owner must install the reviewed receiver and its
|
||||
`archive.py` and `external_session.py` dependencies under a non-user-writable
|
||||
`C:\Program Files\Akyldash` directory, set the config ACL so
|
||||
only the owner, SYSTEM, and Administrators have full control, fill in the real
|
||||
Python executable, owner SID, and local start time in the task template, and
|
||||
confirm SSH host-key checking and the forced-command alias. Keep `enabled`
|
||||
false and the task
|
||||
disabled until the normal backup/restore and production readiness gates are
|
||||
recorded. Do not bind a Gitea workflow or PR runner to this Windows host.
|
||||
The reviewed bootstrap installer requires a clean `main` checkout at an
|
||||
explicit SHA, verifies the signed `C:\Python313\python.exe` and protected ACLs,
|
||||
and stages client files under a SHA-versioned Program Files directory without
|
||||
overwriting an existing version. It preserves an existing canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json`, requires it to remain disabled,
|
||||
and creates a second disabled auto-apply config only if absent. It writes two
|
||||
disabled task XML files for owner review and does not register or enable tasks.
|
||||
The launcher starts Python with `-B -E -s -S`, removes paths outside the standard
|
||||
library and protected client folder, and checks imported module origins. Before
|
||||
loading the receiver it replaces inherited command lookup with the fixed Windows,
|
||||
OpenSSH, and Git directories; it clears inherited `GIT_*` variables and verifies
|
||||
that `ssh.exe`, `git.exe`, and `powershell.exe` resolve to their fixed paths. The
|
||||
installer checks those binaries and every parent path for reparse points, trusted
|
||||
owners, and write access limited to SYSTEM, Administrators, and TrustedInstaller.
|
||||
Synthetic poisoning tests ran on Linux; the launcher and installer have not
|
||||
been executed on Windows. Keep both config gates and both tasks disabled until
|
||||
the owner records the normal backup/restore and production readiness gates.
|
||||
Do not bind a Gitea workflow or PR runner to this Windows host.
|
||||
|
||||
The root-side status endpoint requires `/opt/akyldash/current` to exactly
|
||||
match current `main`; it does not update that checkout. An owner must advance
|
||||
the root-controlled checkout through the approved installation process before
|
||||
a release built from a newer commit can be considered ready. Gitea's filtered
|
||||
workflow-run listing is bounded to ten entries; if Gitea reports more entries
|
||||
or an inconsistent page, status returns unavailable rather than guessing.
|
||||
The root status/deploy helper remains the reviewed root-installed bootstrap
|
||||
code; it never checks out or executes target release code. A newer app release
|
||||
is eligible without manually advancing `/opt/akyldash/current` only when the
|
||||
host's Compose file, Caddyfile, and installed release policy match the exact
|
||||
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
|
||||
the Git blob SHA and size are verified. Status checks before preparing the
|
||||
tuple; the receiver repeats the check before creating a journal or stopping
|
||||
services, and cutover checks again after the verified external backup and
|
||||
immediately before switching image references. A Compose/Caddy mismatch
|
||||
returns `runtime_config_changed`; a policy mismatch returns
|
||||
`release_policy_changed` and requires a reviewed owner bootstrap update. No
|
||||
host checkout or root helper is automatically changed. The release must still
|
||||
be current `main`; CI/release provenance, digest, backup, canary, and rollback
|
||||
checks remain required. Gitea's filtered workflow-run listing is bounded to
|
||||
ten entries; if Gitea reports more entries or an inconsistent page, status
|
||||
returns unavailable rather than guessing.
|
||||
|
||||
## Automation boundary: deployment is not active
|
||||
|
||||
The automatic chain currently ends at release publication:
|
||||
Until the owner separately activates both Windows tasks and the auto-apply
|
||||
configuration after the backup/restore and production gates, the chain ends at
|
||||
release publication. After that explicit one-time owner authorization, the
|
||||
prepared-state poll and separate apply task can process later matching
|
||||
application releases without advancing `/opt/akyldash/current` or approving
|
||||
each commit individually. A release that changes protected runtime files still
|
||||
requires a reviewed bootstrap update.
|
||||
|
||||
1. Successful `main` CI triggers image builds and publishes digest references
|
||||
plus the release descriptor artifact.
|
||||
@@ -82,14 +122,15 @@ The automatic chain currently ends at release publication:
|
||||
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
|
||||
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
|
||||
receiver. A green result means only that this status check completed.
|
||||
3. The fixed receiver is physically started only by an explicitly enabled
|
||||
owner-controlled Windows trigger or by the manual release CLI. The proposed
|
||||
`--once` path queries root's verified status endpoint and receives the
|
||||
fixed tuple; no repository workflow invokes it. The example scheduler and
|
||||
config remain disabled, and no Windows trigger has been installed or
|
||||
exercised.
|
||||
3. The fixed receiver is physically started only by the separately enabled
|
||||
owner-controlled auto-apply task or by an interactive owner command. The
|
||||
`--once` preparation task stores a verified tuple and never sends deploy;
|
||||
the separate apply task requires the tuple to match a fresh status response.
|
||||
No repository workflow invokes either task. Both scheduler templates and
|
||||
both configuration gates remain disabled, and neither task has been
|
||||
installed or exercised.
|
||||
|
||||
Production CD is still not active: no Windows task or credential is installed,
|
||||
Production CD is still not active: task/credential setup has not been verified,
|
||||
and no VPS or live D: operation has been performed. This design avoids a
|
||||
Windows Actions runner entirely; the owner-controlled local task is not exposed
|
||||
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
|
||||
@@ -150,9 +191,10 @@ from a root-owned checkout and provision, without exposing values in logs:
|
||||
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
|
||||
or arbitrary command access;
|
||||
- a protected installation of the Windows client under
|
||||
`C:\Program Files\Akyldash`, with the sample JSON and Task Scheduler XML
|
||||
reviewed and kept disabled until the owner explicitly accepts unattended
|
||||
polling.
|
||||
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
|
||||
XML templates reviewed. Keep the preparation task/config separate from the
|
||||
auto-apply task/config; the auto-apply pair remains disabled until the owner
|
||||
explicitly accepts unattended production deployment.
|
||||
|
||||
The installer intentionally does not create credentials, SSH entries, canary
|
||||
values, registry permissions, or a new privilege rule. Existing files with
|
||||
|
||||
@@ -6,22 +6,33 @@ private keys, secret material, or raw process environments.
|
||||
|
||||
## Required before release use
|
||||
|
||||
- PR #59 proposes a disabled one-shot Windows trigger. No task or endpoint is
|
||||
installed. If the owner chooses to activate it after merge and all other
|
||||
gates, install the reviewed `receive_external.py`, `archive.py`, and
|
||||
`external_session.py` client files in `C:\Program Files\Akyldash` with
|
||||
write access limited to administrators/SYSTEM, copy
|
||||
`infra/deploy/windows-release-trigger.example.json` to
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` and retain
|
||||
`enabled:false`, then grant only the interactive owner, SYSTEM, and
|
||||
Administrators full control on the config and its parent. Fill in the real
|
||||
Python path, owner SID, and a future local start time in the XML template.
|
||||
Review/import the task while
|
||||
disabled; it runs as that logged-on owner at least privilege, ignores a
|
||||
concurrent instance, does not wake the PC, and invokes only `--once`. Enable
|
||||
the JSON and task only after the owner separately approves unattended
|
||||
polling. Disable either one to stop future polls; an already running cold
|
||||
backup/deploy must be allowed to finish or be explicitly recovered.
|
||||
- PR #59's disabled one-shot Windows trigger is in `main`; it has not been
|
||||
installed or exercised. This PR's bootstrap files are also uninstalled.
|
||||
After review and merge, run `install_windows_release_client.ps1` only from a
|
||||
clean `main` checkout and provide its exact SHA through `-ExpectedCommit`.
|
||||
The installer verifies Python's PSF Authenticode signature and protected
|
||||
ACLs, stages client files in a SHA-versioned Program Files directory without
|
||||
replacing an existing version, preserves an existing canonical trigger
|
||||
config only if it is still disabled, and writes two disabled task XML files
|
||||
for review. It never registers or enables tasks. The canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` path is known, but its
|
||||
contents and enabled state have not been read. The launcher uses the
|
||||
verified `C:\Python313\python.exe` with `-B -E -s -S` and validates module
|
||||
origins. It also replaces inherited PATH, clears `GIT_*` overrides, and
|
||||
requires SSH, Git, and PowerShell to resolve to fixed protected binaries; the
|
||||
installer checks their parent paths for untrusted write access. The prep task
|
||||
stores a protected `PREPARED` tuple and never deploys.
|
||||
The separate auto-apply task is also disabled and requires a second config
|
||||
gate. The `enabled` field in the preparation config alone cannot deploy.
|
||||
Apply runs only when the fresh root status tuple matches a tuple saved by an
|
||||
earlier poll; no per-commit owner confirmation is needed after the owner
|
||||
explicitly activates unattended deployment. Enable the preparation config
|
||||
and task only after the owner separately approves polling. Enable the
|
||||
auto-apply config and task only after the owner approves unattended
|
||||
production deployment and completes every backup/restore readiness gate.
|
||||
Disabling either auto-apply control stops future automatic deploys; an
|
||||
already running cold backup/deploy must be allowed to finish or be explicitly
|
||||
recovered.
|
||||
- The `akyldash_release` SSH key must be dedicated to the root forced-command
|
||||
dispatcher, with OpenSSH `restrict` (or equivalent no-PTY/no-forwarding
|
||||
options), verified host-key checking, and exact status/deploy command
|
||||
@@ -30,17 +41,27 @@ private keys, secret material, or raw process environments.
|
||||
owner action. No key is generated, copied, or installed by this PR.
|
||||
- Decide whether unattended production CD is acceptable. It currently is not
|
||||
active: `deploy-handoff.yaml` remains status-only and reports `NOT DEPLOYED`;
|
||||
the proposed local task and its config remain disabled. This avoids exposing
|
||||
the Windows host to repository PR workflows. Verify that the root status
|
||||
command is installed from a reviewed checkout, its forced-command key is
|
||||
restricted, and the host checkout exactly matches current `main`. It does
|
||||
not update that checkout automatically. The endpoint fails closed if the
|
||||
both local tasks and both config gates remain disabled. This avoids exposing
|
||||
the Windows host to repository PR workflows. Verify the root status command
|
||||
is installed from reviewed bootstrap code and its forced-command key is
|
||||
restricted. The host checkout need not match current `main`: status compares
|
||||
root-protected Compose/Caddy bytes and the installed release policy with the
|
||||
exact candidate SHA through Gitea's pinned contents API and verifies Git blob
|
||||
hashes/sizes. The receiver repeats this before stopping services and cutover
|
||||
repeats it after backup. A mismatch requires a reviewed owner bootstrap
|
||||
update; no root checkout/helper code is automatically executed or updated.
|
||||
Current `main` and CI/release provenance are still rechecked before apply.
|
||||
The endpoint fails closed if the
|
||||
filtered workflow-run API page is incomplete (more than ten candidates or
|
||||
count mismatch), so status API behavior must be checked on the live Gitea
|
||||
before activation.
|
||||
- Confirm the registry package names and least-privilege writer/pull access for
|
||||
`admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest
|
||||
retention for the rollback window.
|
||||
retention for the rollback window. The latest read-only Gitea check found
|
||||
main CI run #706 successful, image release run #708 failed because the
|
||||
repository `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets are absent,
|
||||
and status-only handoff run #709 skipped. Configure those repository secrets
|
||||
through Gitea's secret settings; do not put their values in Git or logs.
|
||||
- Confirm Gitea artifact retention covers the rollback window. The workflow
|
||||
requests 90 days for the small release descriptor and checksum.
|
||||
- Provide a dated, sanitized root audit: installed checkout SHA, Compose
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -18,20 +19,23 @@ from pathlib import Path
|
||||
|
||||
from archive import verify_external_archive
|
||||
from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result,
|
||||
MAX_ARCHIVE_BYTES, receive_archive, write_ack, write_request)
|
||||
MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request)
|
||||
|
||||
DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash")
|
||||
DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release"
|
||||
TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json")
|
||||
AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json")
|
||||
PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json")
|
||||
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
|
||||
MAX_STATUS_BYTES = 1024
|
||||
STATUS_TIMEOUT_SECONDS = 180
|
||||
NOT_READY_REASONS = {
|
||||
"recovery_journal", "host_checkout_stale", "no_current_main_release", "main_advanced",
|
||||
"already_applied",
|
||||
"recovery_journal", "runtime_config_changed", "release_policy_changed",
|
||||
"no_current_main_release", "main_advanced", "already_applied",
|
||||
}
|
||||
MAX_ERROR_BYTES = 8192
|
||||
_WINDOWS = os.name == "nt"
|
||||
DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh"
|
||||
|
||||
|
||||
def _reparse(path: Path) -> bool:
|
||||
@@ -213,7 +217,7 @@ def validate_status_response(payload: bytes) -> dict | None:
|
||||
or value["release"] is not None):
|
||||
raise ValueError("release status not-ready response is invalid")
|
||||
return None
|
||||
if value["state"] != "candidate" or value["reason"] is not None:
|
||||
if value["state"] != "prepared" or value["reason"] is not None:
|
||||
raise ValueError("release status state is invalid")
|
||||
release = value["release"]
|
||||
if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
@@ -223,7 +227,59 @@ def validate_status_response(payload: bytes) -> dict | None:
|
||||
or not isinstance(release["descriptor_sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])):
|
||||
raise ValueError("release status candidate identity is invalid")
|
||||
return {"version": 1, "action": "release", **release}
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None:
|
||||
validate_request(request)
|
||||
if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path):
|
||||
raise ValueError("prepared release state path is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
if path.exists():
|
||||
check_private_acl(path)
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
check_private_acl(temporary)
|
||||
os.replace(temporary, path)
|
||||
check_private_acl(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict:
|
||||
if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file():
|
||||
raise ValueError("prepared release state is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
raw = path.read_bytes()
|
||||
if not raw or len(raw) > MAX_STATUS_BYTES:
|
||||
raise ValueError("prepared release state is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("prepared release state is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)):
|
||||
raise ValueError("prepared release state has an unsupported schema")
|
||||
release = value["release"]
|
||||
if set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("prepared release tuple is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
@@ -255,7 +311,31 @@ def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = "ssh", ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release apply is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()):
|
||||
raise ValueError("automatic release apply configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError("automatic release apply configuration is unreadable") from error
|
||||
if not raw or len(raw) > 1024:
|
||||
raise ValueError("automatic release apply configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("automatic release apply configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool):
|
||||
raise ValueError("automatic release apply configuration is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS:
|
||||
@@ -369,7 +449,7 @@ def receive_backup(reader, writer, *, repository: Path, request: dict, expected_
|
||||
raise
|
||||
|
||||
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
@@ -407,19 +487,61 @@ def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: st
|
||||
raise
|
||||
|
||||
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, ssh: str = "ssh") -> None:
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
if not config["enabled"]:
|
||||
print("Release trigger is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is ready; deployment not started.")
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.")
|
||||
|
||||
|
||||
def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG,
|
||||
auto_apply_file: Path = AUTO_APPLY_CONFIG,
|
||||
prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
auto_config = load_auto_apply_config(auto_apply_file)
|
||||
if not config["enabled"] or not auto_config["enabled"]:
|
||||
print("Automatic apply is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
if _reparse(prepared_file):
|
||||
raise ValueError("prepared release state is unsafe")
|
||||
if not prepared_file.exists():
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
previous = read_prepared_request(prepared_file)
|
||||
if previous != request:
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH, confirm=input) -> None:
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("applying a prepared release requires an interactive owner confirmation")
|
||||
request = read_prepared_request(prepared_file)
|
||||
answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ")
|
||||
if answer != f"APPLY {request['commit']}":
|
||||
print("Prepared release was not applied.")
|
||||
return
|
||||
config = load_trigger_config(config_file)
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
@@ -470,7 +592,9 @@ def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true", help="query the fixed status endpoint and deploy at most one verified release")
|
||||
parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy")
|
||||
parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation")
|
||||
parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled")
|
||||
parser.add_argument("--rollback", action="store_true")
|
||||
parser.add_argument("--data-compatible", action="store_true")
|
||||
parser.add_argument("--run-id")
|
||||
@@ -480,11 +604,21 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.once:
|
||||
if (args.rollback or args.data_compatible or any(
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any(
|
||||
(args.run_id, args.commit, args.descriptor_sha256))
|
||||
or args.ssh_alias != "akyldash_vps"):
|
||||
):
|
||||
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
|
||||
run_release_once()
|
||||
elif args.apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--apply-prepared accepts no release tuple or rollback flags")
|
||||
apply_prepared_release()
|
||||
elif args.auto_apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags")
|
||||
run_auto_apply_once()
|
||||
elif args.rollback:
|
||||
if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)):
|
||||
raise ValueError("rollback requires --data-compatible and accepts no release tuple")
|
||||
|
||||
@@ -227,8 +227,8 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list))
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_status_response_accepts_only_the_fixed_candidate_tuple(self):
|
||||
response = {"schema_version": 1, "state": "candidate", "reason": None,
|
||||
def test_status_response_accepts_only_the_fixed_prepared_tuple(self):
|
||||
response = {"schema_version": 1, "state": "prepared", "reason": None,
|
||||
"release": {"run_id": "302", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}}
|
||||
self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()),
|
||||
@@ -239,6 +239,10 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIsNone(receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}'
|
||||
))
|
||||
with self.assertRaisesRegex(ValueError, "state"):
|
||||
receive_external.validate_status_response(
|
||||
json.dumps({**response, "state": "candidate"}).encode()
|
||||
)
|
||||
|
||||
def test_status_response_rejects_extra_or_duplicate_fields(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
@@ -266,7 +270,7 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIn("ClearAllForwardings=yes", command)
|
||||
self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
|
||||
def test_once_disabled_is_noop_and_candidate_reuses_existing_release_flow(self):
|
||||
def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self):
|
||||
config = {"schema_version": 1, "enabled": False,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY),
|
||||
"ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS}
|
||||
@@ -276,20 +280,78 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
with patch.object(receive_external, "_WINDOWS", True), \
|
||||
patch.object(receive_external, "check_private_acl"), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_release_once(config_file=path)
|
||||
patch.object(receive_external, "run_release") as deploy, \
|
||||
patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"):
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
config["enabled"] = True
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
candidate = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = candidate
|
||||
receive_external.run_release_once(config_file=path)
|
||||
deploy.assert_called_once_with(
|
||||
candidate, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
prepared = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = prepared
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared)
|
||||
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=False):
|
||||
with self.assertRaisesRegex(ValueError, "interactive owner confirmation"):
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
)
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \
|
||||
patch.object(receive_external, "run_release") as apply:
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
confirm=lambda prompt: f"APPLY {'a' * 40}",
|
||||
)
|
||||
apply.assert_called_once_with(
|
||||
prepared, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
|
||||
def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self):
|
||||
previous = {"version": 1, "action": "release", "run_id": "301",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
latest = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "c" * 40, "descriptor_sha256": "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
prepared_file = root / "prepared.json"
|
||||
trigger_file = root / "trigger.json"
|
||||
auto_file = root / "auto.json"
|
||||
with patch.object(receive_external, "check_private_acl"):
|
||||
receive_external.write_prepared_request(previous, prepared_file)
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "query_release_status", return_value=latest) as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
query.assert_called_once()
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(prepared_file), latest)
|
||||
|
||||
query.reset_mock()
|
||||
query.return_value = latest
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
deploy.assert_called_once_with(
|
||||
latest, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release",
|
||||
)
|
||||
|
||||
def test_auto_apply_disabled_does_not_open_ssh(self):
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once()
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
def test_windows_trigger_templates_are_fixed_and_disabled(self):
|
||||
root = Path(__file__).resolve().parents[1] / "deploy"
|
||||
@@ -306,7 +368,16 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege")
|
||||
self.assertEqual(
|
||||
task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'"C:\\Program Files\\Akyldash\\receive_external.py" --once',
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once',
|
||||
)
|
||||
auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(auto_config, {"schema_version": 1, "enabled": False})
|
||||
auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot()
|
||||
self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(
|
||||
auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -146,15 +146,21 @@ def deploy_external(
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
|
||||
raise ValueError("release descriptor digest is invalid")
|
||||
from release_handoff import latest_main_sha
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
from release_source import require_runtime_compatibility
|
||||
|
||||
def verify_release_still_current() -> None:
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT,
|
||||
policy_file=Path("/etc/akyldash/release-policy.json"),
|
||||
)
|
||||
|
||||
verify_release_still_current()
|
||||
if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop:
|
||||
raise ValueError("stopped services do not match the pre-backup image snapshot")
|
||||
if app_images(backup_value, "external backup images") != current_before_stop:
|
||||
raise ValueError("external backup image identities do not match the current deployment")
|
||||
repository_revision = run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
|
||||
if repository_revision != manifest["commit"]:
|
||||
raise ValueError("host checkout does not match the verified release commit")
|
||||
current_files = {
|
||||
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
|
||||
for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
|
||||
@@ -195,6 +201,7 @@ def deploy_external(
|
||||
}
|
||||
journal.update({"status": "external-backup-verified", "target": target})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
verify_release_still_current()
|
||||
replace_images(ENV_FILE, old_env, new_env)
|
||||
journal["status"] = "references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
|
||||
@@ -12,7 +12,7 @@ from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids
|
||||
from external_session import read_request, send_backup_session, validate_request, write_result
|
||||
from release_source import fetch_release
|
||||
from release_source import fetch_release, require_runtime_compatibility
|
||||
from release_state import read_json, validate_state, write_json_atomic
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
@@ -54,15 +54,21 @@ def execute(request: dict, *, reader, writer, policy: dict | None = None, api=No
|
||||
request, policy=policy, current_main=current_main, checkout=ROOT,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
|
||||
with deployment_lock():
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
current = image_ids()
|
||||
current_record = {service: current[service] for service in SERVICES}
|
||||
revision = deploy_release_revision()
|
||||
if revision != manifest["commit"]:
|
||||
raise ValueError("installed host checkout does not match the verified release commit")
|
||||
previous_state = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
previous_state = validate_state(read_json(STATE_FILE))
|
||||
|
||||
@@ -1,50 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only selector for one fully verified current-main release candidate."""
|
||||
"""Read-only preparation endpoint for one fully verified current-main release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import fetch_release, latest_release_request
|
||||
from release_source import fetch_release, latest_release_request, runtime_compatibility_reason
|
||||
from release_state import read_json, validate_state
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
STATUS_MAX_BYTES = 1024
|
||||
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
|
||||
|
||||
|
||||
def checkout_sha(checkout: Path) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(checkout), "rev-parse", "HEAD"],
|
||||
check=True, text=True, capture_output=True, timeout=10,
|
||||
)
|
||||
value = result.stdout.strip()
|
||||
if not SHA_RE.fullmatch(value):
|
||||
raise ValueError("production checkout revision is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE,
|
||||
policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE,
|
||||
main_lookup=latest_main_sha,
|
||||
checkout_lookup=checkout_sha) -> dict:
|
||||
main_lookup=latest_main_sha) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("release status command must run as root")
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
|
||||
current_main = main_lookup(checkout)
|
||||
if checkout_lookup(checkout) != current_main:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "host_checkout_stale", "release": None}
|
||||
|
||||
api_args = {"api": api} if api is not None else {}
|
||||
reason = runtime_compatibility_reason(
|
||||
current_main, checkout=checkout, policy_file=policy_file, **api_args,
|
||||
)
|
||||
if reason is not None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None}
|
||||
request = latest_release_request(current_main=current_main, checkout=checkout, **api_args)
|
||||
if request is None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None}
|
||||
@@ -70,7 +57,7 @@ def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"state": "candidate",
|
||||
"state": "prepared",
|
||||
"reason": None,
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{40}$')]
|
||||
[string]$ExpectedCommit
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$python = 'C:\Python313\python.exe'
|
||||
$git = 'C:\Program Files\Git\cmd\git.exe'
|
||||
$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'
|
||||
$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$appRoot = 'C:\Program Files\Akyldash'
|
||||
$app = Join-Path $appRoot "client-$ExpectedCommit"
|
||||
$programData = 'C:\ProgramData\Akyldash'
|
||||
$trigger = Join-Path $programData 'release-trigger.json'
|
||||
$autoApply = Join-Path $programData 'release-auto-apply.json'
|
||||
$taskTemplate = Join-Path $PSScriptRoot 'windows-release-trigger.example.xml'
|
||||
$repo = (& $git -C $PSScriptRoot rev-parse --show-toplevel).Trim()
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cannot locate the reviewed repository checkout.' }
|
||||
$branch = (& $git -C $repo branch --show-current).Trim()
|
||||
$head = (& $git -C $repo rev-parse HEAD).Trim()
|
||||
$dirty = & $git -C $repo status --porcelain
|
||||
if ($LASTEXITCODE -ne 0 -or $branch -ne 'main' -or $head -ne $ExpectedCommit -or $dirty) {
|
||||
throw 'Install source must be the clean main checkout at ExpectedCommit.'
|
||||
}
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this reviewed bootstrap from an elevated administrator session.'
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $python
|
||||
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Python Software Foundation') {
|
||||
throw 'The pinned Python interpreter does not have the verified Python Software Foundation signature.'
|
||||
}
|
||||
function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" }
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" }
|
||||
if ($AllowOwnerWrite) {
|
||||
$allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or
|
||||
($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {
|
||||
throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path"
|
||||
}
|
||||
}
|
||||
$ownerHasFullControl = @($acl.Access | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq $identity.User.Value -and
|
||||
($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl
|
||||
}).Count -gt 0
|
||||
if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" }
|
||||
} else {
|
||||
$writableUntrusted = @($acl.Access | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
($_.FileSystemRights.ToString() -match 'Write|Modify|FullControl') -and
|
||||
$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18', 'S-1-5-32-544')
|
||||
})
|
||||
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
|
||||
}
|
||||
}
|
||||
function Assert-TrustedExecutable([string]$Path) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Refusing missing or reparse executable: $Path"
|
||||
}
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
$current = $item
|
||||
while ($null -ne $current) {
|
||||
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Executable path contains a reparse point: $($current.FullName)"
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $current.FullName
|
||||
$ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Executable path has an untrusted owner: $($current.FullName)"
|
||||
}
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }
|
||||
if ($rule.AccessControlType -eq 'Allow' -and
|
||||
($rule.FileSystemRights.ToString() -match 'Write|Modify|FullControl') -and
|
||||
$sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Executable path grants write access outside SYSTEM/Administrators/TrustedInstaller: $($current.FullName)"
|
||||
}
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tool in @($git, $ssh, $powershell)) { Assert-TrustedExecutable $tool }
|
||||
|
||||
Assert-ProtectedPath (Split-Path -Parent $python)
|
||||
Assert-ProtectedPath $python
|
||||
if (-not (Test-Path -LiteralPath $appRoot -PathType Container)) { throw 'Protected receiver directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-ProtectedPath $appRoot
|
||||
if (Test-Path -LiteralPath $app) { throw 'Versioned receiver destination already exists; inspect it manually instead of overwriting.' }
|
||||
if (-not (Test-Path -LiteralPath $programData -PathType Container)) { throw 'Protected ProgramData directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-ProtectedPath $programData -AllowOwnerWrite $true
|
||||
if (Test-Path -LiteralPath $trigger) { Assert-ProtectedPath $trigger -AllowOwnerWrite $true }
|
||||
if (Test-Path -LiteralPath $trigger) {
|
||||
$triggerItem = Get-Item -LiteralPath $trigger
|
||||
if ($triggerItem.Length -gt 4096) { throw 'Existing trigger configuration exceeds its size limit.' }
|
||||
$triggerConfig = Get-Content -LiteralPath $trigger -Raw | ConvertFrom-Json
|
||||
$triggerFields = @($triggerConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($triggerConfig.schema_version -ne 1 -or $triggerConfig.enabled -ne $false -or
|
||||
$triggerConfig.repository -ne 'D:\Dev\akyldash' -or $triggerConfig.ssh_alias -ne 'akyldash_release' -or
|
||||
$triggerFields -ne 'enabled,repository,schema_version,ssh_alias') {
|
||||
throw 'Existing trigger configuration is not the fixed disabled configuration.'
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $autoApply) {
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
$autoItem = Get-Item -LiteralPath $autoApply
|
||||
if ($autoItem.Length -gt 1024) { throw 'Existing auto-apply configuration exceeds its size limit.' }
|
||||
$autoConfig = Get-Content -LiteralPath $autoApply -Raw | ConvertFrom-Json
|
||||
$autoFields = @($autoConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($autoConfig.schema_version -ne 1 -or $autoConfig.enabled -ne $false -or
|
||||
$autoFields -ne 'enabled,schema_version') {
|
||||
throw 'Existing auto-apply configuration must be disabled and match its fixed schema.'
|
||||
}
|
||||
}
|
||||
|
||||
$paths = @(
|
||||
@{ Source = (Join-Path $repo 'infra\backup\receive_external.py'); Name = 'receive_external.py' },
|
||||
@{ Source = (Join-Path $repo 'infra\backup\archive.py'); Name = 'archive.py' },
|
||||
@{ Source = (Join-Path $repo 'infra\backup\external_session.py'); Name = 'external_session.py' },
|
||||
@{ Source = (Join-Path $repo 'infra\deploy\windows_release_launcher.py'); Name = 'windows_release_launcher.py' }
|
||||
)
|
||||
foreach ($entry in $paths) {
|
||||
if (-not (Test-Path -LiteralPath $entry.Source -PathType Leaf)) { throw "Bootstrap source is missing: $($entry.Name)" }
|
||||
}
|
||||
$null = New-Item -ItemType Directory -Path $app
|
||||
Assert-ProtectedPath $app
|
||||
foreach ($entry in $paths) {
|
||||
Copy-Item -LiteralPath $entry.Source -Destination (Join-Path $app $entry.Name)
|
||||
Assert-ProtectedPath (Join-Path $app $entry.Name)
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $trigger)) {
|
||||
$config = '{"schema_version":1,"enabled":false,"repository":"D:\\Dev\\akyldash","ssh_alias":"akyldash_release"}'
|
||||
Set-Content -LiteralPath $trigger -Value $config -Encoding Ascii -NoNewline
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $autoApply)) {
|
||||
Set-Content -LiteralPath $autoApply -Value '{"schema_version":1,"enabled":false}' -Encoding Ascii -NoNewline
|
||||
}
|
||||
Assert-ProtectedPath $trigger -AllowOwnerWrite $true
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
|
||||
$template = [xml](Get-Content -LiteralPath $taskTemplate -Raw)
|
||||
$ns = [System.Xml.XmlNamespaceManager]::new($template.NameTable)
|
||||
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$sid = $identity.User.Value
|
||||
$template.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $ns).InnerText = $sid
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $ns).InnerText = $python
|
||||
$launcher = Join-Path $app 'windows_release_launcher.py'
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $ns).InnerText = "-B -E -s -S `"$launcher`" --once"
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $ns).InnerText = $app
|
||||
$template.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $ns).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$templatePath = Join-Path $programData 'Akyldash-ReleaseTrigger-disabled.xml'
|
||||
$template.Save($templatePath)
|
||||
$applyTemplate = [xml](Get-Content -LiteralPath $taskTemplate -Raw)
|
||||
$applyNs = [System.Xml.XmlNamespaceManager]::new($applyTemplate.NameTable)
|
||||
$applyNs.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI', $applyNs).InnerText = '\Akyldash\ReleaseAutoApply'
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $applyNs).InnerText = $sid
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $applyNs).InnerText = $python
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $applyNs).InnerText = "-B -E -s -S `"$launcher`" --auto-apply-prepared"
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $applyNs).InnerText = $app
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $applyNs).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$applyTemplate.Save((Join-Path $programData 'Akyldash-ReleaseAutoApply-disabled.xml'))
|
||||
Write-Output "Prepared disabled receiver files and task template for reviewed main commit $ExpectedCommit."
|
||||
Write-Output 'No task was registered or enabled; both configuration gates remain disabled.'
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -26,6 +27,12 @@ MAX_API_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_API_DOWNLOAD_BYTES = 10 * 1024 * 1024
|
||||
MAX_CANDIDATE_RELEASE_RUNS = 10
|
||||
API_TIMEOUT_SECONDS = 15
|
||||
RUNTIME_FILES = (
|
||||
"deploy/production/compose.yaml",
|
||||
"deploy/production/Caddyfile",
|
||||
"infra/deploy/release-policy.json",
|
||||
)
|
||||
MAX_RUNTIME_FILE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def run_api_helper(command: list[str], *, env: dict | None, timeout: int,
|
||||
@@ -85,6 +92,58 @@ def api_json(path: str, *, helper: str = "gitea-api", env: dict | None = None) -
|
||||
return value
|
||||
|
||||
|
||||
def repository_file(path: str, commit: str, *, api=api_json) -> bytes:
|
||||
"""Fetch one small, pinned repository file and verify Gitea's git blob identity."""
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit) or path not in RUNTIME_FILES:
|
||||
raise ValueError("runtime file request is not allowlisted or commit-pinned")
|
||||
query = urlencode({"ref": commit})
|
||||
value = api(f"/repos/{REPOSITORY}/contents/{path}?{query}")
|
||||
if (value.get("type") != "file" or value.get("path") != path
|
||||
or value.get("encoding") != "base64"):
|
||||
raise ValueError("Gitea runtime file metadata is invalid")
|
||||
encoded, blob_sha, size = value.get("content"), value.get("sha"), value.get("size")
|
||||
if (not isinstance(encoded, str) or not isinstance(blob_sha, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", blob_sha)
|
||||
or isinstance(size, bool) or not isinstance(size, int)
|
||||
or size < 0 or size > MAX_RUNTIME_FILE_BYTES):
|
||||
raise ValueError("Gitea runtime file exceeds its limit or has invalid metadata")
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, base64.binascii.Error) as error:
|
||||
raise ValueError("Gitea runtime file content is invalid base64") from error
|
||||
git_blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode("ascii") + b"\0" + content).hexdigest()
|
||||
if len(content) != size or git_blob_sha != blob_sha:
|
||||
raise ValueError("Gitea runtime file content does not match its pinned git blob")
|
||||
return content
|
||||
|
||||
|
||||
def runtime_compatibility_reason(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> str | None:
|
||||
"""Require the bootstrap host's fixed runtime/configuration to match target source."""
|
||||
for relative in RUNTIME_FILES:
|
||||
local_path = policy_file if relative == "infra/deploy/release-policy.json" else checkout / relative
|
||||
try:
|
||||
info = local_path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError(f"protected runtime file is not a regular file: {relative}")
|
||||
local_content = local_path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError(f"protected runtime file is unavailable: {relative}") from error
|
||||
remote_content = repository_file(relative, commit, api=api)
|
||||
if local_content != remote_content:
|
||||
return "release_policy_changed" if relative.endswith("release-policy.json") else "runtime_config_changed"
|
||||
return None
|
||||
|
||||
|
||||
def require_runtime_compatibility(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> None:
|
||||
reason = runtime_compatibility_reason(
|
||||
commit, checkout=checkout, policy_file=policy_file, api=api,
|
||||
)
|
||||
if reason is not None:
|
||||
raise ValueError(f"host {reason.replace('_', ' ')}; reviewed bootstrap update is required")
|
||||
|
||||
|
||||
def latest_release_request(*, current_main: str, api=api_json,
|
||||
checkout: Path | None = None) -> dict | None:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", current_main):
|
||||
|
||||
@@ -87,6 +87,59 @@ class DeploymentCutoverTests(unittest.TestCase):
|
||||
run_mock.assert_not_called()
|
||||
compose_mock.assert_not_called()
|
||||
|
||||
def test_main_advance_after_backup_aborts_before_image_reference_switch(self):
|
||||
commit = "b" * 40
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "deploy/production").mkdir(parents=True)
|
||||
for name, payload in (("compose.yaml", b"compose\n"), ("Caddyfile", b"caddy\n")):
|
||||
(root / "deploy/production" / name).write_bytes(payload)
|
||||
from hashlib import sha256
|
||||
deployment_files = {
|
||||
f"deploy/production/{name}": sha256((root / "deploy/production" / name).read_bytes()).hexdigest()
|
||||
for name in ("compose.yaml", "Caddyfile")
|
||||
}
|
||||
backup = {"images": self.current, "deployment_files": deployment_files}
|
||||
backup_identity = {
|
||||
"backup_id": "20261103T000000Z-" + "a" * 32,
|
||||
"size": 100, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": "d" * 64, "manifest": backup,
|
||||
}
|
||||
manifest = {
|
||||
"commit": commit,
|
||||
"builder": {"name": "Kaniko", "image": deploy_release.BUILDER_IMAGE},
|
||||
"images": {service: f"registry/{service}@sha256:{'e' * 64}" for service in ("backend", "opensearch")},
|
||||
}
|
||||
journal = {"status": "external-backup-verified"}
|
||||
with (
|
||||
patch.object(deploy_release.os, "geteuid", return_value=0),
|
||||
patch.object(deploy_release, "ROOT", root),
|
||||
patch.object(deploy_release, "STATE_FILE", root / "state.json"),
|
||||
patch.object(deploy_release, "JOURNAL_FILE", root / "journal.json"),
|
||||
patch.object(deploy_release, "validate_manifest", return_value=manifest),
|
||||
patch("release_state.validate_backup_identity", return_value=backup_identity),
|
||||
patch("release_handoff.latest_main_sha", side_effect=(commit, "f" * 40)),
|
||||
patch("release_source.require_runtime_compatibility"),
|
||||
patch.object(deploy_release, "image_ids", return_value=self.current),
|
||||
patch.object(deploy_release, "app_images", return_value=self.current),
|
||||
patch.object(deploy_release, "read_json", return_value=journal),
|
||||
patch.object(deploy_release, "write_json_atomic"),
|
||||
patch.object(deploy_release, "run", side_effect=(
|
||||
"", "sha256:" + "1" * 64, "", "sha256:" + "2" * 64,
|
||||
"sha256:" + "1" * 64, "sha256:" + "2" * 64,
|
||||
)),
|
||||
patch.object(deploy_release, "replace_images") as replace,
|
||||
patch.object(deploy_release, "restore_previous_references"),
|
||||
patch.object(deploy_release, "compose"),
|
||||
patch.object(deploy_release, "write_deployed_backend_image"),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "cutover failed"):
|
||||
deploy_release.deploy_external(
|
||||
manifest, {}, backup, backup_identity, "9" * 64,
|
||||
self.current, "ru", "ky",
|
||||
)
|
||||
replace.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,7 +28,7 @@ def release_state(commit: str, descriptor: str) -> dict:
|
||||
|
||||
|
||||
class ExternalDeployTests(unittest.TestCase):
|
||||
def run_execute(self, root: Path, state: dict, *, compose_mock=None):
|
||||
def run_execute(self, root: Path, state: dict, *, compose_mock=None, compatibility_error=None):
|
||||
compose_mock = compose_mock or Mock()
|
||||
state_file = root / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
@@ -43,6 +43,7 @@ class ExternalDeployTests(unittest.TestCase):
|
||||
patch.object(external_deploy, "JOURNAL_FILE", journal),
|
||||
patch.object(external_deploy, "STATE_FILE", state_file),
|
||||
patch.object(external_deploy, "fetch_release", return_value=({"commit": COMMIT}, b"")),
|
||||
patch.object(external_deploy, "require_runtime_compatibility", side_effect=compatibility_error),
|
||||
patch.object(external_deploy, "deployment_lock", return_value=nullcontext()),
|
||||
patch.object(external_deploy, "image_ids", return_value=CURRENT),
|
||||
patch.object(external_deploy, "deploy_release_revision", return_value=COMMIT),
|
||||
@@ -73,6 +74,30 @@ class ExternalDeployTests(unittest.TestCase):
|
||||
self.assertEqual(journal["status"], "external-backup-pending")
|
||||
self.assertEqual(journal["commit"], COMMIT)
|
||||
|
||||
def test_runtime_mismatch_fails_before_journal_or_production_operations(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "runtime config mismatch"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=ValueError("runtime config mismatch"),
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_runtime_change_after_initial_check_fails_inside_lock_before_stop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "changed after status"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=[None, ValueError("runtime changed after status")],
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,7 +8,7 @@ import external_status
|
||||
|
||||
|
||||
class ExternalStatusTests(unittest.TestCase):
|
||||
def test_status_is_candidate_only_after_release_descriptor_verification(self):
|
||||
def test_status_is_prepared_only_after_release_descriptor_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -19,15 +19,19 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "latest_main_sha", return_value=commit), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None) as compatible, \
|
||||
patch.object(external_status, "latest_release_request", return_value=request) as select, \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
value = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
checkout_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(value["state"], "candidate")
|
||||
self.assertEqual(value["state"], "prepared")
|
||||
self.assertEqual(value["release"], request)
|
||||
compatible.assert_called_once()
|
||||
self.assertEqual(compatible.call_args.args, (commit,))
|
||||
self.assertEqual(compatible.call_args.kwargs["checkout"], root)
|
||||
self.assertEqual(compatible.call_args.kwargs["policy_file"], policy)
|
||||
select.assert_called_once_with(current_main=commit, checkout=root, api=ANY)
|
||||
verify.assert_called_once()
|
||||
|
||||
@@ -46,17 +50,38 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
"current": images, "previous": images,
|
||||
}), encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release") as verify:
|
||||
result = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
checkout_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(result["reason"], "already_applied")
|
||||
verify.assert_not_called()
|
||||
|
||||
def test_status_fails_closed_for_root_guard_recovery_and_stale_checkout(self):
|
||||
def test_status_does_not_publish_prepared_tuple_if_main_advances_during_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
policy = root / "policy.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
mains = iter((commit, "c" * 40))
|
||||
result = external_status.status_document(
|
||||
checkout=root, journal=root / "journal", policy_file=policy,
|
||||
state_file=root / "state.json", main_lookup=lambda _path: next(mains),
|
||||
)
|
||||
self.assertEqual(result, {
|
||||
"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None,
|
||||
})
|
||||
verify.assert_called_once()
|
||||
|
||||
def test_status_fails_closed_for_root_guard_recovery_and_runtime_mismatch(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
journal = root / "recovery.json"
|
||||
@@ -70,11 +95,15 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
value = external_status.status_document(checkout=root, journal=journal, policy_file=policy)
|
||||
self.assertEqual(value["reason"], "recovery_journal")
|
||||
journal.unlink()
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value="runtime_config_changed"), \
|
||||
patch.object(external_status, "latest_release_request") as select:
|
||||
value = external_status.status_document(
|
||||
checkout=root, journal=journal, policy_file=policy,
|
||||
main_lookup=lambda _path: "a" * 40, checkout_lookup=lambda _path: "c" * 40,
|
||||
main_lookup=lambda _path: "a" * 40,
|
||||
)
|
||||
self.assertEqual(value["reason"], "host_checkout_stale")
|
||||
self.assertEqual(value["reason"], "runtime_config_changed")
|
||||
select.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
@@ -49,6 +50,84 @@ def write_artifact(path, manifest=MANIFEST, extra=()):
|
||||
|
||||
|
||||
class ReleaseSourceTests(unittest.TestCase):
|
||||
def test_runtime_files_allow_bootstrap_a_to_release_b_only_when_configs_match(self):
|
||||
commit_b = "f" * 40
|
||||
contents = {
|
||||
"deploy/production/compose.yaml": b"compose-v1\n",
|
||||
"deploy/production/Caddyfile": b"caddy-v1\n",
|
||||
"infra/deploy/release-policy.json": b"policy-v1\n",
|
||||
}
|
||||
|
||||
def response(path):
|
||||
payload = contents[path]
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(payload)).encode() + b"\0" + payload).hexdigest()
|
||||
return {"type": "file", "path": path, "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": blob_sha}
|
||||
|
||||
def api(request):
|
||||
self.assertIn(f"?ref={commit_b}", request)
|
||||
path = request.split("/contents/", 1)[1].split("?", 1)[0]
|
||||
return response(path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
(checkout / "deploy/production").mkdir(parents=True)
|
||||
(checkout / "deploy/production/compose.yaml").write_bytes(contents["deploy/production/compose.yaml"])
|
||||
(checkout / "deploy/production/Caddyfile").write_bytes(contents["deploy/production/Caddyfile"])
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(contents["infra/deploy/release-policy.json"])
|
||||
self.assertIsNone(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
))
|
||||
|
||||
for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile"):
|
||||
local_path = checkout / relative
|
||||
expected = contents[relative]
|
||||
local_path.write_bytes(b"changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "runtime_config_changed")
|
||||
local_path.write_bytes(expected)
|
||||
policy.write_bytes(b"policy-changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "release_policy_changed")
|
||||
|
||||
def test_runtime_file_rejects_forged_git_blob_identity(self):
|
||||
payload = b"trusted content\n"
|
||||
response = {"type": "file", "path": "deploy/production/compose.yaml", "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": "a" * 40}
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
release_source.repository_file(
|
||||
"deploy/production/compose.yaml", COMMIT,
|
||||
api=lambda _path: response,
|
||||
)
|
||||
|
||||
def test_runtime_compatibility_rejects_local_symlink_even_when_bytes_match(self):
|
||||
content = b"protected\n"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
config_dir = checkout / "deploy/production"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "compose.yaml").write_bytes(content)
|
||||
(config_dir / "Caddyfile").write_bytes(content)
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(content)
|
||||
external = checkout / "outside.yaml"
|
||||
external.write_bytes(content)
|
||||
(config_dir / "Caddyfile").unlink()
|
||||
(config_dir / "Caddyfile").symlink_to(external)
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError, "not a regular file"):
|
||||
release_source.runtime_compatibility_reason(
|
||||
COMMIT, checkout=checkout, policy_file=policy,
|
||||
api=lambda _path: {
|
||||
"type": "file", "path": "deploy/production/compose.yaml",
|
||||
"encoding": "base64", "content": base64.b64encode(content).decode(),
|
||||
"size": len(content), "sha": blob_sha,
|
||||
},
|
||||
)
|
||||
|
||||
def test_verify_run_binds_run_id_workflow_path_event_branch_commit_and_main(self):
|
||||
def api(path):
|
||||
if path.endswith("/actions/runs/301"):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class WindowsReleaseBootstrapTests(unittest.TestCase):
|
||||
def test_windows_tool_environment_discards_path_and_git_overrides(self):
|
||||
environment = {
|
||||
"PATH": r"C:\Users\attacker\bin",
|
||||
"PATHEXT": ".COM;.EXE;.BAT",
|
||||
"PSModulePath": r"C:\Users\attacker\modules",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "core.sshCommand",
|
||||
"GIT_CONFIG_VALUE_0": "attacker.exe",
|
||||
"SAFE_SETTING": "preserved",
|
||||
}
|
||||
calls = []
|
||||
|
||||
def which(command):
|
||||
calls.append(command)
|
||||
return str(WINDOWS_TOOLS[command])
|
||||
|
||||
configure_windows_tool_environment(environ=environment, which=which)
|
||||
self.assertEqual(environment["PATH"], WINDOWS_PATH)
|
||||
self.assertEqual(environment["PATHEXT"], ".EXE")
|
||||
self.assertEqual(environment["SystemRoot"], r"C:\Windows")
|
||||
self.assertEqual(environment["PSModulePath"], r"C:\Windows\System32\WindowsPowerShell\v1.0\Modules")
|
||||
self.assertEqual(environment["SAFE_SETTING"], "preserved")
|
||||
self.assertFalse(any(key.upper().startswith("GIT_") for key in environment))
|
||||
self.assertEqual(set(calls), set(WINDOWS_TOOLS))
|
||||
|
||||
def test_windows_tool_environment_rejects_unexpected_resolution(self):
|
||||
environment = {"PATH": r"C:\attacker"}
|
||||
with self.assertRaisesRegex(RuntimeError, "protected path"):
|
||||
configure_windows_tool_environment(environ=environment, which=lambda _command: r"C:\attacker\ssh.exe")
|
||||
|
||||
def test_launcher_ignores_poisoned_working_directory_and_pythonpath(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
app = root / "Program Files" / "Akyldash"
|
||||
poison = root / "poison"
|
||||
app.mkdir(parents=True)
|
||||
poison.mkdir()
|
||||
launcher = app / "windows_release_launcher.py"
|
||||
launcher.write_bytes((ROOT / "infra/deploy/windows_release_launcher.py").read_bytes())
|
||||
(app / "receive_external.py").write_text("def main(): return 0\n", encoding="utf-8")
|
||||
(app / "archive.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
(app / "external_session.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
marker = root / "sitecustomize-ran"
|
||||
(poison / "json.py").write_text("raise RuntimeError('poison json imported')\n", encoding="utf-8")
|
||||
(poison / "sitecustomize.py").write_text(
|
||||
"import os\nfrom pathlib import Path\nPath(os.environ['POISON_MARKER']).touch()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
driver = app / "driver.py"
|
||||
driver.write_text(
|
||||
"import sys\n"
|
||||
"from windows_release_launcher import configure_import_path\n"
|
||||
"configure_import_path(executable=sys.executable, app_dir=__import__('pathlib').Path(__file__).parent, "
|
||||
"python_root=__import__('pathlib').Path(sys.prefix), expected_executable=__import__('pathlib').Path(sys.executable), "
|
||||
"protected_app_root=__import__('pathlib').Path(__file__).parents[1])\n"
|
||||
"import json, receive_external, archive, external_session\n"
|
||||
"from pathlib import Path\n"
|
||||
"assert Path(json.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())\n"
|
||||
"assert Path(receive_external.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(archive.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(external_session.__file__).resolve().parent == Path(__file__).parent.resolve()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = {**os.environ, "PYTHONPATH": str(poison), "POISON_MARKER": str(marker)}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-E", "-s", "-S", str(driver)], cwd=poison,
|
||||
env=environment, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_disabled_task_uses_pinned_python_and_hardened_launcher(self):
|
||||
path = ROOT / "infra/deploy/windows-release-trigger.example.xml"
|
||||
tree = ET.parse(path)
|
||||
namespace = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
task = tree.getroot()
|
||||
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=namespace), "false")
|
||||
trigger = task.find("t:Triggers/t:CalendarTrigger", namespace)
|
||||
self.assertEqual(trigger.findtext("t:Enabled", namespaces=namespace), "false")
|
||||
self.assertEqual(task.findtext("t:Actions/t:Exec/t:Command", namespaces=namespace), r"C:\Python313\python.exe")
|
||||
arguments = task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=namespace)
|
||||
self.assertIn("-B -E -s -S", arguments)
|
||||
self.assertIn("windows_release_launcher.py", arguments)
|
||||
|
||||
def test_installer_never_registers_or_enables_task_and_uses_canonical_trigger(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("release-trigger.json", script)
|
||||
self.assertIn("release-auto-apply.json", script)
|
||||
self.assertIn('"enabled":false', script)
|
||||
self.assertIn("C:\\Python313\\python.exe", script)
|
||||
self.assertIn("Get-AuthenticodeSignature", script)
|
||||
self.assertIn(".Status -ne 'Valid'", script)
|
||||
self.assertIn("client-$ExpectedCommit", script)
|
||||
self.assertIn("--auto-apply-prepared", script)
|
||||
self.assertIn("Akyldash-ReleaseAutoApply-disabled.xml", script)
|
||||
self.assertIn("Assert-TrustedExecutable $tool", script)
|
||||
self.assertIn(r"$git = 'C:\Program Files\Git\cmd\git.exe'", script)
|
||||
self.assertIn(r"$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'", script)
|
||||
self.assertIn(r"$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'", script)
|
||||
self.assertIn("$current = $current.Parent", script)
|
||||
self.assertIn("PropagationFlags]::InheritOnly", script)
|
||||
self.assertNotIn("Register-ScheduledTask", script)
|
||||
self.assertNotIn("Enable-ScheduledTask", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"enabled": false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation.</Description>
|
||||
<URI>\Akyldash\ReleaseAutoApply</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
<Interval>PT5M</Interval>
|
||||
<Duration>P1D</Duration>
|
||||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||||
</Repetition>
|
||||
</CalendarTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Owner">
|
||||
<UserId>REPLACE_WITH_OWNER_SID</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<Enabled>false</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --auto-apply-prepared</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
@@ -6,7 +6,7 @@
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2026-11-02T09:00:00</StartBoundary>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
@@ -37,8 +37,8 @@
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Program Files\Akyldash\python.exe</Command>
|
||||
<Arguments>"C:\Program Files\Akyldash\receive_external.py" --once</Arguments>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --once</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Harden the fixed Windows Python process before loading the release receiver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import ntpath
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PYTHON_EXE = Path(r"C:\Python313\python.exe")
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
WINDOWS_TOOLS = {
|
||||
"ssh.exe": Path(r"C:\Windows\System32\OpenSSH\ssh.exe"),
|
||||
"git.exe": Path(r"C:\Program Files\Git\cmd\git.exe"),
|
||||
"powershell.exe": Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
|
||||
}
|
||||
WINDOWS_PATH = os.pathsep.join((
|
||||
r"C:\Windows\System32\OpenSSH",
|
||||
r"C:\Program Files\Git\cmd",
|
||||
r"C:\Windows\System32",
|
||||
r"C:\Windows",
|
||||
))
|
||||
|
||||
|
||||
def configure_windows_tool_environment(*, environ=None, which=shutil.which,
|
||||
windows_root: Path = Path(r"C:\Windows")) -> None:
|
||||
"""Remove inherited command lookup and Git overrides before receiver imports."""
|
||||
environment = os.environ if environ is None else environ
|
||||
system_root = str(windows_root)
|
||||
environment["SystemRoot"] = system_root
|
||||
environment["PATH"] = WINDOWS_PATH
|
||||
environment["PATHEXT"] = ".EXE"
|
||||
environment["PSModulePath"] = ntpath.join(
|
||||
str(windows_root), "System32", "WindowsPowerShell", "v1.0", "Modules")
|
||||
for name in tuple(environment):
|
||||
if name.upper().startswith("GIT_"):
|
||||
del environment[name]
|
||||
for command, expected in WINDOWS_TOOLS.items():
|
||||
resolved = which(command)
|
||||
if resolved is None or os.path.normcase(os.path.abspath(resolved)) != os.path.normcase(os.path.abspath(expected)):
|
||||
raise RuntimeError(f"required Windows tool is missing from its protected path: {command}")
|
||||
|
||||
|
||||
def _is_beneath(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def configure_import_path(*, executable: Path, app_dir: Path, python_root: Path,
|
||||
path_entries: list[str] | None = None,
|
||||
expected_executable: Path = PYTHON_EXE,
|
||||
protected_app_root: Path = Path(r"C:\Program Files")) -> None:
|
||||
"""Keep only stdlib paths and the protected app directory; discard CWD/env paths."""
|
||||
executable = Path(executable).resolve()
|
||||
app_dir = Path(app_dir).resolve()
|
||||
python_root = Path(python_root).resolve()
|
||||
if os.path.normcase(str(executable)) != os.path.normcase(str(expected_executable.resolve())):
|
||||
raise RuntimeError("release trigger must use the verified Python 3.13 installation")
|
||||
if not app_dir.is_dir() or not _is_beneath(app_dir, protected_app_root):
|
||||
raise RuntimeError("release receiver directory is outside protected Program Files")
|
||||
if not python_root.is_dir() or not _is_beneath(executable, python_root):
|
||||
raise RuntimeError("Python executable is outside its protected installation")
|
||||
entries = sys.path if path_entries is None else path_entries
|
||||
safe_stdlib = []
|
||||
for value in entries:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value)
|
||||
if _is_beneath(candidate, python_root):
|
||||
safe_stdlib.append(str(candidate))
|
||||
if not safe_stdlib:
|
||||
raise RuntimeError("Python standard library path is unavailable")
|
||||
sys.path[:] = safe_stdlib + [str(app_dir)]
|
||||
|
||||
|
||||
def _verify_module_origin(module, root: Path) -> None:
|
||||
origin = getattr(module, "__file__", None)
|
||||
if not isinstance(origin, str) or not _is_beneath(Path(origin), root):
|
||||
raise RuntimeError(f"release module was loaded outside its protected directory: {module.__name__}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flags = sys.flags
|
||||
if not (flags.ignore_environment and flags.no_user_site and flags.no_site):
|
||||
raise RuntimeError("launch Python with -E -s -S to disable environment and site imports")
|
||||
configure_import_path(executable=Path(sys.executable), app_dir=APP_DIR,
|
||||
python_root=PYTHON_EXE.parent)
|
||||
if os.name == "nt":
|
||||
configure_windows_tool_environment()
|
||||
for name in ("json", "ssl", "subprocess", "pathlib"):
|
||||
module = importlib.import_module(name)
|
||||
_verify_module_origin(module, PYTHON_EXE.parent)
|
||||
receiver = importlib.import_module("receive_external")
|
||||
_verify_module_origin(receiver, APP_DIR)
|
||||
for name in ("archive", "external_session"):
|
||||
_verify_module_origin(importlib.import_module(name), APP_DIR)
|
||||
return receiver.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user