@rizom/ops 0.2.0-alpha.254 → 0.2.0-alpha.256

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.
@@ -5,6 +5,7 @@ import { type LookupHost } from "./observed-status";
5
5
  import { type RunCommand as OpsRunCommand } from "./run-subprocess";
6
6
  import { type SshKeygen } from "./ssh-key-bootstrap";
7
7
  import type { UserRunner } from "./user-runner";
8
+ import type { cleanupDirectorySyncStress, runDeployedDirectorySyncStress } from "./directory-sync-stress-system";
8
9
  export interface CommandResult {
9
10
  success: boolean;
10
11
  message?: string;
@@ -17,6 +18,8 @@ export interface CommandDependencies extends LoadPilotRegistryOptions {
17
18
  lookupHost?: LookupHost | undefined;
18
19
  bootstrapRunCommand?: OpsRunCommand | undefined;
19
20
  sshKeygen?: SshKeygen | undefined;
21
+ directorySyncStressRunner?: typeof runDeployedDirectorySyncStress | undefined;
22
+ directorySyncStressCleanupRunner?: typeof cleanupDirectorySyncStress | undefined;
20
23
  }
21
24
  export declare const globalFlags: FlagDefinitions;
22
25
  export declare const commands: readonly CommandDefinition<CommandDependencies, CommandResult>[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
- "version": "0.2.0-alpha.254",
3
+ "version": "0.2.0-alpha.256",
4
4
  "description": "Operator CLI for managing private brain fleet registry repos",
5
5
  "keywords": [
6
6
  "brains",
@@ -0,0 +1,47 @@
1
+ name: Load Bitwarden environment through Varlock
2
+ description: Load, mask, and export the pilot environment from Varlock
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Load shared environment
8
+ shell: bash
9
+ run: |
10
+ for attempt in 1 2 3; do
11
+ if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
12
+ break
13
+ fi
14
+ if [ "$attempt" = "3" ]; then
15
+ exit 1
16
+ fi
17
+ echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
18
+ sleep 5
19
+ done
20
+ node <<'NODE'
21
+ import { appendFileSync, readFileSync, rmSync } from "node:fs";
22
+ const env = JSON.parse(readFileSync("/tmp/varlock-env.json", "utf8"));
23
+ const output = process.env.GITHUB_ENV;
24
+ if (!output) throw new Error("Missing GITHUB_ENV");
25
+ const newline = "\n";
26
+ const chunks = [];
27
+ for (const [key, value] of Object.entries(env)) {
28
+ if (key === "BWS_ACCESS_TOKEN" || value === null || value === undefined) continue;
29
+ const text = String(value).replace(/\r\n/g, newline);
30
+ const escapedMask = text
31
+ .replace(/%/g, "%25")
32
+ .replace(/\r/g, "%0D")
33
+ .replace(/\n/g, "%0A");
34
+ if (escapedMask.length > 0) {
35
+ process.stdout.write(`::add-mask::${escapedMask}${newline}`);
36
+ }
37
+ if (text.includes(newline)) {
38
+ let delimiter = `VARLOCK_${key}_EOF`;
39
+ while (text.includes(delimiter)) delimiter += "_X";
40
+ chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
41
+ } else {
42
+ chunks.push(`${key}=${text}`);
43
+ }
44
+ }
45
+ appendFileSync(output, `${chunks.join(newline)}${newline}`);
46
+ rmSync("/tmp/varlock-env.json", { force: true });
47
+ NODE
@@ -87,60 +87,10 @@ jobs:
87
87
  - name: Install operator tooling
88
88
  run: bun install
89
89
 
90
- - name: Load shared env via varlock
90
+ - name: Load shared env via Varlock
91
+ uses: ./.github/actions/varlock-env
91
92
  env:
92
93
  BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
93
- run: |
94
- for attempt in 1 2 3; do
95
- if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
96
- break
97
- fi
98
- if [ "$attempt" = "3" ]; then
99
- exit 1
100
- fi
101
- echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
102
- sleep 5
103
- done
104
- node <<'NODE'
105
- import { appendFileSync, readFileSync } from "node:fs";
106
- const env = JSON.parse(readFileSync('/tmp/varlock-env.json', 'utf8'));
107
- const githubEnvPath = process.env.GITHUB_ENV;
108
- if (!githubEnvPath) {
109
- throw new Error('Missing GITHUB_ENV');
110
- }
111
-
112
- const newline = String.fromCharCode(10);
113
- const carriageReturn = String.fromCharCode(13);
114
- const chunks = [];
115
- for (const [key, value] of Object.entries(env)) {
116
- if (key === 'BWS_ACCESS_TOKEN' || value === null || value === undefined) {
117
- continue;
118
- }
119
-
120
- const text = String(value)
121
- .split(carriageReturn + newline)
122
- .join(newline);
123
- const escapedMask = text
124
- .replace(/%/g, '%25')
125
- .replace(/\r/g, '%0D')
126
- .replace(/\n/g, '%0A');
127
- if (escapedMask.length > 0) {
128
- process.stdout.write('::add-mask::' + escapedMask + newline);
129
- }
130
-
131
- if (text.includes(newline)) {
132
- let delimiter = `VARLOCK_${key}_EOF`;
133
- while (text.includes(delimiter)) {
134
- delimiter += '_X';
135
- }
136
- chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
137
- } else {
138
- chunks.push(`${key}=${text}`);
139
- }
140
- }
141
-
142
- appendFileSync(githubEnvPath, chunks.join(newline) + newline);
143
- NODE
144
94
 
145
95
  - name: Decrypt user secrets
146
96
  id: user_secrets
@@ -271,6 +221,13 @@ jobs:
271
221
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
272
222
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
273
223
 
224
+ - name: Install container health watchdog
225
+ env:
226
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
227
+ run: |
228
+ SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')" \
229
+ bun deploy/scripts/install-health-watchdog.ts
230
+
274
231
  - name: Verify origin TLS
275
232
  env:
276
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
@@ -0,0 +1,100 @@
1
+ name: Directory Sync Stress
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ handle:
7
+ description: Smoke handle only
8
+ required: true
9
+ type: string
10
+ default: smoke
11
+ profile:
12
+ description: Workload profile
13
+ required: true
14
+ type: choice
15
+ default: regression
16
+ options:
17
+ - regression
18
+ - load
19
+ - stress
20
+ confirm:
21
+ description: Type stress:<handle> to confirm reversible content writes
22
+ required: true
23
+ type: string
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ concurrency:
29
+ group: directory-sync-stress-${{ inputs.handle }}
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ stress:
34
+ runs-on: ubuntu-latest
35
+ timeout-minutes: 75
36
+ steps:
37
+ - uses: actions/checkout@v5
38
+
39
+ - uses: oven-sh/setup-bun@v2
40
+
41
+ - name: Install operator tooling
42
+ run: bun install
43
+
44
+ - name: Load Bitwarden environment through Varlock
45
+ uses: ./.github/actions/varlock-env
46
+ env:
47
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
48
+
49
+ - name: Run smoke directory-sync stress profile
50
+ env:
51
+ HANDLE_INPUT: ${{ inputs.handle }}
52
+ CONFIRM_INPUT: ${{ inputs.confirm }}
53
+ run: |
54
+ bunx brains-ops stress:directory-sync "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
55
+ --profile "${{ inputs.profile }}" \
56
+ --confirm "$CONFIRM_INPUT" \
57
+ --artifacts-dir "$RUNNER_TEMP/directory-sync-stress"
58
+
59
+ - name: Upload stress evidence
60
+ if: always()
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: directory-sync-stress-${{ inputs.handle }}-${{ inputs.profile }}-${{ github.run_id }}
64
+ path: ${{ runner.temp }}/directory-sync-stress
65
+ if-no-files-found: warn
66
+
67
+ cleanup:
68
+ needs: stress
69
+ if: always()
70
+ runs-on: ubuntu-latest
71
+ timeout-minutes: 20
72
+ steps:
73
+ - uses: actions/checkout@v5
74
+
75
+ - uses: oven-sh/setup-bun@v2
76
+
77
+ - name: Install operator tooling
78
+ run: bun install
79
+
80
+ - name: Load Bitwarden environment through Varlock
81
+ uses: ./.github/actions/varlock-env
82
+ env:
83
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
84
+
85
+ - name: Remove residual stress probes
86
+ env:
87
+ HANDLE_INPUT: ${{ inputs.handle }}
88
+ CONFIRM_INPUT: ${{ inputs.confirm }}
89
+ run: |
90
+ bunx brains-ops stress:directory-sync:cleanup "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
91
+ --confirm "$CONFIRM_INPUT" \
92
+ --artifacts-dir "$RUNNER_TEMP/directory-sync-stress-cleanup"
93
+
94
+ - name: Upload cleanup evidence
95
+ if: always()
96
+ uses: actions/upload-artifact@v4
97
+ with:
98
+ name: directory-sync-stress-cleanup-${{ inputs.handle }}-${{ github.run_id }}
99
+ path: ${{ runner.temp }}/directory-sync-stress-cleanup
100
+ if-no-files-found: warn
@@ -0,0 +1,133 @@
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 healthWatchdogScript: string = `#!/usr/bin/env bash
8
+ set -euo pipefail
9
+ umask 077
10
+
11
+ lock_path="\${BRAIN_WATCHDOG_LOCK_PATH:-/run/brains-health-watchdog.lock}"
12
+ exec 9>"$lock_path"
13
+ flock -n 9 || exit 0
14
+
15
+ incident_dir="\${BRAIN_WATCHDOG_INCIDENT_DIR:-/var/log/brains-health-watchdog}"
16
+ state_dir="\${BRAIN_WATCHDOG_STATE_DIR:-/var/lib/brains-health-watchdog}"
17
+ MAX_RESTARTS=3
18
+ WINDOW_SECONDS=3600
19
+ mkdir -p "$incident_dir" "$state_dir"
20
+
21
+ write_safe_inspect() {
22
+ docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"state":{{json .State}},"restartPolicy":{{json .HostConfig.RestartPolicy}}}' "$1"
23
+ }
24
+
25
+ while read -r container_id; do
26
+ [ -n "$container_id" ] || continue
27
+ service_name=$(docker inspect --format '{{ index .Config.Labels "service" }}' "$container_id" 2>/dev/null || true)
28
+ container_name=$(docker inspect --format '{{ .Name }}' "$container_id" 2>/dev/null | tr -d '/' || true)
29
+ state_key=$(printf '%s-%s' "$service_name" "$container_name" | tr -c 'A-Za-z0-9_.-' '_')
30
+ state_path="$state_dir/$state_key.restarts"
31
+ recent_path="$state_path.tmp"
32
+ now_epoch=$(date -u +%s)
33
+ cutoff_epoch=$((now_epoch - WINDOW_SECONDS))
34
+ touch "$state_path"
35
+ awk -v cutoff="$cutoff_epoch" '$1 >= cutoff' "$state_path" >"$recent_path"
36
+ restart_count=$(wc -l <"$recent_path")
37
+
38
+ timestamp=$(date -u +%Y%m%dT%H%M%SZ)
39
+ incident_path="$incident_dir/$timestamp-$state_key.log"
40
+ temporary_path="$incident_path.tmp"
41
+
42
+ if [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
43
+ {
44
+ printf '{"timestamp":"%s","event":"restart-suppressed","reason":"restart-budget-exhausted","service":"%s","container":"%s","restartsInWindow":%s}\\n' "$timestamp" "$service_name" "$container_name" "$restart_count"
45
+ write_safe_inspect "$container_id"
46
+ docker logs --timestamps --tail 1000 "$container_id"
47
+ } >"$temporary_path" 2>&1
48
+ mv "$temporary_path" "$incident_path"
49
+ mv "$recent_path" "$state_path"
50
+ logger -t brains-health-watchdog "restart budget exhausted container=$container_name service=$service_name incident=$incident_path"
51
+ continue
52
+ fi
53
+
54
+ {
55
+ printf '{"timestamp":"%s","event":"container-restart","reason":"docker-health-unhealthy","service":"%s","container":"%s"}\\n' "$timestamp" "$service_name" "$container_name"
56
+ write_safe_inspect "$container_id"
57
+ docker logs --timestamps --tail 1000 "$container_id"
58
+ } >"$temporary_path" 2>&1
59
+ mv "$temporary_path" "$incident_path"
60
+ printf '%s\\n' "$now_epoch" >>"$recent_path"
61
+ mv "$recent_path" "$state_path"
62
+
63
+ logger -t brains-health-watchdog "restarting unhealthy container=$container_name service=$service_name incident=$incident_path"
64
+ docker restart "$container_id"
65
+ done < <(docker ps --filter health=unhealthy --filter label=service --format '{{.ID}}')
66
+ `;
67
+
68
+ export const healthWatchdogServiceUnit: string = `[Unit]
69
+ Description=Restart persistently unhealthy Brain containers
70
+ After=docker.service
71
+ Requires=docker.service
72
+
73
+ [Service]
74
+ Type=oneshot
75
+ ExecStart=${WATCHDOG_PATH}
76
+ `;
77
+
78
+ export const healthWatchdogTimerUnit: string = `[Unit]
79
+ Description=Monitor Brain container liveness
80
+
81
+ [Timer]
82
+ OnBootSec=2min
83
+ OnUnitActiveSec=30s
84
+ RandomizedDelaySec=5s
85
+ Persistent=true
86
+ Unit=brains-health-watchdog.service
87
+
88
+ [Install]
89
+ WantedBy=timers.target
90
+ `;
91
+
92
+ function installCommand(path: string, content: string, mode: string): string {
93
+ const encoded = Buffer.from(content, "utf8").toString("base64");
94
+ return `printf '%s' '${encoded}' | $SUDO base64 --decode | $SUDO tee '${path}' >/dev/null\n$SUDO chmod ${mode} '${path}'`;
95
+ }
96
+
97
+ export function buildHealthWatchdogInstallScript(): string {
98
+ return `#!/usr/bin/env bash
99
+ set -euo pipefail
100
+ SUDO=""
101
+ if [ "$(id -u)" -ne 0 ]; then
102
+ SUDO=sudo
103
+ fi
104
+
105
+ ${installCommand(WATCHDOG_PATH, healthWatchdogScript, "0755")}
106
+ ${installCommand(SERVICE_PATH, healthWatchdogServiceUnit, "0644")}
107
+ ${installCommand(TIMER_PATH, healthWatchdogTimerUnit, "0644")}
108
+
109
+ $SUDO systemctl daemon-reload
110
+ $SUDO systemctl enable --now brains-health-watchdog.timer
111
+ `;
112
+ }
113
+
114
+ export async function installHealthWatchdog(): Promise<void> {
115
+ const serverIp = requireEnv("SERVER_IP");
116
+ const sshUser = requireEnv("SSH_USER").trim();
117
+ if (!sshUser) throw new Error("Missing SSH_USER");
118
+ const child = Bun.spawn(["ssh", `${sshUser}@${serverIp}`, "bash -s"], {
119
+ stdin: new Blob([buildHealthWatchdogInstallScript()]),
120
+ stdout: "inherit",
121
+ stderr: "inherit",
122
+ });
123
+ const exitCode = await child.exited;
124
+ if (exitCode !== 0) {
125
+ throw new Error(
126
+ `Health watchdog installation failed with exit ${exitCode}`,
127
+ );
128
+ }
129
+ }
130
+
131
+ if (import.meta.main) {
132
+ await installHealthWatchdog();
133
+ }
@@ -9,6 +9,7 @@ Treat these as checked-in deploy artifacts in the pilot repo:
9
9
  - `deploy/scripts/`
10
10
  - `.github/workflows/build.yml`
11
11
  - `.github/workflows/deploy.yml`
12
+ - `.github/workflows/directory-sync-stress.yml`
12
13
  - `.github/workflows/reconcile.yml`
13
14
 
14
15
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
@@ -66,6 +67,20 @@ During the approved window:
66
67
 
67
68
  If any gate fails, disable all three workflows again. Restore the prior pilot desired-state and dependency revision, reconcile with the prior ops version, and redeploy the prior image tag/digest as one rollback pair. Verify the prior `/health` version and identity/content/site checks before re-enabling automation. Never restore only config or only an image.
68
69
 
70
+ ## Directory-sync stress gate
71
+
72
+ Use the manual `Directory Sync Stress` workflow only against a disposable smoke user. It refuses a target unless the handle, domain, and content repository all identify smoke and the confirmation input exactly matches `stress:<handle>`.
73
+
74
+ Profiles are deterministic and reversible:
75
+
76
+ - `regression`: 20 probes;
77
+ - `load`: ramps to 350 probes, updates all, renames 100, updates again, then deletes all;
78
+ - `stress`: ramps to 700 probes and renames 200 before cleanup.
79
+
80
+ The workflow loads operator credentials through Bitwarden/Varlock, but it is separate from Deploy and cannot deploy an image. It creates a rollback branch before the first content write, gates on health timeouts during the monitored workload window, preserves warmup and cleanup samples as evidence, uploads JSON/Markdown/runtime artifacts, and runs an independent idempotent cleanup job with `if: always()`. Once cleanup confirms that no probes remain, it also prunes retained `ops/directory-sync-stress-backup-*` branches; if probes remain, the branches stay available for recovery.
81
+
82
+ Treat any gated health failure, restart, OOM, residual probe, or entity-baseline drift as a failed gate. Do not restart the target during measurement. Recovery is a separate operator action after evidence collection.
83
+
69
84
  ## Stale deploy lock recovery
70
85
 
71
86
  Kamal intentionally leaves its remote deploy lock in place when a deployment is cancelled or interrupted. Confirm that no deployment for the user is still active before releasing the lock, then use the deploy workflow's explicit recovery input: