@rizom/ops 0.2.0-alpha.35 → 0.2.0-alpha.350
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/brains-ops.js +785 -304
- package/dist/capability-bundle-migration.d.ts +22 -0
- package/dist/cert-bootstrap.d.ts +3 -1
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +1 -0
- package/dist/deploy.js +99 -166
- package/dist/directory-sync-stress-system.d.ts +75 -0
- package/dist/directory-sync-stress.d.ts +107 -0
- package/dist/entries/deploy.d.ts +3 -2
- package/dist/health-watchdog-smoke.d.ts +54 -0
- package/dist/images.d.ts +79 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +784 -302
- package/dist/legacy-pilot-migration.d.ts +11 -0
- package/dist/legacy-projection-job-recovery.d.ts +29 -0
- package/dist/load-registry.d.ts +60 -6
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -10
- package/dist/preview-domain.d.ts +9 -0
- package/dist/push-secrets.d.ts +2 -9
- package/dist/push-target.d.ts +1 -2
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/run-command.d.ts +17 -3
- package/dist/run-subprocess.d.ts +1 -6
- package/dist/schema.d.ts +123 -162
- package/dist/secrets-encrypt.d.ts +7 -13
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/stage-legacy-crossover.d.ts +23 -0
- package/dist/stress-command.d.ts +14 -0
- package/dist/stress-git-checkout.d.ts +21 -0
- package/dist/stress-health-monitor.d.ts +46 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/user-add.d.ts +15 -0
- package/dist/verify-user.d.ts +22 -0
- package/package.json +50 -42
- package/templates/rover-pilot/.env.schema +24 -3
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/build.yml +70 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +71 -57
- package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
- package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
- package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
- package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
- package/templates/rover-pilot/README.md +16 -6
- package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +873 -0
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
- package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
- package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
- package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +28 -17
- package/templates/rover-pilot/docs/operator-playbook.md +307 -29
- package/templates/rover-pilot/docs/user-onboarding.md +48 -463
- package/templates/rover-pilot/pilot.yaml +7 -4
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Dockerfile +0 -30
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
|
@@ -15,13 +15,52 @@ const decrypter = new Decrypter();
|
|
|
15
15
|
decrypter.addIdentity(ageSecretKey);
|
|
16
16
|
|
|
17
17
|
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
-
const secrets =
|
|
19
|
-
const pilot =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
const secrets = parseStringYamlMapping(plaintext);
|
|
19
|
+
const pilot = parseStringYamlMapping(readFileSync("pilot.yaml", "utf8"));
|
|
20
|
+
|
|
21
|
+
writeSecretGitHubEnv("AI_API_KEY", secrets["aiApiKey"]);
|
|
22
|
+
writeSecretGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"]);
|
|
23
|
+
writeSecretGitHubEnv(
|
|
24
|
+
"STUDIO_CONTENT_REPO_PAT",
|
|
25
|
+
secrets["studioContentRepoPat"] ?? secrets["gitSyncToken"],
|
|
26
|
+
);
|
|
27
|
+
writeSecretGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"]);
|
|
28
|
+
writeSecretGitHubEnv("DISCORD_PUBLIC_KEY", secrets["discordPublicKey"]);
|
|
29
|
+
writeSecretGitHubEnv("DISCORD_APPLICATION_ID", secrets["discordApplicationId"]);
|
|
30
|
+
// Per-user AT Protocol publishing credential (optional; from the user's
|
|
31
|
+
// encrypted secrets file). The scaffold wires this so a pilot can publish its
|
|
32
|
+
// brain's agent card to its PDS; a deployment that doesn't publish simply
|
|
33
|
+
// leaves it unset.
|
|
34
|
+
writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
|
|
35
|
+
// Per-user custom-domain TLS overrides the shared fleet certificate when a
|
|
36
|
+
// complete certificate/key pair is present in the encrypted user secrets.
|
|
37
|
+
const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
|
|
38
|
+
const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
|
|
39
|
+
if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Custom-domain TLS secrets require both certificatePem and privateKeyPem",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
// A corrupted stored value would otherwise only surface as a kamal-proxy
|
|
45
|
+
// "unable to load certificate" failure after the container has already booted.
|
|
46
|
+
assertPemShape("certificatePem", certificatePem);
|
|
47
|
+
assertPemShape("privateKeyPem", privateKeyPem);
|
|
48
|
+
writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
|
|
49
|
+
writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
|
|
50
|
+
|
|
51
|
+
function assertPemShape(name: string, value: string | undefined): void {
|
|
52
|
+
if (value === undefined || value.trim().length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
|
|
57
|
+
!/-----END [A-Z0-9 ]+-----/.test(value)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
25
64
|
|
|
26
65
|
writeGitHubOutput(
|
|
27
66
|
"shared_ai_api_key_secret_name",
|
|
@@ -32,10 +71,29 @@ writeGitHubOutput(
|
|
|
32
71
|
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
72
|
);
|
|
34
73
|
writeGitHubOutput(
|
|
35
|
-
"
|
|
36
|
-
requireFlatValue(pilot, "
|
|
74
|
+
"shared_content_repo_admin_token_secret_name",
|
|
75
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
37
76
|
);
|
|
38
77
|
|
|
78
|
+
function writeSecretGitHubEnv(name: string, value: string | undefined): void {
|
|
79
|
+
if (!value || value.trim().length === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
maskGitHubSecret(value);
|
|
84
|
+
writeGitHubEnv(name, value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function maskGitHubSecret(value: string): void {
|
|
88
|
+
const escaped = value
|
|
89
|
+
.replace(/%/g, "%25")
|
|
90
|
+
.replace(/\r/g, "%0D")
|
|
91
|
+
.replace(/\n/g, "%0A");
|
|
92
|
+
if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
93
|
+
console.log(`::add-mask::${escaped}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
39
97
|
function extractAgeIdentity(contents: string): string {
|
|
40
98
|
const line = contents
|
|
41
99
|
.split(/\r?\n/)
|
|
@@ -49,27 +107,25 @@ function extractAgeIdentity(contents: string): string {
|
|
|
49
107
|
return line;
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!line || line.startsWith("#")) {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
110
|
+
function parseStringYamlMapping(contents: string): Record<string, string> {
|
|
111
|
+
const parsed: unknown = Bun.YAML.parse(contents);
|
|
112
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
113
|
+
throw new Error("Expected a YAML mapping");
|
|
114
|
+
}
|
|
60
115
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
const result: Record<string, string> = {};
|
|
117
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
result[key] = value;
|
|
64
120
|
}
|
|
65
|
-
|
|
66
|
-
const [, key, rawValue] = match;
|
|
67
|
-
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
68
121
|
}
|
|
69
|
-
|
|
70
122
|
return result;
|
|
71
123
|
}
|
|
72
124
|
|
|
125
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
126
|
+
return value?.replace(/\\n/g, "\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
73
129
|
function requireFlatValue(
|
|
74
130
|
values: Record<string, string>,
|
|
75
131
|
key: string,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { requireEnv } from "./helpers";
|
|
2
|
+
|
|
3
|
+
const WATCHDOG_PATH = "/usr/local/sbin/brains-health-watchdog";
|
|
4
|
+
const SERVICE_PATH = "/etc/systemd/system/brains-health-watchdog.service";
|
|
5
|
+
const TIMER_PATH = "/etc/systemd/system/brains-health-watchdog.timer";
|
|
6
|
+
|
|
7
|
+
export const BRAIN_WATCHDOG_LABEL = "ai.rizom.brain.watchdog";
|
|
8
|
+
export const BRAIN_WATCHDOG_LABEL_VALUE = "true";
|
|
9
|
+
export const BRAIN_WATCHDOG_LABEL_FILTER: string = `${BRAIN_WATCHDOG_LABEL}=${BRAIN_WATCHDOG_LABEL_VALUE}`;
|
|
10
|
+
|
|
11
|
+
export const healthWatchdogScript: string = `#!/usr/bin/env bash
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
umask 077
|
|
14
|
+
|
|
15
|
+
lock_path="\${BRAIN_WATCHDOG_LOCK_PATH:-/run/brains-health-watchdog.lock}"
|
|
16
|
+
exec 9>"$lock_path"
|
|
17
|
+
flock -n 9 || exit 0
|
|
18
|
+
|
|
19
|
+
incident_dir="\${BRAIN_WATCHDOG_INCIDENT_DIR:-/var/log/brains-health-watchdog}"
|
|
20
|
+
state_dir="\${BRAIN_WATCHDOG_STATE_DIR:-/var/lib/brains-health-watchdog}"
|
|
21
|
+
MAX_RESTARTS=3
|
|
22
|
+
WINDOW_SECONDS=3600
|
|
23
|
+
mkdir -p "$incident_dir" "$state_dir"
|
|
24
|
+
|
|
25
|
+
write_safe_inspect() {
|
|
26
|
+
docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"state":{{json .State}},"restartPolicy":{{json .HostConfig.RestartPolicy}}}' "$1"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
while read -r container_id; do
|
|
30
|
+
[ -n "$container_id" ] || continue
|
|
31
|
+
service_name=$(docker inspect --format '{{ index .Config.Labels "service" }}' "$container_id" 2>/dev/null || true)
|
|
32
|
+
container_name=$(docker inspect --format '{{ .Name }}' "$container_id" 2>/dev/null | tr -d '/' || true)
|
|
33
|
+
state_key=$(printf '%s-%s-%s' "$service_name" "$container_name" "$container_id" | tr -c 'A-Za-z0-9_.-' '_')
|
|
34
|
+
state_path="$state_dir/$state_key.restarts"
|
|
35
|
+
recent_path="$state_path.tmp"
|
|
36
|
+
now_epoch=$(date -u +%s)
|
|
37
|
+
cutoff_epoch=$((now_epoch - WINDOW_SECONDS))
|
|
38
|
+
touch "$state_path"
|
|
39
|
+
awk -v cutoff="$cutoff_epoch" '$1 >= cutoff' "$state_path" >"$recent_path"
|
|
40
|
+
restart_count=$(wc -l <"$recent_path")
|
|
41
|
+
|
|
42
|
+
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
|
43
|
+
incident_path="$incident_dir/$timestamp-$state_key.log"
|
|
44
|
+
temporary_path="$incident_path.tmp"
|
|
45
|
+
|
|
46
|
+
if [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
|
|
47
|
+
{
|
|
48
|
+
printf '{"timestamp":"%s","event":"restart-suppressed","reason":"restart-budget-exhausted","service":"%s","container":"%s","restartsInWindow":%s}\\n' "$timestamp" "$service_name" "$container_name" "$restart_count"
|
|
49
|
+
write_safe_inspect "$container_id"
|
|
50
|
+
docker logs --timestamps --tail 1000 "$container_id"
|
|
51
|
+
} >"$temporary_path" 2>&1
|
|
52
|
+
mv "$temporary_path" "$incident_path"
|
|
53
|
+
mv "$recent_path" "$state_path"
|
|
54
|
+
logger -t brains-health-watchdog "restart budget exhausted container=$container_name service=$service_name incident=$incident_path"
|
|
55
|
+
continue
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
printf '{"timestamp":"%s","event":"container-restart","reason":"docker-health-unhealthy","service":"%s","container":"%s"}\\n' "$timestamp" "$service_name" "$container_name"
|
|
60
|
+
write_safe_inspect "$container_id"
|
|
61
|
+
docker logs --timestamps --tail 1000 "$container_id"
|
|
62
|
+
} >"$temporary_path" 2>&1
|
|
63
|
+
mv "$temporary_path" "$incident_path"
|
|
64
|
+
printf '%s\\n' "$now_epoch" >>"$recent_path"
|
|
65
|
+
mv "$recent_path" "$state_path"
|
|
66
|
+
|
|
67
|
+
logger -t brains-health-watchdog "restarting unhealthy container=$container_name service=$service_name incident=$incident_path"
|
|
68
|
+
docker restart "$container_id"
|
|
69
|
+
done < <(docker ps --filter health=unhealthy --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}')
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export const healthWatchdogServiceUnit: string = `[Unit]
|
|
73
|
+
Description=Restart persistently unhealthy Brain containers
|
|
74
|
+
After=docker.service
|
|
75
|
+
Requires=docker.service
|
|
76
|
+
|
|
77
|
+
[Service]
|
|
78
|
+
Type=oneshot
|
|
79
|
+
ExecStart=${WATCHDOG_PATH}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export const healthWatchdogTimerUnit: string = `[Unit]
|
|
83
|
+
Description=Monitor Brain container liveness
|
|
84
|
+
|
|
85
|
+
[Timer]
|
|
86
|
+
OnBootSec=2min
|
|
87
|
+
OnUnitActiveSec=30s
|
|
88
|
+
RandomizedDelaySec=5s
|
|
89
|
+
Persistent=true
|
|
90
|
+
Unit=brains-health-watchdog.service
|
|
91
|
+
|
|
92
|
+
[Install]
|
|
93
|
+
WantedBy=timers.target
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
function installCommand(path: string, content: string, mode: string): string {
|
|
97
|
+
const encoded = Buffer.from(content, "utf8").toString("base64");
|
|
98
|
+
return `printf '%s' '${encoded}' | $SUDO base64 --decode | $SUDO tee '${path}' >/dev/null\n$SUDO chmod ${mode} '${path}'`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildHealthWatchdogInstallScript(): string {
|
|
102
|
+
return `#!/usr/bin/env bash
|
|
103
|
+
set -euo pipefail
|
|
104
|
+
SUDO=""
|
|
105
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
106
|
+
SUDO=sudo
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
${installCommand(WATCHDOG_PATH, healthWatchdogScript, "0755")}
|
|
110
|
+
${installCommand(SERVICE_PATH, healthWatchdogServiceUnit, "0644")}
|
|
111
|
+
${installCommand(TIMER_PATH, healthWatchdogTimerUnit, "0644")}
|
|
112
|
+
|
|
113
|
+
$SUDO systemctl daemon-reload
|
|
114
|
+
$SUDO systemctl enable --now brains-health-watchdog.timer
|
|
115
|
+
|
|
116
|
+
labelled_containers=""
|
|
117
|
+
if ! labelled_containers=$($SUDO docker ps --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}' 2>/dev/null); then
|
|
118
|
+
echo "Warning: unable to verify running Brain containers with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
|
|
119
|
+
elif [ -z "$labelled_containers" ]; then
|
|
120
|
+
echo "Warning: no running Brain containers found with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
|
|
121
|
+
fi
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function installHealthWatchdog(): Promise<void> {
|
|
126
|
+
const serverIp = requireEnv("SERVER_IP");
|
|
127
|
+
const sshUser = requireEnv("SSH_USER").trim();
|
|
128
|
+
if (!sshUser) throw new Error("Missing SSH_USER");
|
|
129
|
+
const child = Bun.spawn(["ssh", `${sshUser}@${serverIp}`, "bash -s"], {
|
|
130
|
+
stdin: new Blob([buildHealthWatchdogInstallScript()]),
|
|
131
|
+
stdout: "inherit",
|
|
132
|
+
stderr: "inherit",
|
|
133
|
+
});
|
|
134
|
+
const exitCode = await child.exited;
|
|
135
|
+
if (exitCode !== 0) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Health watchdog installation failed with exit ${exitCode}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (import.meta.main) {
|
|
143
|
+
await installHealthWatchdog();
|
|
144
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireEnv,
|
|
3
|
+
runResolveMissingImages,
|
|
4
|
+
writeGitHubOutput,
|
|
5
|
+
} from "./helpers";
|
|
6
|
+
|
|
7
|
+
// The Build workflow's resolve step — the logic lives in @rizom/ops; this
|
|
8
|
+
// pilot repo only supplies its own paths and repository.
|
|
9
|
+
await runResolveMissingImages({
|
|
10
|
+
rootDir: process.cwd(),
|
|
11
|
+
imageRepository: `ghcr.io/${requireEnv("GITHUB_REPOSITORY")}`,
|
|
12
|
+
writeOutput: writeGitHubOutput,
|
|
13
|
+
});
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseEnvFile,
|
|
7
|
+
requireEnv,
|
|
8
|
+
sitePackagesFor,
|
|
9
|
+
siteImageTag,
|
|
10
|
+
writeGitHubOutput,
|
|
11
|
+
} from "./helpers";
|
|
4
12
|
|
|
5
13
|
const handle = requireEnv("HANDLE");
|
|
6
14
|
const envPath = `users/${handle}/.env`;
|
|
@@ -17,23 +25,42 @@ if (!brainDomain) {
|
|
|
17
25
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!zone) {
|
|
25
|
-
throw new Error(`Could not derive preview domain from ${brainDomain}`);
|
|
28
|
+
const registry = await loadPilotRegistry(process.cwd());
|
|
29
|
+
const user = registry.users.find((entry) => entry.handle === handle);
|
|
30
|
+
if (!user) {
|
|
31
|
+
throw new Error(`Unknown user handle: ${handle}`);
|
|
26
32
|
}
|
|
27
|
-
const previewDomain =
|
|
33
|
+
const previewDomain = derivePreviewDomain(brainDomain, {
|
|
34
|
+
sharedDomain: registry.pilot.domainSuffix,
|
|
35
|
+
});
|
|
36
|
+
const wwwDomain = isFleetDomain(
|
|
37
|
+
handle,
|
|
38
|
+
brainDomain,
|
|
39
|
+
registry.pilot.domainSuffix,
|
|
40
|
+
)
|
|
41
|
+
? ""
|
|
42
|
+
: `www.${brainDomain}`;
|
|
43
|
+
|
|
44
|
+
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
45
|
+
|
|
46
|
+
// The image tag is a pure function of this instance's own config: plain
|
|
47
|
+
// `brain-{version}` for a default instance, or its own `brain-{version}-sites-
|
|
48
|
+
// {hash}` when it declares a siteOverride. Resolved through the same helper the
|
|
49
|
+
// build uses so the tag we wait for and run matches exactly what was pushed.
|
|
50
|
+
const sitePackages = sitePackagesFor(user.siteOverride);
|
|
51
|
+
const imageTag = siteImageTag(brainVersion, sitePackages);
|
|
28
52
|
|
|
29
53
|
const outputs: Record<string, string> = {
|
|
30
|
-
brain_version:
|
|
54
|
+
brain_version: brainVersion,
|
|
31
55
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
32
56
|
brain_domain: brainDomain,
|
|
33
57
|
preview_domain: previewDomain,
|
|
58
|
+
www_domain: wwwDomain,
|
|
59
|
+
cloudflare_zone_id: user.cloudflareZoneId ?? process.env["CF_ZONE_ID"] ?? "",
|
|
34
60
|
brain_yaml_path: brainYamlPath,
|
|
35
61
|
instance_name: `rover-${handle}`,
|
|
36
62
|
image_repository: `ghcr.io/${repository}`,
|
|
63
|
+
image_tag: imageTag,
|
|
37
64
|
registry_username: repositoryOwner,
|
|
38
65
|
};
|
|
39
66
|
|
|
@@ -47,3 +74,11 @@ for (const key of required) {
|
|
|
47
74
|
for (const [key, value] of Object.entries(outputs)) {
|
|
48
75
|
writeGitHubOutput(key, value);
|
|
49
76
|
}
|
|
77
|
+
|
|
78
|
+
function isFleetDomain(
|
|
79
|
+
userHandle: string,
|
|
80
|
+
domain: string,
|
|
81
|
+
pilotDomainSuffix: string,
|
|
82
|
+
): boolean {
|
|
83
|
+
return domain === `${userHandle}${pilotDomainSuffix}`;
|
|
84
|
+
}
|
|
@@ -5,53 +5,6 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { readJsonResponse, requireEnv } from "./helpers";
|
|
7
7
|
|
|
8
|
-
const handle = requireEnv("HANDLE");
|
|
9
|
-
const contentRepo = requireEnv("CONTENT_REPO");
|
|
10
|
-
const token = requireEnv("GIT_SYNC_TOKEN");
|
|
11
|
-
const sourceDir = join("users", handle, "content");
|
|
12
|
-
|
|
13
|
-
if (!existsSync(sourceDir)) {
|
|
14
|
-
process.exit(0);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const { owner, repo } = parseRepoSlug(contentRepo);
|
|
18
|
-
await ensureGitHubRepo({ owner, repo, token });
|
|
19
|
-
|
|
20
|
-
const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
|
|
21
|
-
const checkoutDir = join(tempRoot, "repo");
|
|
22
|
-
const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
|
|
23
|
-
|
|
24
|
-
runGit(["clone", remoteUrl, checkoutDir]);
|
|
25
|
-
runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
|
|
26
|
-
|
|
27
|
-
const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
|
|
28
|
-
if (copiedFiles === 0) {
|
|
29
|
-
process.exit(0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
|
|
33
|
-
runGit([
|
|
34
|
-
"-C",
|
|
35
|
-
checkoutDir,
|
|
36
|
-
"config",
|
|
37
|
-
"user.email",
|
|
38
|
-
"41898282+github-actions[bot]@users.noreply.github.com",
|
|
39
|
-
]);
|
|
40
|
-
runGit(["-C", checkoutDir, "add", "."]);
|
|
41
|
-
|
|
42
|
-
if (hasNoStagedChanges(checkoutDir)) {
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
runGit([
|
|
47
|
-
"-C",
|
|
48
|
-
checkoutDir,
|
|
49
|
-
"commit",
|
|
50
|
-
"-m",
|
|
51
|
-
`chore(content): seed ${handle} anchor profile`,
|
|
52
|
-
]);
|
|
53
|
-
runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
|
|
54
|
-
|
|
55
8
|
const STALE_ANCHOR_PROFILE_MARKERS = [
|
|
56
9
|
"name: Your Name Here",
|
|
57
10
|
"Delete this and write your own",
|
|
@@ -68,6 +21,55 @@ interface GitHubRepoResponse {
|
|
|
68
21
|
private?: boolean;
|
|
69
22
|
}
|
|
70
23
|
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
const handle = requireEnv("HANDLE");
|
|
26
|
+
const contentRepo = requireEnv("CONTENT_REPO");
|
|
27
|
+
const token = requireEnv("GIT_SYNC_TOKEN");
|
|
28
|
+
const sourceDir = join("users", handle, "content");
|
|
29
|
+
|
|
30
|
+
if (!existsSync(sourceDir)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { owner, repo } = parseRepoSlug(contentRepo);
|
|
35
|
+
await ensureGitHubRepo({ owner, repo, token });
|
|
36
|
+
|
|
37
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
|
|
38
|
+
const checkoutDir = join(tempRoot, "repo");
|
|
39
|
+
const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
|
|
40
|
+
|
|
41
|
+
runGit(["clone", remoteUrl, checkoutDir]);
|
|
42
|
+
runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
|
|
43
|
+
|
|
44
|
+
const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
|
|
45
|
+
if (copiedFiles === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
|
|
50
|
+
runGit([
|
|
51
|
+
"-C",
|
|
52
|
+
checkoutDir,
|
|
53
|
+
"config",
|
|
54
|
+
"user.email",
|
|
55
|
+
"41898282+github-actions[bot]@users.noreply.github.com",
|
|
56
|
+
]);
|
|
57
|
+
runGit(["-C", checkoutDir, "add", "."]);
|
|
58
|
+
|
|
59
|
+
if (hasNoStagedChanges(checkoutDir)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
runGit([
|
|
64
|
+
"-C",
|
|
65
|
+
checkoutDir,
|
|
66
|
+
"commit",
|
|
67
|
+
"-m",
|
|
68
|
+
`chore(content): seed ${handle} anchor profile`,
|
|
69
|
+
]);
|
|
70
|
+
runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
|
|
71
|
+
}
|
|
72
|
+
|
|
71
73
|
async function ensureGitHubRepo(
|
|
72
74
|
options: EnsureGitHubRepoOptions,
|
|
73
75
|
): Promise<void> {
|
|
@@ -177,3 +179,5 @@ function isStaleAnchorProfile(content: string): boolean {
|
|
|
177
179
|
content.includes(marker),
|
|
178
180
|
);
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
await main();
|
|
@@ -16,8 +16,11 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function
|
|
20
|
-
|
|
19
|
+
async function findRecordId(
|
|
20
|
+
name: string,
|
|
21
|
+
type: "A" | "CNAME",
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
21
24
|
const lookup = await fetch(lookupUrl, { headers });
|
|
22
25
|
const payload = (await readJsonResponse(
|
|
23
26
|
lookup,
|
|
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
27
30
|
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
return payload.result?.[0]?.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function upsertRecord(name: string): Promise<void> {
|
|
37
|
+
// Prefer an existing A record. If the hostname currently has a CNAME,
|
|
38
|
+
// replace that CNAME in-place so deploys can claim legacy www aliases.
|
|
39
|
+
const existing =
|
|
40
|
+
(await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
|
|
31
41
|
const url = existing
|
|
32
|
-
? `${baseUrl}/zones/${zoneId}/dns_records/${existing
|
|
42
|
+
? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
|
|
33
43
|
: `${baseUrl}/zones/${zoneId}/dns_records`;
|
|
34
44
|
|
|
35
45
|
const response = await fetch(url, {
|
|
@@ -4,7 +4,7 @@ import { parseEnvSchema } from "./helpers";
|
|
|
4
4
|
const envSchemaPath = ".env.schema";
|
|
5
5
|
const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
|
|
6
6
|
const requiredKeys = schema
|
|
7
|
-
.filter((entry) => entry.required)
|
|
7
|
+
.filter((entry) => entry.required && entry.key !== "BWS_ACCESS_TOKEN")
|
|
8
8
|
.map((entry) => entry.key);
|
|
9
9
|
|
|
10
10
|
const missing: string[] = [];
|
|
@@ -14,6 +14,17 @@ for (const key of requiredKeys) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const discordKeys = [
|
|
18
|
+
"DISCORD_BOT_TOKEN",
|
|
19
|
+
"DISCORD_PUBLIC_KEY",
|
|
20
|
+
"DISCORD_APPLICATION_ID",
|
|
21
|
+
];
|
|
22
|
+
if (discordKeys.some((key) => process.env[key])) {
|
|
23
|
+
for (const key of discordKeys) {
|
|
24
|
+
if (!process.env[key]) missing.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
if (missing.length > 0) {
|
|
18
29
|
throw new Error(`Missing required secrets: ${missing.join(", ")}`);
|
|
19
30
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Canonical Crossover Approval Record
|
|
2
|
+
|
|
3
|
+
> Draft evidence record only. Completing this file does not authorize a merge, publish, reconcile, or deployment.
|
|
4
|
+
|
|
5
|
+
Do not include secret values, private keys, access tokens, or decrypted user configuration.
|
|
6
|
+
|
|
7
|
+
## Authorization and freeze
|
|
8
|
+
|
|
9
|
+
- Operator:
|
|
10
|
+
- Approval reference:
|
|
11
|
+
- Approved window:
|
|
12
|
+
- Freeze start:
|
|
13
|
+
- Build workflow disabled and idle:
|
|
14
|
+
- Reconcile workflow disabled and idle:
|
|
15
|
+
- Deploy workflow disabled and idle:
|
|
16
|
+
|
|
17
|
+
## Reviewed inputs
|
|
18
|
+
|
|
19
|
+
- `brains` crossover commit:
|
|
20
|
+
- Private-pilot source commit used for staging:
|
|
21
|
+
- Canonical review commit:
|
|
22
|
+
- Secret-free review diff SHA-256:
|
|
23
|
+
- Identity-review evidence SHA-256:
|
|
24
|
+
- Reviewed source-to-target bundle manifest SHA-256:
|
|
25
|
+
- Reviewed hosted-site pin manifest SHA-256:
|
|
26
|
+
- Source and review worktrees clean:
|
|
27
|
+
|
|
28
|
+
## Hosted site and theme pins
|
|
29
|
+
|
|
30
|
+
List every hosted site. External site and theme package versions must be exact and must
|
|
31
|
+
match the staged user desired state; do not infer them from the brain or from each other.
|
|
32
|
+
|
|
33
|
+
| Handle | Site package | Exact site version | Theme package | Exact theme version | Package/image evidence |
|
|
34
|
+
| ------ | ------------ | ------------------ | ------------- | ------------------- | ---------------------- |
|
|
35
|
+
| | | | | | |
|
|
36
|
+
|
|
37
|
+
## Forward artifact pins
|
|
38
|
+
|
|
39
|
+
| Artifact | Exact version | Registry integrity or digest | Verified installable |
|
|
40
|
+
| ---------------------- | ------------- | ---------------------------- | -------------------- |
|
|
41
|
+
| `@rizom/brain` | | | |
|
|
42
|
+
| `@rizom/ops` | | | |
|
|
43
|
+
| private-pilot lockfile | n/a | | |
|
|
44
|
+
|
|
45
|
+
Record every image. Tags alone are not immutable evidence.
|
|
46
|
+
|
|
47
|
+
| Cohort/handle | Config commit | Image repository | Image tag | Image digest |
|
|
48
|
+
| ------------- | ------------- | ---------------- | --------- | ------------ |
|
|
49
|
+
| | | | | |
|
|
50
|
+
|
|
51
|
+
## Rollback pair
|
|
52
|
+
|
|
53
|
+
- Prior private-pilot commit:
|
|
54
|
+
- Prior `@rizom/ops` version and registry integrity:
|
|
55
|
+
|
|
56
|
+
| Cohort/handle | Prior config commit | Prior image repository | Prior image tag | Prior image digest |
|
|
57
|
+
| ------------- | ------------------- | ---------------------- | --------------- | ------------------ |
|
|
58
|
+
| | | | | |
|
|
59
|
+
|
|
60
|
+
## Identity review
|
|
61
|
+
|
|
62
|
+
- Repository and image names unchanged or explicitly approved:
|
|
63
|
+
- GitHub organization and content repository identities unchanged:
|
|
64
|
+
- Server, domain, Cloudflare zone, and ATProto identities unchanged:
|
|
65
|
+
- Secret selector names and encrypted secret artifacts unchanged:
|
|
66
|
+
- Per-user runtime-version and site-package tag inputs reviewed:
|
|
67
|
+
- No plaintext source secrets copied into the review artifact:
|
|
68
|
+
- Every generated config declares `bundleContract: capability-bundles-v1`:
|
|
69
|
+
- Pilot and explicit cohort source selections exactly matched the reviewed manifest:
|
|
70
|
+
- No removed model, preset, or old-format schema discriminator remains:
|
|
71
|
+
- Every external hosted site and theme package has an exact reviewed pin:
|
|
72
|
+
|
|
73
|
+
## Offline convergence
|
|
74
|
+
|
|
75
|
+
Command:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bunx brains-ops reconcile-all <canonical-review-copy> --dry-run
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- First-pass reconciler-owned changed files:
|
|
82
|
+
- Second-pass reconciler-owned changed files (must be zero):
|
|
83
|
+
- Observational `views/users.md` unchanged by reconciliation:
|
|
84
|
+
- Review copy unchanged:
|
|
85
|
+
- External content-repository access blocked:
|
|
86
|
+
|
|
87
|
+
## Pre-window validation
|
|
88
|
+
|
|
89
|
+
- Package tests, typecheck, lint, build, and packed-consumer startup:
|
|
90
|
+
- Architecture and dependency boundaries:
|
|
91
|
+
- Environment-schema and workspace checks:
|
|
92
|
+
- Crossover migration and comment preservation:
|
|
93
|
+
- Canary-first order and health checks reviewed:
|
|
94
|
+
- Paired rollback reviewed:
|
|
95
|
+
|
|
96
|
+
## Execution record
|
|
97
|
+
|
|
98
|
+
Complete only inside the explicitly approved maintenance window.
|
|
99
|
+
|
|
100
|
+
- Unified packages published and verified:
|
|
101
|
+
- Canonical desired state committed while automation remained frozen:
|
|
102
|
+
- Image digest set matched this record:
|
|
103
|
+
- First reconcile reviewed:
|
|
104
|
+
- Per-instance health, MCP authorization, identity, content, and site checks:
|
|
105
|
+
- Second reconcile produced zero reconciler-owned drift and no deploy work:
|
|
106
|
+
- Freeze lifted:
|
|
107
|
+
- One-week soak start:
|