Compare commits
1 Commits
f8f01c4b2e
...
d576a74b96
| Author | SHA1 | Date | |
|---|---|---|---|
| d576a74b96 |
|
|
@ -16,7 +16,7 @@ Environment=TIMMY_BASE_PATH=/timmy-staging
|
||||||
Environment=TIMMY_AGENT_ENABLED=false
|
Environment=TIMMY_AGENT_ENABLED=false
|
||||||
Environment=TIMMY_VISION_ENABLED=false
|
Environment=TIMMY_VISION_ENABLED=false
|
||||||
EnvironmentFile=/etc/timmy-staging.env
|
EnvironmentFile=/etc/timmy-staging.env
|
||||||
ExecStart=/usr/bin/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false /usr/local/lib/timmy-staging/node server.mjs
|
ExecStart=/usr/bin/node server.mjs
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=30s
|
TimeoutStartSec=30s
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ No DNS change is required for the approved subpage deployment. The private URL i
|
||||||
These commands are reference commands for an approved maintenance window; they have not been run by this change:
|
These commands are reference commands for an approved maintenance window; they have not been run by this change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_SOURCE="$(readlink -f "$(command -v node)")"
|
|
||||||
"$NODE_SOURCE" --version # must report v22.x before installation
|
|
||||||
sudo install -D -o root -g root -m 755 "$NODE_SOURCE" /usr/local/lib/timmy-staging/node
|
|
||||||
/usr/local/lib/timmy-staging/node --version
|
|
||||||
sudo useradd --system --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin timmy-staging
|
sudo useradd --system --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin timmy-staging
|
||||||
sudo install -d -o root -g root -m 755 /opt/timmy-staging/releases
|
sudo install -d -o root -g root -m 755 /opt/timmy-staging/releases
|
||||||
sudo install -d -o timmy-staging -g timmy-staging -m 700 /var/lib/timmy-staging
|
sudo install -d -o timmy-staging -g timmy-staging -m 700 /var/lib/timmy-staging
|
||||||
|
|
@ -34,7 +30,7 @@ sudo chmod 600 /etc/timmy-staging.env
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit only `TIMMY_RELEASE_TAG` and `TIMMY_RELEASE_COMMIT` for the selected artifact. The unit deliberately applies `TIMMY_AGENT_ENABLED=false` and `TIMMY_VISION_ENABLED=false` on the `ExecStart` command after loading the environment file, so values in that file cannot enable either subsystem. Leave the loopback host, port, and base path unchanged. The environment file must stay root-owned mode 600 and absent from release archives. The copied Node 22 executable is root-owned at `/usr/local/lib/timmy-staging/node`, outside every home directory, so `ProtectHome=true` remains enforceable; replace it only through a separately reviewed Node upgrade.
|
Edit only `TIMMY_RELEASE_TAG` and `TIMMY_RELEASE_COMMIT` for the selected artifact. Leave `HOST=127.0.0.1`, `PORT=4174`, `TIMMY_BASE_PATH=/timmy-staging`, `TIMMY_AGENT_ENABLED=false`, and `TIMMY_VISION_ENABLED=false`. The environment file must stay root-owned mode 600 and absent from release archives.
|
||||||
|
|
||||||
Generate Caddy basic-auth material interactively (for example, `caddy hash-password`) and inject it as `TIMMY_STAGING_PASSWORD_HASH`; do not commit the output. Merge the staging handle before the existing catchall, validate a temporary complete config, then use `caddy reload` rather than restarting unrelated services.
|
Generate Caddy basic-auth material interactively (for example, `caddy hash-password`) and inject it as `TIMMY_STAGING_PASSWORD_HASH`; do not commit the output. Merge the staging handle before the existing catchall, validate a temporary complete config, then use `caddy reload` rather than restarting unrelated services.
|
||||||
|
|
||||||
|
|
@ -48,7 +44,7 @@ python3 scripts/deploy_staging.py --dry-run promote \
|
||||||
--sha256 64_LOWERCASE_HEX_CHARACTERS --commit 40_LOWERCASE_HEX_CHARACTERS
|
--sha256 64_LOWERCASE_HEX_CHARACTERS --commit 40_LOWERCASE_HEX_CHARACTERS
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run the same command without `--dry-run`. Promotion opens only a non-symlink regular source, copies it while hashing into a mode-400 file in a private temporary directory, and inspects and extracts only that verified copy before cleaning it. It rejects unsafe members and forbidden artifacts, extracts once to `/opt/timmy-staging/releases/<commit>`, and never overwrites that directory. It atomically swaps `current`, restarts only `timmy-staging.service`, checks bounded health, and invokes `npm run test:staging-smoke` as an argv array with `shell=False`. Restart, health, or smoke failure automatically restores and verifies the prior symlink. If there is no prior release, the tool removes `current` and stops the service instead of restarting it against a missing path; the failed release remains unreferenced as inert immutable evidence for operator review.
|
Then run the same command without `--dry-run`. Promotion verifies the expected SHA-256 before opening the tar, rejects unsafe members and forbidden artifacts, extracts once to `/opt/timmy-staging/releases/<commit>`, and never overwrites that directory. It atomically swaps `current`, restarts only `timmy-staging.service`, checks bounded health, and invokes `npm run test:staging-smoke` as an argv array with `shell=False`. Restart, health, or smoke failure automatically restores and verifies the prior symlink.
|
||||||
|
|
||||||
The operator must update `/etc/timmy-staging.env` release identity to the same reviewed tag and commit before promotion. A narrow wrapper/sudo policy may set the smoke environment without granting arbitrary command execution:
|
The operator must update `/etc/timmy-staging.env` release identity to the same reviewed tag and commit before promotion. A narrow wrapper/sudo policy may set the smoke environment without granting arbitrary command execution:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
|
@ -12,11 +11,9 @@ from pathlib import Path, PurePosixPath
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Sequence
|
from typing import Callable, Sequence
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
@ -38,7 +35,6 @@ class DeploymentError(RuntimeError):
|
||||||
class DeploymentConfig:
|
class DeploymentConfig:
|
||||||
root: Path
|
root: Path
|
||||||
restart_command: tuple[str, ...]
|
restart_command: tuple[str, ...]
|
||||||
stop_command: tuple[str, ...]
|
|
||||||
smoke_command: tuple[str, ...]
|
smoke_command: tuple[str, ...]
|
||||||
health_url: str
|
health_url: str
|
||||||
command_timeout: float = 180.0
|
command_timeout: float = 180.0
|
||||||
|
|
@ -69,37 +65,15 @@ def _validate_identity(tag: str, commit: str, expected_sha256: str) -> None:
|
||||||
raise DeploymentError("SHA-256 must be exactly 64 lowercase hexadecimal characters")
|
raise DeploymentError("SHA-256 must be exactly 64 lowercase hexadecimal characters")
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
def sha256_file(path: Path) -> str:
|
||||||
def _verified_archive_copy(source_path: Path, expected_sha256: str):
|
digest = hashlib.sha256()
|
||||||
"""Copy and hash an untrusted regular archive once, then yield the private copy."""
|
|
||||||
source_fd = -1
|
|
||||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
||||||
try:
|
try:
|
||||||
source_fd = os.open(source_path, flags)
|
with path.open("rb") as handle:
|
||||||
if not stat.S_ISREG(os.fstat(source_fd).st_mode):
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
raise DeploymentError("archive source must be a regular file, not a symlink or special file")
|
digest.update(chunk)
|
||||||
with tempfile.TemporaryDirectory(prefix="timmy-verified-archive-") as private_dir:
|
|
||||||
private_path = Path(private_dir) / "archive"
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with os.fdopen(source_fd, "rb", closefd=True) as source:
|
|
||||||
source_fd = -1
|
|
||||||
with private_path.open("xb") as destination:
|
|
||||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
destination.write(chunk)
|
|
||||||
destination.flush()
|
|
||||||
os.fsync(destination.fileno())
|
|
||||||
private_path.chmod(0o400)
|
|
||||||
if digest.hexdigest() != expected_sha256:
|
|
||||||
raise DeploymentError("archive SHA-256 mismatch; refusing extraction")
|
|
||||||
yield private_path
|
|
||||||
except DeploymentError:
|
|
||||||
raise
|
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise DeploymentError(f"cannot stage archive: {error.strerror or 'I/O error'}") from error
|
raise DeploymentError(f"cannot read archive: {error.strerror or 'I/O error'}") from error
|
||||||
finally:
|
return digest.hexdigest()
|
||||||
if source_fd >= 0:
|
|
||||||
os.close(source_fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_parts(name: str) -> tuple[str, ...]:
|
def _safe_parts(name: str) -> tuple[str, ...]:
|
||||||
|
|
@ -146,8 +120,6 @@ def inspect_archive(archive_path: Path, config: DeploymentConfig) -> tuple[list[
|
||||||
if total > config.max_total_bytes:
|
if total > config.max_total_bytes:
|
||||||
raise DeploymentError("archive expanded size exceeds configured limit")
|
raise DeploymentError("archive expanded size exceeds configured limit")
|
||||||
common_root = all_parts[0][0] if all(parts[0] == all_parts[0][0] for parts in all_parts) else None
|
common_root = all_parts[0][0] if all(parts[0] == all_parts[0][0] for parts in all_parts) else None
|
||||||
if common_root and _is_forbidden((common_root,)):
|
|
||||||
raise DeploymentError(f"forbidden archive artifact: {common_root}")
|
|
||||||
for parts in all_parts:
|
for parts in all_parts:
|
||||||
relative = parts[1:] if common_root else parts
|
relative = parts[1:] if common_root else parts
|
||||||
if not relative:
|
if not relative:
|
||||||
|
|
@ -177,19 +149,7 @@ def _extract_validated(archive_path: Path, destination: Path, members: list[tarf
|
||||||
target.chmod(0o755 if member.mode & 0o111 else 0o644)
|
target.chmod(0o755 if member.mode & 0o111 else 0o644)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_deployment_root(config: DeploymentConfig, *, create: bool) -> None:
|
|
||||||
if config.root.is_symlink():
|
|
||||||
raise DeploymentError("deployment root must not be a symlink")
|
|
||||||
if config.root.exists() and not config.root.is_dir():
|
|
||||||
raise DeploymentError("deployment root is not a directory")
|
|
||||||
if create:
|
|
||||||
config.root.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
||||||
if config.root.is_symlink() or not config.root.is_dir():
|
|
||||||
raise DeploymentError("deployment root is not a safe directory")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_releases_directory(config: DeploymentConfig, *, create: bool) -> None:
|
def _ensure_releases_directory(config: DeploymentConfig, *, create: bool) -> None:
|
||||||
_ensure_deployment_root(config, create=create)
|
|
||||||
if config.releases.is_symlink():
|
if config.releases.is_symlink():
|
||||||
raise DeploymentError("releases directory must not be a symlink")
|
raise DeploymentError("releases directory must not be a symlink")
|
||||||
if config.releases.exists() and not config.releases.is_dir():
|
if config.releases.exists() and not config.releases.is_dir():
|
||||||
|
|
@ -217,9 +177,7 @@ def _current_commit(config: DeploymentConfig) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _atomic_point(config: DeploymentConfig, commit: str | None) -> None:
|
def _atomic_point(config: DeploymentConfig, commit: str | None) -> None:
|
||||||
_ensure_releases_directory(config, create=False)
|
config.root.mkdir(parents=True, exist_ok=True)
|
||||||
if config.current.exists() and not config.current.is_symlink():
|
|
||||||
raise DeploymentError("current exists but is not a symlink")
|
|
||||||
temporary = config.root / f".current-{os.getpid()}-{secrets.token_hex(6)}"
|
temporary = config.root / f".current-{os.getpid()}-{secrets.token_hex(6)}"
|
||||||
try:
|
try:
|
||||||
if commit is None:
|
if commit is None:
|
||||||
|
|
@ -271,28 +229,25 @@ def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha25
|
||||||
run_command: RunCommand = _run_argv, health_check: HealthCheck = poll_health) -> dict:
|
run_command: RunCommand = _run_argv, health_check: HealthCheck = poll_health) -> dict:
|
||||||
archive = Path(archive)
|
archive = Path(archive)
|
||||||
_validate_identity(tag, commit, expected_sha256)
|
_validate_identity(tag, commit, expected_sha256)
|
||||||
_ensure_deployment_root(config, create=False)
|
actual_sha256 = sha256_file(archive)
|
||||||
with _verified_archive_copy(archive, expected_sha256) as verified_archive:
|
if actual_sha256 != expected_sha256:
|
||||||
members, common_root = inspect_archive(verified_archive, config)
|
raise DeploymentError("archive SHA-256 mismatch; refusing extraction")
|
||||||
final = config.releases / commit
|
members, common_root = inspect_archive(archive, config)
|
||||||
if final.exists() or final.is_symlink():
|
final = config.releases / commit
|
||||||
raise DeploymentError(f"immutable release already exists: {commit}")
|
if final.exists() or final.is_symlink():
|
||||||
previous = _current_commit(config)
|
raise DeploymentError(f"immutable release already exists: {commit}")
|
||||||
_ensure_releases_directory(config, create=True)
|
previous = _current_commit(config)
|
||||||
pending = config.releases / f".pending-{commit}-{secrets.token_hex(6)}"
|
_ensure_releases_directory(config, create=True)
|
||||||
if pending.exists() or pending.is_symlink():
|
pending = config.releases / f".pending-{commit}-{secrets.token_hex(6)}"
|
||||||
raise DeploymentError("pending release boundary already exists")
|
pending.mkdir(mode=0o755)
|
||||||
pending.mkdir(mode=0o755)
|
try:
|
||||||
if pending.is_symlink() or not pending.is_dir():
|
_extract_validated(archive, pending, members, common_root)
|
||||||
raise DeploymentError("pending release boundary is not a safe directory")
|
metadata = {"schemaVersion": 1, "tag": tag, "commit": commit, "sha256": expected_sha256}
|
||||||
try:
|
(pending / ".timmy-release.json").write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
_extract_validated(verified_archive, pending, members, common_root)
|
os.replace(pending, final)
|
||||||
metadata = {"schemaVersion": 1, "tag": tag, "commit": commit, "sha256": expected_sha256}
|
except Exception:
|
||||||
(pending / ".timmy-release.json").write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
|
shutil.rmtree(pending, ignore_errors=True)
|
||||||
os.replace(pending, final)
|
raise
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(pending, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
_atomic_point(config, commit)
|
_atomic_point(config, commit)
|
||||||
try:
|
try:
|
||||||
_verify(config, commit, run_command, health_check, smoke=True)
|
_verify(config, commit, run_command, health_check, smoke=True)
|
||||||
|
|
@ -302,11 +257,9 @@ def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha25
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
_verify(config, previous, run_command, health_check, smoke=False)
|
_verify(config, previous, run_command, health_check, smoke=False)
|
||||||
else:
|
else:
|
||||||
run_command(config.stop_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
|
run_command(config.restart_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
|
||||||
except Exception as rollback_error:
|
except Exception as rollback_error:
|
||||||
raise DeploymentError("promotion verification failed and rollback verification also failed") from rollback_error
|
raise DeploymentError("promotion verification failed and rollback verification also failed") from rollback_error
|
||||||
if previous is None:
|
|
||||||
raise DeploymentError("promotion verification failed; no prior release; service stopped") from error
|
|
||||||
raise DeploymentError("promotion verification failed; prior release restored") from error
|
raise DeploymentError("promotion verification failed; prior release restored") from error
|
||||||
return {"ok": True, "action": "promote", **metadata, "previousCommit": previous}
|
return {"ok": True, "action": "promote", **metadata, "previousCommit": previous}
|
||||||
|
|
||||||
|
|
@ -378,7 +331,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument("--root", type=Path, default=Path(os.environ.get("TIMMY_STAGING_ROOT", "/opt/timmy-staging")))
|
parser.add_argument("--root", type=Path, default=Path(os.environ.get("TIMMY_STAGING_ROOT", "/opt/timmy-staging")))
|
||||||
parser.add_argument("--dry-run", action="store_true", help="validate and print the intended action without mutation or commands")
|
parser.add_argument("--dry-run", action="store_true", help="validate and print the intended action without mutation or commands")
|
||||||
parser.add_argument("--restart-command", default=os.environ.get("TIMMY_STAGING_RESTART_COMMAND", '["systemctl","restart","timmy-staging.service"]'))
|
parser.add_argument("--restart-command", default=os.environ.get("TIMMY_STAGING_RESTART_COMMAND", '["systemctl","restart","timmy-staging.service"]'))
|
||||||
parser.add_argument("--stop-command", default=os.environ.get("TIMMY_STAGING_STOP_COMMAND", '["systemctl","stop","timmy-staging.service"]'))
|
|
||||||
parser.add_argument("--smoke-command", default=os.environ.get("TIMMY_STAGING_SMOKE_COMMAND", '["npm","run","test:staging-smoke"]'))
|
parser.add_argument("--smoke-command", default=os.environ.get("TIMMY_STAGING_SMOKE_COMMAND", '["npm","run","test:staging-smoke"]'))
|
||||||
parser.add_argument("--health-url", default=os.environ.get("TIMMY_STAGING_HEALTH_URL", "http://127.0.0.1:4174/timmy-staging/api/healthz"))
|
parser.add_argument("--health-url", default=os.environ.get("TIMMY_STAGING_HEALTH_URL", "http://127.0.0.1:4174/timmy-staging/api/healthz"))
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
@ -399,7 +351,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||||
config = DeploymentConfig(
|
config = DeploymentConfig(
|
||||||
root=args.root,
|
root=args.root,
|
||||||
restart_command=_json_argv(args.restart_command, "restart command"),
|
restart_command=_json_argv(args.restart_command, "restart command"),
|
||||||
stop_command=_json_argv(args.stop_command, "stop command"),
|
|
||||||
smoke_command=_json_argv(args.smoke_command, "smoke command"),
|
smoke_command=_json_argv(args.smoke_command, "smoke command"),
|
||||||
health_url=args.health_url,
|
health_url=args.health_url,
|
||||||
)
|
)
|
||||||
|
|
@ -408,9 +359,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||||
elif args.command == "promote":
|
elif args.command == "promote":
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
_validate_identity(args.tag, args.commit, args.sha256)
|
_validate_identity(args.tag, args.commit, args.sha256)
|
||||||
_ensure_deployment_root(config, create=False)
|
actual = sha256_file(args.archive)
|
||||||
with _verified_archive_copy(args.archive, args.sha256) as verified_archive:
|
if actual != args.sha256:
|
||||||
members, wrapper = inspect_archive(verified_archive, config)
|
raise DeploymentError("archive SHA-256 mismatch; refusing extraction")
|
||||||
|
members, wrapper = inspect_archive(args.archive, config)
|
||||||
result = {"ok": True, "dryRun": True, "action": "promote", "commit": args.commit, "tag": args.tag, "members": len(members), "wrapper": wrapper}
|
result = {"ok": True, "dryRun": True, "action": "promote", "commit": args.commit, "tag": args.tag, "members": len(members), "wrapper": wrapper}
|
||||||
else:
|
else:
|
||||||
result = promote(config=config, tag=args.tag, archive=args.archive, expected_sha256=args.sha256, commit=args.commit)
|
result = promote(config=config, tag=args.tag, archive=args.archive, expected_sha256=args.sha256, commit=args.commit)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
const servicePath = new URL('../deploy/timmy-staging.service', import.meta.url);
|
const servicePath = new URL('../deploy/timmy-staging.service', import.meta.url);
|
||||||
const envPath = new URL('../deploy/timmy-staging.env.example', import.meta.url);
|
const envPath = new URL('../deploy/timmy-staging.env.example', import.meta.url);
|
||||||
|
|
@ -20,29 +19,11 @@ test('systemd template runs a dedicated loopback-only agent-disabled service', a
|
||||||
'Environment=TIMMY_BASE_PATH=/timmy-staging',
|
'Environment=TIMMY_BASE_PATH=/timmy-staging',
|
||||||
'Environment=TIMMY_AGENT_ENABLED=false',
|
'Environment=TIMMY_AGENT_ENABLED=false',
|
||||||
]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive);
|
]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive);
|
||||||
assert.match(service, /^ExecStart=\/usr\/bin\/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false \/usr\/local\/lib\/timmy-staging\/node server\.mjs$/m);
|
assert.match(service, /^ExecStart=\/usr\/bin\/node server\.mjs$/m);
|
||||||
assert.doesNotMatch(service, /ExecStart=\/usr\/(?:local\/)?bin\/node|0\.0\.0\.0|TIMMY_AGENT_ENABLED=true/);
|
assert.doesNotMatch(service, /0\.0\.0\.0|TIMMY_AGENT_ENABLED=true/);
|
||||||
assert.doesNotMatch(service, literalSecret);
|
assert.doesNotMatch(service, literalSecret);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('systemd command-level environment keeps agent and vision disabled after EnvironmentFile overrides', async () => {
|
|
||||||
const service = await readFile(servicePath, 'utf8');
|
|
||||||
const environmentFileIndex = service.indexOf('EnvironmentFile=');
|
|
||||||
const execLine = service.match(/^ExecStart=(.+)$/m)?.[1];
|
|
||||||
assert.ok(execLine, 'ExecStart must exist');
|
|
||||||
assert.ok(environmentFileIndex < service.indexOf(`ExecStart=${execLine}`), 'EnvironmentFile must be loaded before command overrides');
|
|
||||||
const argv = execLine.trim().split(/\s+/);
|
|
||||||
assert.equal(argv.shift(), '/usr/bin/env');
|
|
||||||
const assignments = argv.filter(value => /^TIMMY_(?:AGENT|VISION)_ENABLED=/.test(value));
|
|
||||||
assert.deepEqual(assignments, ['TIMMY_AGENT_ENABLED=false', 'TIMMY_VISION_ENABLED=false']);
|
|
||||||
const probe = spawnSync('/usr/bin/env', [
|
|
||||||
...assignments, process.execPath, '-e',
|
|
||||||
'process.stdout.write(`${process.env.TIMMY_AGENT_ENABLED},${process.env.TIMMY_VISION_ENABLED}`)',
|
|
||||||
], { env: { ...process.env, TIMMY_AGENT_ENABLED: 'true', TIMMY_VISION_ENABLED: 'true' }, encoding: 'utf8' });
|
|
||||||
assert.equal(probe.status, 0, probe.stderr);
|
|
||||||
assert.equal(probe.stdout, 'false,false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('systemd template applies filesystem privilege process and resource confinement', async () => {
|
test('systemd template applies filesystem privilege process and resource confinement', async () => {
|
||||||
const service = await readFile(servicePath, 'utf8');
|
const service = await readFile(servicePath, 'utf8');
|
||||||
for (const directive of [
|
for (const directive of [
|
||||||
|
|
@ -93,12 +74,8 @@ test('runbook covers safe installation operation verification rollback backup an
|
||||||
'Rollback', 'Backup', 'Remove staging', 'Template validation',
|
'Rollback', 'Backup', 'Remove staging', 'Template validation',
|
||||||
]) assert.match(runbook, new RegExp(`^## .*${heading}`, 'mi'), heading);
|
]) assert.match(runbook, new RegExp(`^## .*${heading}`, 'mi'), heading);
|
||||||
assert.match(runbook, /sha256/i);
|
assert.match(runbook, /sha256/i);
|
||||||
assert.match(runbook, /install -D -o root -g root -m 755[^\n]+\/usr\/local\/lib\/timmy-staging\/node/);
|
|
||||||
assert.match(runbook, /\/usr\/local\/lib\/timmy-staging\/node --version/);
|
|
||||||
assert.match(runbook, /chmod 600|install -m 600/);
|
assert.match(runbook, /chmod 600|install -m 600/);
|
||||||
assert.match(runbook, /systemctl restart timmy-staging\.service/);
|
assert.match(runbook, /systemctl restart timmy-staging\.service/);
|
||||||
assert.match(runbook, /no prior release[^.]*stops[^.]*service/i);
|
|
||||||
assert.match(runbook, /failed release[^.]*inert[^.]*evidence/i);
|
|
||||||
assert.match(runbook, /journalctl -u timmy-staging\.service/);
|
assert.match(runbook, /journalctl -u timmy-staging\.service/);
|
||||||
assert.match(runbook, /do not.*live|approval/i);
|
assert.match(runbook, /do not.*live|approval/i);
|
||||||
assert.doesNotMatch(runbook, literalSecret);
|
assert.doesNotMatch(runbook, literalSecret);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SCRIPT = ROOT / "scripts" / "deploy_staging.py"
|
SCRIPT = ROOT / "scripts" / "deploy_staging.py"
|
||||||
|
|
@ -64,7 +63,6 @@ class DeployTests(unittest.TestCase):
|
||||||
self.config = self.deploy.DeploymentConfig(
|
self.config = self.deploy.DeploymentConfig(
|
||||||
root=self.root,
|
root=self.root,
|
||||||
restart_command=("fixture-restart",),
|
restart_command=("fixture-restart",),
|
||||||
stop_command=("fixture-stop",),
|
|
||||||
smoke_command=("fixture-smoke",),
|
smoke_command=("fixture-smoke",),
|
||||||
health_url="http://127.0.0.1:4174/api/healthz",
|
health_url="http://127.0.0.1:4174/api/healthz",
|
||||||
command_timeout=1.0,
|
command_timeout=1.0,
|
||||||
|
|
@ -118,37 +116,6 @@ class DeployTests(unittest.TestCase):
|
||||||
self.promote(archive, "0" * 64)
|
self.promote(archive, "0" * 64)
|
||||||
self.assertFalse((self.root / "releases").exists())
|
self.assertFalse((self.root / "releases").exists())
|
||||||
|
|
||||||
def test_verified_private_archive_copy_is_used_after_caller_archive_mutates(self):
|
|
||||||
archive, digest = self.archive("mutable.tar.gz")
|
|
||||||
evil = Path(self.tmp.name) / "evil.tar.gz"
|
|
||||||
make_archive(evil, [
|
|
||||||
("timmy-release/", b"", "dir"),
|
|
||||||
("timmy-release/server.mjs", b"EVIL\n", "file"),
|
|
||||||
])
|
|
||||||
real_inspect = self.deploy.inspect_archive
|
|
||||||
inspected_paths = []
|
|
||||||
|
|
||||||
def mutate_then_inspect(path, config):
|
|
||||||
inspected_paths.append(Path(path))
|
|
||||||
archive.write_bytes(evil.read_bytes())
|
|
||||||
return real_inspect(path, config)
|
|
||||||
|
|
||||||
with mock.patch.object(self.deploy, "inspect_archive", side_effect=mutate_then_inspect):
|
|
||||||
self.promote(archive, digest)
|
|
||||||
|
|
||||||
release = self.root / "releases" / COMMIT_B
|
|
||||||
self.assertNotEqual(inspected_paths, [archive])
|
|
||||||
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
|
|
||||||
self.assertTrue(all(not path.exists() for path in inspected_paths), "verified temp archive must be cleaned")
|
|
||||||
|
|
||||||
def test_archive_source_must_be_a_nonsymlink_regular_file(self):
|
|
||||||
archive, digest = self.archive("regular.tar.gz")
|
|
||||||
symlink = Path(self.tmp.name) / "archive-link.tar.gz"
|
|
||||||
symlink.symlink_to(archive)
|
|
||||||
for source in (symlink, Path(self.tmp.name)):
|
|
||||||
with self.subTest(source=source), self.assertRaisesRegex(self.deploy.DeploymentError, "regular file|stage archive"):
|
|
||||||
self.promote(source, digest)
|
|
||||||
|
|
||||||
def test_absolute_and_traversal_paths_are_rejected(self):
|
def test_absolute_and_traversal_paths_are_rejected(self):
|
||||||
for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")):
|
for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")):
|
||||||
archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")])
|
archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")])
|
||||||
|
|
@ -179,18 +146,6 @@ class DeployTests(unittest.TestCase):
|
||||||
with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
|
with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
|
||||||
self.promote(archive, digest)
|
self.promote(archive, digest)
|
||||||
|
|
||||||
def test_forbidden_common_roots_are_rejected_case_insensitively_but_release_wrapper_is_allowed(self):
|
|
||||||
for index, root in enumerate((".git", "ViDeO", "ARTIFACTS", "Credentials")):
|
|
||||||
archive, _ = self.archive(f"forbidden-root-{index}.tar.gz", [
|
|
||||||
(f"{root}/", b"", "dir"),
|
|
||||||
(f"{root}/server.mjs", b"evil", "file"),
|
|
||||||
])
|
|
||||||
with self.subTest(root=root), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
|
|
||||||
self.deploy.inspect_archive(archive, self.config)
|
|
||||||
archive, _ = self.archive("normal-wrapper.tar.gz")
|
|
||||||
_, wrapper = self.deploy.inspect_archive(archive, self.config)
|
|
||||||
self.assertEqual(wrapper, "timmy-release")
|
|
||||||
|
|
||||||
def test_release_root_symlink_is_rejected(self):
|
def test_release_root_symlink_is_rejected(self):
|
||||||
outside = Path(self.tmp.name) / "outside"
|
outside = Path(self.tmp.name) / "outside"
|
||||||
outside.mkdir()
|
outside.mkdir()
|
||||||
|
|
@ -200,41 +155,6 @@ class DeployTests(unittest.TestCase):
|
||||||
self.promote(archive, digest)
|
self.promote(archive, digest)
|
||||||
self.assertEqual(list(outside.iterdir()), [])
|
self.assertEqual(list(outside.iterdir()), [])
|
||||||
|
|
||||||
def test_deployment_root_symlink_is_rejected_before_writing_outside(self):
|
|
||||||
outside = Path(self.tmp.name) / "outside-root"
|
|
||||||
outside.mkdir()
|
|
||||||
self.root.rmdir()
|
|
||||||
self.root.symlink_to(outside, target_is_directory=True)
|
|
||||||
archive, digest = self.archive()
|
|
||||||
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root"):
|
|
||||||
self.promote(archive, digest)
|
|
||||||
self.assertEqual(list(outside.iterdir()), [])
|
|
||||||
|
|
||||||
def test_deployment_root_non_directory_is_rejected(self):
|
|
||||||
self.root.rmdir()
|
|
||||||
self.root.write_text("not a directory")
|
|
||||||
archive, digest = self.archive()
|
|
||||||
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root is not a directory"):
|
|
||||||
self.promote(archive, digest)
|
|
||||||
|
|
||||||
def test_current_and_pending_symlink_boundaries_are_rejected_before_extraction(self):
|
|
||||||
outside = Path(self.tmp.name) / "outside-boundary"
|
|
||||||
outside.mkdir()
|
|
||||||
(self.root / "releases").mkdir()
|
|
||||||
(self.root / "current").symlink_to(outside, target_is_directory=True)
|
|
||||||
archive, digest = self.archive("current-boundary.tar.gz")
|
|
||||||
with self.assertRaisesRegex(self.deploy.DeploymentError, "current symlink escapes"):
|
|
||||||
self.promote(archive, digest)
|
|
||||||
self.assertEqual(list(outside.iterdir()), [])
|
|
||||||
|
|
||||||
(self.root / "current").unlink()
|
|
||||||
pending = self.root / "releases" / f".pending-{COMMIT_B}-fixed"
|
|
||||||
pending.symlink_to(outside, target_is_directory=True)
|
|
||||||
with mock.patch.object(self.deploy.secrets, "token_hex", return_value="fixed"):
|
|
||||||
with self.assertRaisesRegex(self.deploy.DeploymentError, "pending release boundary"):
|
|
||||||
self.promote(archive, digest)
|
|
||||||
self.assertEqual(list(outside.iterdir()), [])
|
|
||||||
|
|
||||||
def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self):
|
def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self):
|
||||||
archive, digest = self.archive()
|
archive, digest = self.archive()
|
||||||
result = self.promote(archive, digest)
|
result = self.promote(archive, digest)
|
||||||
|
|
@ -281,24 +201,6 @@ class DeployTests(unittest.TestCase):
|
||||||
self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A)
|
self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A)
|
||||||
self.assertGreaterEqual(calls.count(("fixture-restart",)), 2)
|
self.assertGreaterEqual(calls.count(("fixture-restart",)), 2)
|
||||||
|
|
||||||
def test_first_promotion_failure_removes_current_and_stops_service_without_restart(self):
|
|
||||||
archive, digest = self.archive("first-failure.tar.gz")
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def run(argv, **kwargs):
|
|
||||||
calls.append(tuple(argv))
|
|
||||||
if tuple(argv) == ("fixture-smoke",):
|
|
||||||
raise subprocess.CalledProcessError(1, argv)
|
|
||||||
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
||||||
|
|
||||||
with self.assertRaisesRegex(self.deploy.DeploymentError, "no prior release; service stopped"):
|
|
||||||
self.promote(archive, digest, run_command=run)
|
|
||||||
|
|
||||||
self.assertFalse((self.root / "current").exists())
|
|
||||||
self.assertFalse((self.root / "current").is_symlink())
|
|
||||||
self.assertEqual(calls, [("fixture-restart",), ("fixture-smoke",), ("fixture-stop",)])
|
|
||||||
self.assertTrue((self.root / "releases" / COMMIT_B).is_dir(), "failed release remains inert evidence")
|
|
||||||
|
|
||||||
def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self):
|
def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self):
|
||||||
self.seed_release(COMMIT_A)
|
self.seed_release(COMMIT_A)
|
||||||
self.seed_release(COMMIT_B)
|
self.seed_release(COMMIT_B)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user