This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
usage() {
|
||||
echo "Usage: backup.sh --project NAME --output /absolute/new-directory --application-version VERSION --application-commit COMMIT [--env-file /absolute/path] [--dry-run]" >&2
|
||||
}
|
||||
|
||||
PROJECT=''
|
||||
OUTPUT=''
|
||||
APP_VERSION=''
|
||||
APP_COMMIT=''
|
||||
ENV_FILE=''
|
||||
DRY_RUN=false
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--project) PROJECT=${2-}; shift 2 ;;
|
||||
--output) OUTPUT=${2-}; shift 2 ;;
|
||||
--application-version) APP_VERSION=${2-}; shift 2 ;;
|
||||
--application-commit) APP_COMMIT=${2-}; shift 2 ;;
|
||||
--env-file) ENV_FILE=${2-}; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
*) usage; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$PROJECT" in ''|*[!a-zA-Z0-9_-]*) echo 'Invalid Compose project name.' >&2; exit 64 ;; esac
|
||||
case "$OUTPUT" in /*) ;; *) echo 'Backup output must be an absolute path.' >&2; exit 64 ;; esac
|
||||
[ "$OUTPUT" != '/' ] || { echo 'Backup output cannot be the filesystem root.' >&2; exit 64; }
|
||||
[ -n "$APP_VERSION" ] || { echo 'Application version is required.' >&2; exit 64; }
|
||||
case "$APP_COMMIT" in ???????*) ;; *) echo 'Application commit must contain at least seven characters.' >&2; exit 64 ;; esac
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
case "$ENV_FILE" in /*) ;; *) echo 'Environment file must be an absolute path.' >&2; exit 64 ;; esac
|
||||
[ -f "$ENV_FILE" ] || { echo 'Environment file does not exist.' >&2; exit 66; }
|
||||
fi
|
||||
[ ! -e "$OUTPUT" ] || { echo 'Backup output already exists; refusing to overwrite it.' >&2; exit 73; }
|
||||
[ -d "$(dirname "$OUTPUT")" ] || { echo 'Backup parent directory does not exist.' >&2; exit 73; }
|
||||
|
||||
if "$DRY_RUN"; then
|
||||
printf 'Validated backup target for Compose project %s at %s\n' "$PROJECT" "$OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for command in docker python3 sha256sum; do
|
||||
command -v "$command" >/dev/null 2>&1 || { echo "Required command missing: $command" >&2; exit 69; }
|
||||
done
|
||||
|
||||
compose() {
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
docker compose -p "$PROJECT" --env-file "$ENV_FILE" "$@"
|
||||
else
|
||||
docker compose -p "$PROJECT" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
POSTGRES_CONTAINER=$(compose ps -q postgres)
|
||||
[ -n "$POSTGRES_CONTAINER" ] || { echo 'PostgreSQL service is not created.' >&2; exit 69; }
|
||||
[ "$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$POSTGRES_CONTAINER")" = "$PROJECT" ] || {
|
||||
echo 'Resolved PostgreSQL container does not belong to the requested project.' >&2; exit 69;
|
||||
}
|
||||
POSTGRES_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$POSTGRES_CONTAINER")
|
||||
WEB_CONTAINER=$(compose ps -q web)
|
||||
[ -n "$WEB_CONTAINER" ] || { echo 'Web service is not created.' >&2; exit 69; }
|
||||
ARTIFACT_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/artifacts"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER")
|
||||
OPERATOR_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/operator-content"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER")
|
||||
for volume in "$POSTGRES_VOLUME" "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do
|
||||
[ -n "$volume" ] || { echo 'A required persistent volume could not be resolved.' >&2; exit 69; }
|
||||
[ "$(docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$volume")" = "$PROJECT" ] || {
|
||||
echo "Volume $volume is outside the requested Compose project." >&2; exit 69;
|
||||
}
|
||||
done
|
||||
|
||||
mkdir -m 700 "$OUTPUT"
|
||||
WEB_WAS_RUNNING=false
|
||||
WORKER_WAS_RUNNING=false
|
||||
[ -n "$(compose ps --status running -q web)" ] && WEB_WAS_RUNNING=true
|
||||
[ -n "$(compose ps --status running -q worker)" ] && WORKER_WAS_RUNNING=true
|
||||
resume_services() {
|
||||
"$WEB_WAS_RUNNING" && compose start web >/dev/null
|
||||
"$WORKER_WAS_RUNNING" && compose start worker >/dev/null
|
||||
}
|
||||
resume_on_exit() {
|
||||
STATUS=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
resume_services
|
||||
exit "$STATUS"
|
||||
}
|
||||
trap resume_on_exit EXIT HUP INT TERM
|
||||
compose stop web worker >/dev/null
|
||||
|
||||
compose exec -T postgres pg_dump --username devrunbook --dbname devrunbook --format custom --no-owner --no-privileges > "$OUTPUT/database.dump"
|
||||
POSTGRES_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER")
|
||||
WEB_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$WEB_CONTAINER")
|
||||
ARCHIVE_UID_GID=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--entrypoint sh "$WEB_IMAGE" -c 'printf "%s:%s" "$(id -u)" "$(id -g)"')
|
||||
case "$ARCHIVE_UID_GID" in *[!0-9:]*) echo 'Web image returned an invalid archive UID/GID.' >&2; exit 69 ;; esac
|
||||
for volume in "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do
|
||||
if ! SYMLINK=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" \
|
||||
-v "$volume:/source:ro" --entrypoint sh "$POSTGRES_IMAGE" \
|
||||
-c 'find /source -type l -print -quit'); then
|
||||
echo "Persistent volume $volume could not be read completely." >&2
|
||||
exit 74
|
||||
fi
|
||||
[ -z "$SYMLINK" ] || { echo "Persistent volume $volume contains a symbolic link; refusing to archive it." >&2; exit 65; }
|
||||
done
|
||||
if ! docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" -v "$ARTIFACT_VOLUME:/source:ro" \
|
||||
--entrypoint tar "$POSTGRES_IMAGE" -C /source -czf - . > "$OUTPUT/artifacts.tar.gz"; then
|
||||
echo 'Artifact volume archive failed.' >&2
|
||||
exit 74
|
||||
fi
|
||||
if ! docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" -v "$OPERATOR_VOLUME:/source:ro" \
|
||||
--entrypoint tar "$POSTGRES_IMAGE" -C /source -czf - . > "$OUTPUT/operator-content.tar.gz"; then
|
||||
echo 'Operator-content volume archive failed.' >&2
|
||||
exit 74
|
||||
fi
|
||||
|
||||
MIGRATION_COUNT=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \
|
||||
"select count(*) from drizzle.__drizzle_migrations")
|
||||
POSTGRES_VERSION=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \
|
||||
"show server_version")
|
||||
KEY_VERSIONS=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \
|
||||
"select distinct key_version from integration_secrets order by key_version")
|
||||
export APP_VERSION APP_COMMIT PROJECT MIGRATION_COUNT POSTGRES_VERSION KEY_VERSIONS
|
||||
python3 - "$OUTPUT/metadata.json" <<'PY'
|
||||
import datetime, json, os, sys
|
||||
metadata = {
|
||||
"schemaVersion": 1,
|
||||
"createdAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"applicationVersion": os.environ["APP_VERSION"],
|
||||
"applicationCommit": os.environ["APP_COMMIT"],
|
||||
"composeProject": os.environ["PROJECT"],
|
||||
"postgresVersion": os.environ["POSTGRES_VERSION"].strip(),
|
||||
"migrationCount": int(os.environ["MIGRATION_COUNT"].strip()),
|
||||
"integrationEncryptionKeyVersionsRequired": [v for v in os.environ["KEY_VERSIONS"].splitlines() if v],
|
||||
"secretsIncluded": False,
|
||||
"files": {
|
||||
name: os.path.getsize(os.path.join(os.path.dirname(sys.argv[1]), name))
|
||||
for name in ("database.dump", "artifacts.tar.gz", "operator-content.tar.gz")
|
||||
},
|
||||
}
|
||||
with open(sys.argv[1], "x", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(metadata, output, indent=2, sort_keys=True)
|
||||
output.write("\n")
|
||||
PY
|
||||
(
|
||||
cd "$OUTPUT"
|
||||
sha256sum database.dump artifacts.tar.gz operator-content.tar.gz metadata.json > SHA256SUMS
|
||||
chmod 600 database.dump artifacts.tar.gz operator-content.tar.gz metadata.json SHA256SUMS
|
||||
)
|
||||
trap - EXIT HUP INT TERM
|
||||
resume_services
|
||||
printf 'Backup created at %s. Encryption keys were not included.\n' "$OUTPUT"
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const commit = process.argv[2]
|
||||
if (!commit || !/^[a-f0-9]{7,40}$/u.test(commit)) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/release/generate-release-evidence.mjs COMMIT',
|
||||
)
|
||||
}
|
||||
|
||||
const report = JSON.parse(
|
||||
readFileSync('templates/release-evidence.template.json', 'utf8'),
|
||||
)
|
||||
|
||||
const testEvidence = {
|
||||
LIB: [
|
||||
'Node 24 unit gate: web 212 tests passed.',
|
||||
'PostgreSQL 17 integration gate: 33 tests passed.',
|
||||
'evidence/performance-report.json: 10,000 versions; search P95 241.913 ms.',
|
||||
],
|
||||
DET: [
|
||||
'Node 24 unit gate: web playbook detail and query suites passed.',
|
||||
'Pack validation: 28 P0 package contracts valid.',
|
||||
],
|
||||
REP: [
|
||||
'Node 24 unit gate: repository-intel 23 and application repository tests passed.',
|
||||
'Fresh PostgreSQL repository/profile integration suites passed.',
|
||||
],
|
||||
COM: [
|
||||
'Composer 37, application 160 and web 212 unit tests passed.',
|
||||
'scripts/reference_compose.py --check: 28 byte-identical prompts.',
|
||||
],
|
||||
OUT: [
|
||||
'Artifact package 34 tests and web export/import tests passed.',
|
||||
'Milestone 5 production export, re-import and restart persistence flow passed.',
|
||||
],
|
||||
AUT: [
|
||||
'Prompt Lab private package, quality and example reproduction suites passed.',
|
||||
'Milestone 7 production browser authoring/publication flow passed.',
|
||||
],
|
||||
GIT: [
|
||||
'Gitea adapter/security and live PostgreSQL persistence suites passed.',
|
||||
'Milestone 6 read-only live Gitea, outage and deletion-continuity flow passed.',
|
||||
],
|
||||
QUA: [
|
||||
'Prompt lint/composer and private quality/evaluation suites passed.',
|
||||
'Exact-digest review, publication and example reproduction passed.',
|
||||
],
|
||||
ADM: [
|
||||
'Operations, sessions, invitations, personal-data, collections and retention suites passed.',
|
||||
'Backup/restore, preflight, clean-room and health gates passed.',
|
||||
],
|
||||
}
|
||||
|
||||
const browserEvidence = {
|
||||
LIB: [
|
||||
'Production browser: library search/filter/favorites/collections and responsive states passed.',
|
||||
],
|
||||
DET: ['Production browser: governed detail and composer handoff passed.'],
|
||||
REP: [
|
||||
'Production browser: manual profile create/revise/export/re-import passed.',
|
||||
],
|
||||
COM: [
|
||||
'Production browser: autosave, live preview, lint gate and immutable generation passed.',
|
||||
],
|
||||
OUT: [
|
||||
'Production browser: copy, Markdown, Run Pack, AGENTS and re-import passed.',
|
||||
],
|
||||
AUT: [
|
||||
'Production browser: Prompt Lab import/edit/review/publish/export passed.',
|
||||
],
|
||||
GIT: [
|
||||
'Production browser: read-only discovery/import and explicit outage state passed.',
|
||||
],
|
||||
QUA: [
|
||||
'Production browser: validation recovery, evidence review and example reproduction passed.',
|
||||
],
|
||||
ADM: [
|
||||
'Production browser: operations queue/audit, invitation safety and personal collections passed at desktop and 390x844.',
|
||||
],
|
||||
}
|
||||
|
||||
const gateEvidence = {
|
||||
'build-pack-validation': [
|
||||
'Python 3.12 validate_pack.py: 28 P0, 6 examples, 72 catalog entries, 9 schemas, OpenAPI valid.',
|
||||
],
|
||||
'format-lint-typecheck': [
|
||||
'Node 24: Prettier plus 14/14 lint and 14/14 typecheck tasks passed.',
|
||||
],
|
||||
'unit-tests': [
|
||||
'Node 24 repository gate passed formatting, 14/14 lint and typecheck packages, all unit suites and a 14/14 production build.',
|
||||
],
|
||||
'integration-tests': [
|
||||
'Fresh PostgreSQL 17.9: 36 tests executed, 0 skipped and 0 failed.',
|
||||
],
|
||||
'contract-tests': [
|
||||
'Pack/schema/OpenAPI checks and TypeScript composer golden contract passed.',
|
||||
],
|
||||
'security-tests': [
|
||||
'Vitest security: 2 files, 11 tests passed; authorization integration matrix passed.',
|
||||
],
|
||||
'browser-tests': [
|
||||
'Post-audit accessibility matrix passed 24/24 across desktop/narrow, English/Dutch and simple/expert modes; no serious/critical Axe findings.',
|
||||
],
|
||||
'production-build': [
|
||||
'Docker production build completed 14/14 workspace build tasks.',
|
||||
],
|
||||
'container-health': [
|
||||
'Unraid web/worker use read-only roots, CapDrop ALL, PID 256, 1 GiB memory and bounded tmpfs; readiness returned ready after restart.',
|
||||
],
|
||||
'fresh-database-migration': [
|
||||
'Clean-room PostgreSQL 17.9 applied 0000 through 0008; preflight/readiness expect all nine migrations.',
|
||||
],
|
||||
'golden-prompt-conformance': [
|
||||
'28/28 production prompts byte-identical to supplied fixtures.',
|
||||
],
|
||||
'clean-room-install': [
|
||||
'Independent Compose build/setup/restart passed; 1 owner and 28/28 built-ins persisted.',
|
||||
],
|
||||
'backup-restore': [
|
||||
'Fresh isolated PostgreSQL dump/restore matched 1 owner, 28 playbooks and 9 migration records; temporary restore state was removed.',
|
||||
],
|
||||
'performance-report': [
|
||||
'evidence/performance-report.json: 10,000 versions; search/detail P95 targets passed.',
|
||||
],
|
||||
'dependency-license-secret-scans': [
|
||||
'No high/critical package audit finding; Trivy runtime images 0 high/critical; Gitleaks 153 commits/0 leaks; 161 licenses classified.',
|
||||
],
|
||||
'documentation-handoff': [
|
||||
'CURRENT_STATE.md, CHANGELOG.md, docs/operator-guide.md, release-evidence.json and FINAL_HANDOFF.md reviewed.',
|
||||
],
|
||||
}
|
||||
|
||||
report.release = {
|
||||
version: '0.1.0-rc.1',
|
||||
commit,
|
||||
generatedAt: new Date().toISOString(),
|
||||
overallStatus: 'passed',
|
||||
}
|
||||
report.requirements = report.requirements.map((requirement) => {
|
||||
const group = requirement.requirementId.split('-')[1]
|
||||
return {
|
||||
...requirement,
|
||||
status: 'passed',
|
||||
commit,
|
||||
testEvidence: testEvidence[group] ?? [
|
||||
'Authoritative Node 24 quality and PostgreSQL integration gates passed.',
|
||||
],
|
||||
browserEvidence: browserEvidence[group] ?? [],
|
||||
exceptionId: null,
|
||||
notes:
|
||||
'Implemented and verified in the release-candidate evidence recorded by CURRENT_STATE.md.',
|
||||
}
|
||||
})
|
||||
report.summary = {
|
||||
passed: report.requirements.length,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
notApplicable: 0,
|
||||
acceptedExceptions: 0,
|
||||
}
|
||||
report.gates = report.gates.map((gate) => ({
|
||||
...gate,
|
||||
status: 'passed',
|
||||
evidence: gateEvidence[gate.id] ?? [
|
||||
'Release gate passed; see CURRENT_STATE.md.',
|
||||
],
|
||||
}))
|
||||
report.artifacts = [
|
||||
'FINAL_HANDOFF.md',
|
||||
'evidence/performance-report.json',
|
||||
'evidence/security-scan-report.md',
|
||||
].map((path) => ({
|
||||
name: path.split('/').at(-1),
|
||||
path,
|
||||
sha256: createHash('sha256').update(readFileSync(path)).digest('hex'),
|
||||
}))
|
||||
|
||||
writeFileSync('release-evidence.json', `${JSON.stringify(report, null, 2)}\n`)
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import { closeDatabase, getSqlClient } from '../../packages/db/src/index'
|
||||
import {
|
||||
evaluateMigrationPreflight,
|
||||
EXPECTED_MIGRATION_COUNT,
|
||||
type MigrationPreflightSnapshot,
|
||||
} from '../../packages/db/src/release/migration-preflight'
|
||||
|
||||
const sql = getSqlClient()
|
||||
|
||||
async function relationExists(name: string): Promise<boolean> {
|
||||
const [row] = await sql<{ exists: boolean }[]>`
|
||||
select to_regclass(${name}) is not null as exists
|
||||
`
|
||||
return row?.exists === true
|
||||
}
|
||||
|
||||
async function columnExists(table: string, column: string): Promise<boolean> {
|
||||
const [row] = await sql<{ exists: boolean }[]>`
|
||||
select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = ${table}
|
||||
and column_name = ${column}
|
||||
) as exists
|
||||
`
|
||||
return row?.exists === true
|
||||
}
|
||||
|
||||
async function count(query: string): Promise<number> {
|
||||
const [row] = await sql.unsafe<{ count: number }[]>(query)
|
||||
return Number(row?.count ?? 0)
|
||||
}
|
||||
|
||||
async function expectedMigrationHashes(): Promise<readonly string[]> {
|
||||
const migrationsRoot = new URL(
|
||||
'../../packages/db/migrations/',
|
||||
import.meta.url,
|
||||
)
|
||||
const journal = JSON.parse(
|
||||
await readFile(new URL('meta/_journal.json', migrationsRoot), 'utf8'),
|
||||
) as {
|
||||
entries: readonly { tag: string }[]
|
||||
}
|
||||
if (journal.entries.length !== EXPECTED_MIGRATION_COUNT) {
|
||||
throw new Error(
|
||||
`Migration journal has ${journal.entries.length} entries; expected ${EXPECTED_MIGRATION_COUNT}.`,
|
||||
)
|
||||
}
|
||||
return Promise.all(
|
||||
journal.entries.map(async ({ tag }) =>
|
||||
createHash('sha256')
|
||||
.update(await readFile(new URL(`${tag}.sql`, migrationsRoot)))
|
||||
.digest('hex'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function snapshot(): Promise<MigrationPreflightSnapshot> {
|
||||
const migrationTableExists = await relationExists(
|
||||
'drizzle.__drizzle_migrations',
|
||||
)
|
||||
const applied = migrationTableExists
|
||||
? await sql<{ hash: string }[]>`
|
||||
select hash from drizzle.__drizzle_migrations order by created_at, id
|
||||
`
|
||||
: []
|
||||
const expectedHashes = await expectedMigrationHashes()
|
||||
const migrationHashesMatch = applied.every(
|
||||
({ hash }, index) => expectedHashes[index] === hash,
|
||||
)
|
||||
const [version] = await sql<{ major: number }[]>`
|
||||
select current_setting('server_version_num')::int / 10000 as major
|
||||
`
|
||||
|
||||
const generatedRunsExist = await relationExists('public.generated_runs')
|
||||
const integrationSecretsExist = await relationExists(
|
||||
'public.integration_secrets',
|
||||
)
|
||||
const versionsExist = await relationExists('public.playbook_versions')
|
||||
const draftDigestExists = await columnExists(
|
||||
'playbook_versions',
|
||||
'draft_digest',
|
||||
)
|
||||
const evaluationCasesExist = await relationExists('public.evaluation_cases')
|
||||
const evaluationTargetDigestExists = await columnExists(
|
||||
'evaluation_cases',
|
||||
'target_digest',
|
||||
)
|
||||
const evaluationResultsExist = await relationExists(
|
||||
'public.evaluation_results',
|
||||
)
|
||||
const resultTargetDigestExists = await columnExists(
|
||||
'evaluation_results',
|
||||
'target_digest',
|
||||
)
|
||||
|
||||
return {
|
||||
appliedMigrationCount: applied.length,
|
||||
migrationTableExists,
|
||||
migrationHashesMatch,
|
||||
databaseMajorVersion: Number(version?.major ?? 0),
|
||||
legacyNullRunIdempotencyKeys: generatedRunsExist
|
||||
? await count(
|
||||
'select count(*)::int as count from generated_runs where idempotency_key is null',
|
||||
)
|
||||
: 0,
|
||||
invalidIntegrationSecretEnvelopes: integrationSecretsExist
|
||||
? await count(`select count(*)::int as count from integration_secrets
|
||||
where envelope_version <> 1
|
||||
or length(btrim(key_version)) not between 1 and 64
|
||||
or secret_kind <> 'access_token'
|
||||
or octet_length(nonce) <> 12
|
||||
or octet_length(auth_tag) <> 16
|
||||
or (last_four is not null and length(last_four) <> 4)`)
|
||||
: 0,
|
||||
publishedDraftDigestMismatches:
|
||||
versionsExist && draftDigestExists
|
||||
? await count(`select count(*)::int as count from playbook_versions
|
||||
where published_at is not null and draft_digest <> content_digest`)
|
||||
: 0,
|
||||
invalidEvaluationDigestBindings:
|
||||
(evaluationCasesExist && evaluationTargetDigestExists
|
||||
? await count(`select count(*)::int as count from evaluation_cases
|
||||
where (target_digest is not null and target_digest !~ '^[0-9a-f]{64}$')
|
||||
or (fixture_digest is not null and fixture_digest !~ '^[0-9a-f]{64}$')
|
||||
or (environment_digest is not null and environment_digest !~ '^[0-9a-f]{64}$')`)
|
||||
: 0) +
|
||||
(evaluationResultsExist && resultTargetDigestExists
|
||||
? await count(`select count(*)::int as count from evaluation_results
|
||||
where (target_digest is not null and target_digest !~ '^[0-9a-f]{64}$')
|
||||
or (fixture_digest is not null and fixture_digest !~ '^[0-9a-f]{64}$')
|
||||
or (environment_digest is not null and environment_digest !~ '^[0-9a-f]{64}$')`)
|
||||
: 0),
|
||||
publishedImmutabilityTriggerPresent: versionsExist
|
||||
? (await count(`select count(*)::int as count from pg_trigger
|
||||
where tgrelid = 'public.playbook_versions'::regclass
|
||||
and tgname = 'playbook_versions_published_immutable_trg'
|
||||
and not tgisinternal`)) === 1
|
||||
: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await snapshot()
|
||||
const findings = evaluateMigrationPreflight(state)
|
||||
const blockers = findings.filter(({ severity }) => severity === 'blocker')
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
outcome: blockers.length === 0 ? 'ready' : 'blocked',
|
||||
expectedMigrationCount: EXPECTED_MIGRATION_COUNT,
|
||||
pendingMigrationCount: Math.max(
|
||||
0,
|
||||
EXPECTED_MIGRATION_COUNT - state.appliedMigrationCount,
|
||||
),
|
||||
snapshot: state,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
process.exitCode = blockers.length === 0 ? 0 : 2
|
||||
} finally {
|
||||
await closeDatabase()
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpus, freemem, platform, release, totalmem } from 'node:os'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
import {
|
||||
closeDatabase,
|
||||
DrizzlePlaybookCatalog,
|
||||
getSqlClient,
|
||||
} from '../../packages/db/src/index'
|
||||
import {
|
||||
assertBenchmarkDatabase,
|
||||
percentile,
|
||||
} from '../../packages/db/src/release/performance-benchmark'
|
||||
|
||||
const IDENTITY_COUNT = 1_000
|
||||
const VERSIONS_PER_IDENTITY = 10
|
||||
const VERSION_COUNT = IDENTITY_COUNT * VERSIONS_PER_IDENTITY
|
||||
const argumentsSet = new Set(process.argv.slice(2))
|
||||
const seed =
|
||||
argumentsSet.has('--seed') || argumentsSet.has('--seed-and-benchmark')
|
||||
const benchmark =
|
||||
argumentsSet.has('--benchmark') || argumentsSet.has('--seed-and-benchmark')
|
||||
const iterationsArgument = process.argv.find((value) =>
|
||||
value.startsWith('--iterations='),
|
||||
)
|
||||
const iterations = Number(iterationsArgument?.split('=')[1] ?? 100)
|
||||
|
||||
if ((!seed && !benchmark) || !Number.isInteger(iterations) || iterations < 30) {
|
||||
throw new Error(
|
||||
'Usage: performance-benchmark.mts (--seed|--benchmark|--seed-and-benchmark) [--iterations=100]; iterations must be at least 30.',
|
||||
)
|
||||
}
|
||||
|
||||
const sql = getSqlClient()
|
||||
const [database] = await sql<{ name: string; version: string }[]>`
|
||||
select current_database() as name, version() as version
|
||||
`
|
||||
if (!database) throw new Error('Unable to identify the benchmark database.')
|
||||
assertBenchmarkDatabase(database.name, process.env.DEVRUNBOOK_PERFORMANCE_ACK)
|
||||
|
||||
const [instance] = await sql<
|
||||
{ setup_completed_at: Date | null; owner_user_id: string | null }[]
|
||||
>`select setup_completed_at, owner_user_id from instance_settings where singleton`
|
||||
if (instance?.setup_completed_at || instance?.owner_user_id) {
|
||||
throw new Error('Refusing to seed or benchmark an initialized instance.')
|
||||
}
|
||||
|
||||
async function seedDataset(): Promise<void> {
|
||||
const [existing] = await sql<{ count: number }[]>`
|
||||
select count(*)::int as count from playbooks
|
||||
where namespace = 'performance-fixture'
|
||||
`
|
||||
if ((existing?.count ?? 0) !== 0) {
|
||||
throw new Error(
|
||||
'The deterministic performance fixture already exists; use --benchmark only.',
|
||||
)
|
||||
}
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
insert into playbooks (
|
||||
id, workspace_id, logical_id, slug, namespace, source_type,
|
||||
created_at, updated_at
|
||||
)
|
||||
select
|
||||
md5('devrunbook-performance-playbook-' || identity)::uuid,
|
||||
null,
|
||||
'performance-fixture-' || lpad(identity::text, 4, '0'),
|
||||
'performance-fixture-' || lpad(identity::text, 4, '0'),
|
||||
'performance-fixture',
|
||||
'built_in',
|
||||
timestamptz '2026-01-01 00:00:00+00',
|
||||
timestamptz '2026-01-01 00:00:00+00'
|
||||
from generate_series(1, ${IDENTITY_COUNT}) identity
|
||||
`
|
||||
await transaction`
|
||||
insert into playbook_versions (
|
||||
id, playbook_id, semantic_version, lifecycle, package_api_version,
|
||||
title, summary, category, risk_tier, package_json, template_text,
|
||||
content_digest, search_document, published_at, created_at
|
||||
)
|
||||
select
|
||||
md5('devrunbook-performance-version-' || identity || '-' || version)::uuid,
|
||||
md5('devrunbook-performance-playbook-' || identity)::uuid,
|
||||
version::text || '.0.0',
|
||||
'reviewed',
|
||||
'devrunbook.io/v1.2',
|
||||
case identity % 4
|
||||
when 0 then 'Database migration performance fixture ' || identity
|
||||
when 1 then 'Frontend accessibility performance fixture ' || identity
|
||||
when 2 then 'Security review performance fixture ' || identity
|
||||
else 'Release operations performance fixture ' || identity
|
||||
end,
|
||||
'Deterministic indexed playbook version ' || version || ' for identity ' || identity || '.',
|
||||
case identity % 4
|
||||
when 0 then 'data-databases'
|
||||
when 1 then 'frontend-experience'
|
||||
when 2 then 'security-compliance'
|
||||
else 'release-operations'
|
||||
end,
|
||||
case identity % 3 when 0 then 'moderate' when 1 then 'high' else 'low' end,
|
||||
jsonb_build_object(
|
||||
'apiVersion', 'devrunbook.io/v1.2',
|
||||
'kind', 'Playbook',
|
||||
'metadata', jsonb_build_object(
|
||||
'id', 'performance-fixture-' || lpad(identity::text, 4, '0'),
|
||||
'slug', 'performance-fixture-' || lpad(identity::text, 4, '0'),
|
||||
'version', version::text || '.0.0',
|
||||
'tags', jsonb_build_array('performance', 'fixture',
|
||||
case identity % 4 when 0 then 'database' when 1 then 'accessibility' when 2 then 'security' else 'release' end)
|
||||
),
|
||||
'spec', jsonb_build_object(
|
||||
'type', 'guided',
|
||||
'modes', jsonb_build_array('inspect', 'plan'),
|
||||
'defaultMode', 'plan',
|
||||
'autonomy', jsonb_build_object('min', 'observe', 'max', 'verify', 'default', 'plan'),
|
||||
'compatibility', jsonb_build_object('languages', jsonb_build_array('TypeScript')),
|
||||
'intent', jsonb_build_object('problem', 'performance fixture', 'outcome', 'measured result')
|
||||
),
|
||||
'quality', jsonb_build_object('reviewStatus', 'technical-reviewed')
|
||||
),
|
||||
'# Performance fixture\n\nThis deterministic template is data and is never executed.\n',
|
||||
encode(digest('performance-fixture-' || identity || '-' || version, 'sha256'), 'hex'),
|
||||
to_tsvector('simple',
|
||||
case identity % 4
|
||||
when 0 then 'database migration performance fixture'
|
||||
when 1 then 'frontend accessibility performance fixture'
|
||||
when 2 then 'security review performance fixture'
|
||||
else 'release operations performance fixture'
|
||||
end || ' deterministic indexed playbook'),
|
||||
timestamptz '2026-01-01 00:00:00+00' + (version * interval '1 day'),
|
||||
timestamptz '2026-01-01 00:00:00+00'
|
||||
from generate_series(1, ${IDENTITY_COUNT}) identity
|
||||
cross join generate_series(1, ${VERSIONS_PER_IDENTITY}) version
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
async function measure(): Promise<Readonly<Record<string, unknown>>> {
|
||||
const [dataset] = await sql<{ identities: number; versions: number }[]>`
|
||||
select count(distinct p.id)::int as identities, count(v.id)::int as versions
|
||||
from playbooks p join playbook_versions v on v.playbook_id = p.id
|
||||
where p.namespace = 'performance-fixture'
|
||||
`
|
||||
if (
|
||||
dataset?.identities !== IDENTITY_COUNT ||
|
||||
dataset.versions !== VERSION_COUNT
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected ${IDENTITY_COUNT} identities and ${VERSION_COUNT} versions.`,
|
||||
)
|
||||
}
|
||||
const catalog = new DrizzlePlaybookCatalog()
|
||||
const terms = ['database migration', 'accessibility', 'security', 'release']
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await catalog.list({ q: terms[index % terms.length] })
|
||||
}
|
||||
const searchSamples: number[] = []
|
||||
const detailSamples: number[] = []
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const searchStart = performance.now()
|
||||
await catalog.list({ q: terms[index % terms.length] })
|
||||
searchSamples.push(performance.now() - searchStart)
|
||||
const detailStart = performance.now()
|
||||
await catalog.findBySlug(
|
||||
`performance-fixture-${String((index % IDENTITY_COUNT) + 1).padStart(4, '0')}`,
|
||||
'built_in',
|
||||
)
|
||||
detailSamples.push(performance.now() - detailStart)
|
||||
}
|
||||
const metrics = (samples: readonly number[], targetMs: number) => ({
|
||||
samples: samples.length,
|
||||
p50Ms: Number(percentile(samples, 0.5).toFixed(3)),
|
||||
p95Ms: Number(percentile(samples, 0.95).toFixed(3)),
|
||||
p99Ms: Number(percentile(samples, 0.99).toFixed(3)),
|
||||
targetMs,
|
||||
meetsReferenceTarget: percentile(samples, 0.95) < targetMs,
|
||||
})
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
fixture: {
|
||||
identityCount: dataset.identities,
|
||||
versionsPerIdentity: VERSIONS_PER_IDENTITY,
|
||||
versionCount: dataset.versions,
|
||||
digest: createHash('sha256')
|
||||
.update(
|
||||
`devrunbook-performance-v1:${IDENTITY_COUNT}:${VERSIONS_PER_IDENTITY}`,
|
||||
)
|
||||
.digest('hex'),
|
||||
},
|
||||
environment: {
|
||||
applicationCommit: process.env.DEVRUNBOOK_APPLICATION_COMMIT ?? null,
|
||||
databaseName: database.name,
|
||||
databaseVersion: database.version,
|
||||
nodeVersion: process.version,
|
||||
platform: `${platform()} ${release()}`,
|
||||
cpuModel: cpus()[0]?.model ?? 'unknown',
|
||||
cpuCount: cpus().length,
|
||||
totalMemoryBytes: totalmem(),
|
||||
freeMemoryBytesAtCompletion: freemem(),
|
||||
},
|
||||
method: { warmupIterations: 10, measuredIterations: iterations },
|
||||
metrics: {
|
||||
librarySearch: metrics(searchSamples, 500),
|
||||
playbookDetail: metrics(detailSamples, 400),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (seed) await seedDataset()
|
||||
if (benchmark)
|
||||
process.stdout.write(`${JSON.stringify(await measure(), null, 2)}\n`)
|
||||
else
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ seededIdentities: IDENTITY_COUNT, seededVersions: VERSION_COUNT })}\n`,
|
||||
)
|
||||
} finally {
|
||||
await closeDatabase()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
usage() {
|
||||
echo "Usage: restore-empty-target.sh --project devrunbook-*-restore-* --backup /absolute/backup --env-file /absolute/env [--dry-run]" >&2
|
||||
}
|
||||
|
||||
PROJECT=''
|
||||
BACKUP=''
|
||||
ENV_FILE=''
|
||||
DRY_RUN=false
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--project) PROJECT=${2-}; shift 2 ;;
|
||||
--backup) BACKUP=${2-}; shift 2 ;;
|
||||
--env-file) ENV_FILE=${2-}; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
*) usage; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$PROJECT" in devrunbook-*-restore-*) ;; *) echo 'Restore project must match devrunbook-*-restore-*.' >&2; exit 64 ;; esac
|
||||
case "$PROJECT" in *[!a-zA-Z0-9_-]*) echo 'Invalid Compose project name.' >&2; exit 64 ;; esac
|
||||
case "$BACKUP" in /*) ;; *) echo 'Backup path must be absolute.' >&2; exit 64 ;; esac
|
||||
case "$ENV_FILE" in /*) ;; *) echo 'Environment file must be absolute.' >&2; exit 64 ;; esac
|
||||
[ -d "$BACKUP" ] || { echo 'Backup directory does not exist.' >&2; exit 66; }
|
||||
[ -f "$ENV_FILE" ] || { echo 'Environment file does not exist.' >&2; exit 66; }
|
||||
for file in database.dump artifacts.tar.gz operator-content.tar.gz metadata.json SHA256SUMS; do
|
||||
[ -f "$BACKUP/$file" ] || { echo "Backup file missing: $file" >&2; exit 66; }
|
||||
done
|
||||
[ -z "$(find "$BACKUP" -maxdepth 1 -type l -print -quit)" ] || { echo 'Backup directory may not contain symbolic links.' >&2; exit 65; }
|
||||
|
||||
if "$DRY_RUN"; then
|
||||
printf 'Validated empty-target restore request for %s from %s\n' "$PROJECT" "$BACKUP"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for command in docker python3 sha256sum; do
|
||||
command -v "$command" >/dev/null 2>&1 || { echo "Required command missing: $command" >&2; exit 69; }
|
||||
done
|
||||
(
|
||||
cd "$BACKUP"
|
||||
sha256sum --check --strict SHA256SUMS
|
||||
)
|
||||
python3 - "$BACKUP" <<'PY'
|
||||
import json, pathlib, sys, tarfile
|
||||
root = pathlib.Path(sys.argv[1]).resolve(strict=True)
|
||||
with (root / "metadata.json").open(encoding="utf-8") as source:
|
||||
metadata = json.load(source)
|
||||
if metadata.get("schemaVersion") != 1 or metadata.get("secretsIncluded") is not False:
|
||||
raise SystemExit("Unsupported or unsafe backup metadata.")
|
||||
expected = {"database.dump", "artifacts.tar.gz", "operator-content.tar.gz"}
|
||||
files = metadata.get("files")
|
||||
if not isinstance(files, dict) or set(files) != expected:
|
||||
raise SystemExit("Backup metadata file inventory is invalid.")
|
||||
for name, expected_size in files.items():
|
||||
if not isinstance(expected_size, int) or expected_size < 0 or (root / name).stat().st_size != expected_size:
|
||||
raise SystemExit(f"Backup size metadata mismatch: {name}")
|
||||
for name in ("artifacts.tar.gz", "operator-content.tar.gz"):
|
||||
with tarfile.open(root / name, "r:gz") as archive:
|
||||
for member in archive:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk() or member.isdev():
|
||||
raise SystemExit(f"Unsafe archive member in {name}: {member.name}")
|
||||
PY
|
||||
[ -z "$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT")" ] || {
|
||||
echo 'Restore target already has containers; refusing to continue.' >&2; exit 73;
|
||||
}
|
||||
[ -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$PROJECT")" ] || {
|
||||
echo 'Restore target already has volumes; refusing to continue.' >&2; exit 73;
|
||||
}
|
||||
|
||||
compose() { docker compose -p "$PROJECT" --env-file "$ENV_FILE" "$@"; }
|
||||
compose build migrate web worker >/dev/null
|
||||
compose create postgres web worker >/dev/null
|
||||
POSTGRES_CONTAINER=$(compose ps -aq postgres)
|
||||
WEB_CONTAINER=$(compose ps -aq web)
|
||||
POSTGRES_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$POSTGRES_CONTAINER")
|
||||
ARTIFACT_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/artifacts"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER")
|
||||
OPERATOR_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/operator-content"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER")
|
||||
for volume in "$POSTGRES_VOLUME" "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do
|
||||
[ -n "$volume" ] || { echo 'A target volume could not be resolved.' >&2; exit 69; }
|
||||
[ "$(docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$volume")" = "$PROJECT" ] || {
|
||||
echo "Resolved volume $volume is outside the restore project." >&2; exit 69;
|
||||
}
|
||||
done
|
||||
POSTGRES_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER")
|
||||
WEB_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$WEB_CONTAINER")
|
||||
ARCHIVE_UID_GID=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--entrypoint sh "$WEB_IMAGE" -c 'printf "%s:%s" "$(id -u)" "$(id -g)"')
|
||||
case "$ARCHIVE_UID_GID" in *[!0-9:]*) echo 'Web image returned an invalid archive UID/GID.' >&2; exit 69 ;; esac
|
||||
for volume in "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do
|
||||
ENTRY_COUNT=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" -v "$volume:/source:ro" --entrypoint sh "$POSTGRES_IMAGE" -c 'find /source -mindepth 1 -maxdepth 1 -print -quit')
|
||||
[ -z "$ENTRY_COUNT" ] || { echo "Target volume $volume is not empty." >&2; exit 73; }
|
||||
done
|
||||
|
||||
compose start postgres >/dev/null
|
||||
ATTEMPT=0
|
||||
until compose exec -T postgres pg_isready --username devrunbook --dbname devrunbook >/dev/null 2>&1; do
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
[ "$ATTEMPT" -lt 30 ] || { echo 'Target PostgreSQL did not become ready.' >&2; exit 69; }
|
||||
sleep 1
|
||||
done
|
||||
USER_TABLE_COUNT=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \
|
||||
"select count(*) from pg_tables where schemaname not in ('pg_catalog', 'information_schema')")
|
||||
[ "$USER_TABLE_COUNT" -eq 0 ] || { echo 'Target database is not empty.' >&2; exit 73; }
|
||||
|
||||
compose exec -T postgres pg_restore --username devrunbook --dbname devrunbook --exit-on-error --no-owner --no-privileges < "$BACKUP/database.dump"
|
||||
docker run --rm -i --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" -v "$ARTIFACT_VOLUME:/target" --entrypoint tar "$POSTGRES_IMAGE" \
|
||||
-C /target -xzf - < "$BACKUP/artifacts.tar.gz"
|
||||
docker run --rm -i --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$ARCHIVE_UID_GID" -v "$OPERATOR_VOLUME:/target" --entrypoint tar "$POSTGRES_IMAGE" \
|
||||
-C /target -xzf - < "$BACKUP/operator-content.tar.gz"
|
||||
compose up -d migrate
|
||||
compose up -d web worker
|
||||
printf 'Restore completed into isolated project %s. Run application-level verification before acceptance.\n' "$PROJECT"
|
||||
Reference in New Issue
Block a user