@rizom/ops 0.2.0-alpha.4 → 0.2.0-alpha.400
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 +88 -4
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +824 -156
- package/dist/capability-bundle-migration.d.ts +22 -0
- package/dist/cert-bootstrap.d.ts +5 -3
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +13 -0
- package/dist/default-user-runner.d.ts +1 -1
- package/dist/deploy.js +110 -166
- package/dist/directory-sync-stress-system.d.ts +75 -0
- package/dist/directory-sync-stress.d.ts +117 -0
- package/dist/entries/deploy.d.ts +4 -2
- package/dist/health-watchdog-smoke.d.ts +54 -0
- package/dist/image-inventory.d.ts +4 -0
- package/dist/image-types.d.ts +7 -0
- package/dist/images.d.ts +82 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +836 -156
- 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 +78 -7
- package/dist/observed-status.d.ts +12 -0
- package/dist/onboard-user.d.ts +2 -2
- package/dist/origin-ca.d.ts +2 -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-all.d.ts +2 -2
- package/dist/reconcile-cohort.d.ts +2 -2
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/reconcile-lib.d.ts +4 -2
- package/dist/run-command.d.ts +21 -4
- package/dist/run-subprocess.d.ts +1 -5
- package/dist/schema.d.ts +132 -69
- package/dist/secrets-encrypt.d.ts +26 -0
- package/dist/secrets-push.d.ts +1 -1
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/stage-legacy-crossover.d.ts +25 -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/user-offboard.d.ts +61 -0
- package/dist/user-runner.d.ts +5 -0
- package/dist/verify-user.d.ts +22 -0
- package/package.json +50 -40
- package/templates/rover-pilot/.env.schema +28 -2
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/build.yml +71 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +121 -45
- 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/offboard.yml +107 -0
- package/templates/rover-pilot/.github/workflows/reconcile.yml +23 -3
- package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
- package/templates/rover-pilot/README.md +19 -8
- package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +894 -0
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +139 -0
- package/templates/rover-pilot/deploy/scripts/helpers.ts +2 -0
- package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +52 -22
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +19 -4
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +45 -14
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +183 -0
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +72 -16
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
- package/templates/rover-pilot/deploy/scripts/verify-runtime-image.ts +18 -0
- package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +39 -10
- package/templates/rover-pilot/docs/operator-playbook.md +390 -14
- package/templates/rover-pilot/docs/user-onboarding.md +90 -0
- package/templates/rover-pilot/package.json +4 -0
- package/templates/rover-pilot/pilot.yaml +9 -3
- package/templates/rover-pilot/users/alice.yaml +5 -1
- package/dist/user-secret-names.d.ts +0 -6
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Dockerfile +0 -15
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { Decrypter, armor } from "age-encryption";
|
|
4
|
+
|
|
5
|
+
import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
|
|
6
|
+
|
|
7
|
+
const handle = process.argv[2] ?? requireEnv("HANDLE");
|
|
8
|
+
const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
|
|
9
|
+
const encryptedPath = `users/${handle}.secrets.yaml.age`;
|
|
10
|
+
|
|
11
|
+
const armored = readFileSync(encryptedPath, "utf8");
|
|
12
|
+
const decoded = armor.decode(armored);
|
|
13
|
+
|
|
14
|
+
const decrypter = new Decrypter();
|
|
15
|
+
decrypter.addIdentity(ageSecretKey);
|
|
16
|
+
|
|
17
|
+
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
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
|
+
}
|
|
64
|
+
|
|
65
|
+
writeGitHubOutput(
|
|
66
|
+
"shared_ai_api_key_secret_name",
|
|
67
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
68
|
+
);
|
|
69
|
+
writeGitHubOutput(
|
|
70
|
+
"shared_git_sync_token_secret_name",
|
|
71
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
72
|
+
);
|
|
73
|
+
writeGitHubOutput(
|
|
74
|
+
"shared_content_repo_admin_token_secret_name",
|
|
75
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
76
|
+
);
|
|
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
|
+
|
|
97
|
+
function extractAgeIdentity(contents: string): string {
|
|
98
|
+
const line = contents
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.map((entry) => entry.trim())
|
|
101
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
102
|
+
|
|
103
|
+
if (!line) {
|
|
104
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return line;
|
|
108
|
+
}
|
|
109
|
+
|
|
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
|
+
}
|
|
115
|
+
|
|
116
|
+
const result: Record<string, string> = {};
|
|
117
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
result[key] = value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
126
|
+
return value?.replace(/\\n/g, "\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireFlatValue(
|
|
130
|
+
values: Record<string, string>,
|
|
131
|
+
key: string,
|
|
132
|
+
label: string,
|
|
133
|
+
): string {
|
|
134
|
+
const value = values[key];
|
|
135
|
+
if (!value) {
|
|
136
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
|
|
|
30
30
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* These scripts are copied verbatim into generated projects, so they stay
|
|
35
|
+
* dependency-free: the API payloads are checked by hand rather than with a
|
|
36
|
+
* schema library. readJsonResponse returns unknown; asserting the Hetzner
|
|
37
|
+
* response shape onto it meant a changed or error payload surfaced as an
|
|
38
|
+
* undefined field later instead of a failed lookup here.
|
|
39
|
+
*/
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isHetznerServer(value: unknown): value is HetznerServer {
|
|
45
|
+
if (!isRecord(value)) return false;
|
|
46
|
+
if (typeof value["id"] !== "number") return false;
|
|
47
|
+
if (typeof value["status"] !== "string") return false;
|
|
48
|
+
const publicNet = value["public_net"];
|
|
49
|
+
if (publicNet === undefined) return true;
|
|
50
|
+
if (!isRecord(publicNet)) return false;
|
|
51
|
+
const ipv4 = publicNet["ipv4"];
|
|
52
|
+
if (ipv4 === undefined) return true;
|
|
53
|
+
if (!isRecord(ipv4)) return false;
|
|
54
|
+
const ip = ipv4["ip"];
|
|
55
|
+
return ip === undefined || typeof ip === "string";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readServer(payload: unknown): HetznerServer | undefined {
|
|
59
|
+
if (!isRecord(payload)) return undefined;
|
|
60
|
+
const server = payload["server"];
|
|
61
|
+
return isHetznerServer(server) ? server : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readServers(payload: unknown): HetznerServer[] | undefined {
|
|
65
|
+
if (!isRecord(payload)) return undefined;
|
|
66
|
+
const servers = payload["servers"];
|
|
67
|
+
return Array.isArray(servers) && servers.every(isHetznerServer)
|
|
68
|
+
? servers
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
async function listServers(): Promise<HetznerServer[]> {
|
|
34
73
|
const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
|
|
35
74
|
const response = await fetch(url, { headers });
|
|
36
|
-
const payload =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)) as {
|
|
40
|
-
servers?: HetznerServer[];
|
|
41
|
-
};
|
|
42
|
-
if (!response.ok || !payload.servers) {
|
|
75
|
+
const payload = await readJsonResponse(response, "Hetzner server lookup");
|
|
76
|
+
const servers = readServers(payload);
|
|
77
|
+
if (!response.ok || !servers) {
|
|
43
78
|
throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
|
|
44
79
|
}
|
|
45
|
-
return
|
|
80
|
+
return servers;
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
async function createServer(): Promise<HetznerServer> {
|
|
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
|
|
|
58
93
|
labels: { brain: instanceName },
|
|
59
94
|
}),
|
|
60
95
|
});
|
|
61
|
-
const payload =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)) as {
|
|
65
|
-
server?: HetznerServer;
|
|
66
|
-
};
|
|
67
|
-
if (!response.ok || !payload.server) {
|
|
96
|
+
const payload = await readJsonResponse(response, "Hetzner server create");
|
|
97
|
+
const server = readServer(payload);
|
|
98
|
+
if (!response.ok || !server) {
|
|
68
99
|
throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
|
|
69
100
|
}
|
|
70
|
-
return
|
|
101
|
+
return server;
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
async function getServer(id: number): Promise<HetznerServer> {
|
|
74
105
|
const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
|
|
75
|
-
const payload =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!response.ok || !payload.server) {
|
|
106
|
+
const payload = await readJsonResponse(response, "Hetzner server poll");
|
|
107
|
+
const server = readServer(payload);
|
|
108
|
+
if (!response.ok || !server) {
|
|
79
109
|
throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
|
|
80
110
|
}
|
|
81
|
-
return
|
|
111
|
+
return server;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
let server: HetznerServer | undefined = (await listServers())[0];
|
|
@@ -101,7 +131,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
|
|
|
101
131
|
server = await getServer(server.id);
|
|
102
132
|
}
|
|
103
133
|
|
|
104
|
-
const serverIp = server.public_net
|
|
134
|
+
const serverIp = server.public_net.ipv4.ip;
|
|
105
135
|
if (!serverIp) {
|
|
106
136
|
throw new Error(`Server ${server.id} running but has no IPv4 address`);
|
|
107
137
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { requireEnv, writeGitHubOutput } from "./helpers";
|
|
3
4
|
|
|
4
5
|
const eventName = requireEnv("GITHUB_EVENT_NAME");
|
|
@@ -9,14 +10,23 @@ if (eventName === "workflow_dispatch") {
|
|
|
9
10
|
process.exit(0);
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
if (eventName !== "push") {
|
|
13
|
+
if (eventName !== "push" && eventName !== "workflow_run") {
|
|
13
14
|
throw new Error(`Unsupported GITHUB_EVENT_NAME: ${eventName}`);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const beforeSha = requireEnv("BEFORE_SHA");
|
|
17
|
-
const currentSha =
|
|
18
|
+
const currentSha =
|
|
19
|
+
eventName === "workflow_run"
|
|
20
|
+
? execFileSync("git", ["rev-parse", "HEAD"], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
}).trim()
|
|
23
|
+
: requireEnv("GITHUB_SHA");
|
|
18
24
|
|
|
19
|
-
if (
|
|
25
|
+
if (
|
|
26
|
+
!isUsableGitRevision(beforeSha) ||
|
|
27
|
+
!isUsableGitRevision(currentSha) ||
|
|
28
|
+
beforeSha === currentSha
|
|
29
|
+
) {
|
|
20
30
|
writeGitHubOutput("handles_json", JSON.stringify([]));
|
|
21
31
|
process.exit(0);
|
|
22
32
|
}
|
|
@@ -32,10 +42,15 @@ const handles = [
|
|
|
32
42
|
diffOutput
|
|
33
43
|
.split(/\r?\n/)
|
|
34
44
|
.map((path) => {
|
|
35
|
-
const match =
|
|
45
|
+
const match =
|
|
46
|
+
path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
|
|
47
|
+
path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
|
|
36
48
|
return match?.[1] ?? null;
|
|
37
49
|
})
|
|
38
50
|
.filter((handle): handle is string => handle !== null)
|
|
51
|
+
// Deleted generated files belong to retired users and must not be sent
|
|
52
|
+
// through the onboarding/deploy path after an offboarding commit.
|
|
53
|
+
.filter((handle) => existsSync(`users/${handle}.yaml`))
|
|
39
54
|
.sort((left, right) => left.localeCompare(right)),
|
|
40
55
|
),
|
|
41
56
|
];
|
|
@@ -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,5 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseEnvFile,
|
|
7
|
+
requireEnv,
|
|
8
|
+
runtimeImageTag,
|
|
9
|
+
writeGitHubOutput,
|
|
10
|
+
} from "./helpers";
|
|
3
11
|
|
|
4
12
|
const handle = requireEnv("HANDLE");
|
|
5
13
|
const envPath = `users/${handle}/.env`;
|
|
@@ -12,32 +20,47 @@ const repositoryOwner = repository.split("/")[0] ?? "";
|
|
|
12
20
|
const brainYaml = readFileSync(brainYamlPath, "utf8");
|
|
13
21
|
const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
|
|
14
22
|
const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
15
|
-
|
|
16
23
|
if (!brainDomain) {
|
|
17
24
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
const registry = await loadPilotRegistry(process.cwd());
|
|
28
|
+
const user = registry.users.find((entry) => entry.handle === handle);
|
|
29
|
+
if (!user) {
|
|
30
|
+
throw new Error(`Unknown user handle: ${handle}`);
|
|
31
|
+
}
|
|
32
|
+
const previewDomain = derivePreviewDomain(brainDomain, {
|
|
33
|
+
sharedDomain: registry.pilot.domainSuffix,
|
|
34
|
+
});
|
|
35
|
+
const wwwDomain = isFleetDomain(
|
|
36
|
+
handle,
|
|
37
|
+
brainDomain,
|
|
38
|
+
registry.pilot.domainSuffix,
|
|
39
|
+
)
|
|
40
|
+
? ""
|
|
41
|
+
: `www.${brainDomain}`;
|
|
42
|
+
|
|
43
|
+
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
44
|
+
|
|
45
|
+
// Build and Deploy share one tag function: every instance on a Brain version
|
|
46
|
+
// runs the same immutable fleet image.
|
|
47
|
+
const imageTag = runtimeImageTag(brainVersion);
|
|
48
|
+
|
|
20
49
|
const outputs: Record<string, string> = {
|
|
21
|
-
brain_version:
|
|
22
|
-
ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
|
|
23
|
-
git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
|
|
24
|
-
mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
|
|
25
|
-
discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
|
|
50
|
+
brain_version: brainVersion,
|
|
26
51
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
27
52
|
brain_domain: brainDomain,
|
|
53
|
+
preview_domain: previewDomain,
|
|
54
|
+
www_domain: wwwDomain,
|
|
55
|
+
cloudflare_zone_id: user.cloudflareZoneId ?? process.env["CF_ZONE_ID"] ?? "",
|
|
28
56
|
brain_yaml_path: brainYamlPath,
|
|
29
57
|
instance_name: `rover-${handle}`,
|
|
30
58
|
image_repository: `ghcr.io/${repository}`,
|
|
59
|
+
image_tag: imageTag,
|
|
31
60
|
registry_username: repositoryOwner,
|
|
32
61
|
};
|
|
33
62
|
|
|
34
|
-
const required = [
|
|
35
|
-
"brain_version",
|
|
36
|
-
"ai_api_key_secret_name",
|
|
37
|
-
"git_sync_token_secret_name",
|
|
38
|
-
"mcp_auth_token_secret_name",
|
|
39
|
-
"registry_username",
|
|
40
|
-
];
|
|
63
|
+
const required = ["brain_version", "registry_username"];
|
|
41
64
|
for (const key of required) {
|
|
42
65
|
if (!outputs[key]) {
|
|
43
66
|
throw new Error(`Missing ${key} (derived from ${envPath})`);
|
|
@@ -47,3 +70,11 @@ for (const key of required) {
|
|
|
47
70
|
for (const [key, value] of Object.entries(outputs)) {
|
|
48
71
|
writeGitHubOutput(key, value);
|
|
49
72
|
}
|
|
73
|
+
|
|
74
|
+
function isFleetDomain(
|
|
75
|
+
userHandle: string,
|
|
76
|
+
domain: string,
|
|
77
|
+
pilotDomainSuffix: string,
|
|
78
|
+
): boolean {
|
|
79
|
+
return domain === `${userHandle}${pilotDomainSuffix}`;
|
|
80
|
+
}
|