@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.
- package/README.md +12 -0
- package/dist/brains-ops.js +211 -194
- package/dist/deploy.js +23 -23
- package/dist/directory-sync-stress-system.d.ts +63 -0
- package/dist/directory-sync-stress.d.ts +105 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +216 -199
- package/dist/run-command.d.ts +3 -0
- package/package.json +1 -1
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/deploy.yml +9 -52
- package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +100 -0
- package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +133 -0
- package/templates/rover-pilot/docs/operator-playbook.md +15 -0
package/dist/run-command.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
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:
|