@delorenj/pjangler 1.2.21 → 1.2.25
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/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Opt-in, profile-local Slack Socket Mode provisioning.
|
|
3
|
+
#
|
|
4
|
+
# Slack credentials are invocation-only inputs. Capture them before _lib.sh
|
|
5
|
+
# sources fleet.env so an accidentally shared fleet token can never provision a
|
|
6
|
+
# profile. The non-secret allowed-user policy may still come from fleet.env.
|
|
7
|
+
INVOCATION_SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN-}"
|
|
8
|
+
INVOCATION_SLACK_APP_TOKEN="${SLACK_APP_TOKEN-}"
|
|
9
|
+
INVOCATION_SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
|
|
10
|
+
INVOCATION_ENABLE_SLACK="${ENABLE_SLACK-${WIRE_SLACK-}}"
|
|
11
|
+
|
|
12
|
+
# shellcheck source=_lib.sh
|
|
13
|
+
source "$(dirname "$0")/_lib.sh"
|
|
14
|
+
load_role_env
|
|
15
|
+
|
|
16
|
+
SLACK_BOT_TOKEN="$INVOCATION_SLACK_BOT_TOKEN"
|
|
17
|
+
SLACK_APP_TOKEN="$INVOCATION_SLACK_APP_TOKEN"
|
|
18
|
+
if [[ -n "$INVOCATION_SLACK_ALLOWED_USERS" ]]; then
|
|
19
|
+
SLACK_ALLOWED_USERS="$INVOCATION_SLACK_ALLOWED_USERS"
|
|
20
|
+
else
|
|
21
|
+
SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
|
|
22
|
+
fi
|
|
23
|
+
ENABLE_SLACK="$INVOCATION_ENABLE_SLACK"
|
|
24
|
+
unset INVOCATION_SLACK_BOT_TOKEN INVOCATION_SLACK_APP_TOKEN
|
|
25
|
+
|
|
26
|
+
slack_yaml_update() {
|
|
27
|
+
python3 - "$ROLE_YAML" "$@" <<'PYEOF'
|
|
28
|
+
import json
|
|
29
|
+
import pathlib
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
path = pathlib.Path(sys.argv[1])
|
|
34
|
+
updates = dict(zip(sys.argv[2::2], sys.argv[3::2]))
|
|
35
|
+
text = path.read_text(encoding="utf-8")
|
|
36
|
+
match = re.search(r"(?ms)^slack:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text)
|
|
37
|
+
if not match:
|
|
38
|
+
raise SystemExit(f"slack metadata block missing from {path}")
|
|
39
|
+
body = match.group("body")
|
|
40
|
+
for key, value in updates.items():
|
|
41
|
+
replacement = f" {key}: {json.dumps(value)}"
|
|
42
|
+
body, count = re.subn(
|
|
43
|
+
rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
|
|
44
|
+
)
|
|
45
|
+
if count != 1:
|
|
46
|
+
raise SystemExit(f"Slack metadata key {key!r} missing from {path}")
|
|
47
|
+
path.write_text(text[: match.start("body")] + body + text[match.end("body") :], encoding="utf-8")
|
|
48
|
+
PYEOF
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
truthy() {
|
|
52
|
+
case "${1,,}" in
|
|
53
|
+
1|true|yes|on) return 0 ;;
|
|
54
|
+
*) return 1 ;;
|
|
55
|
+
esac
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if [[ "${SKIP_SLACK:-0}" == "1" ]]; then
|
|
59
|
+
slack_yaml_update provisioning_status disabled
|
|
60
|
+
log "[31] slack — DISABLED (SKIP_SLACK=1)"
|
|
61
|
+
mark_done 31-slack
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
already_done 31-slack && { log "[31] slack already wired — skipping"; exit 0; }
|
|
66
|
+
|
|
67
|
+
have_bot=0
|
|
68
|
+
have_app=0
|
|
69
|
+
[[ -n "$SLACK_BOT_TOKEN" ]] && have_bot=1
|
|
70
|
+
[[ -n "$SLACK_APP_TOKEN" ]] && have_app=1
|
|
71
|
+
|
|
72
|
+
if ! truthy "${ENABLE_SLACK:-0}" && (( ! have_bot && ! have_app )); then
|
|
73
|
+
slack_yaml_update provisioning_status deferred
|
|
74
|
+
log "[31] slack — deferred (opt in with ENABLE_SLACK=1 or supply both Slack tokens)"
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
if (( have_bot != have_app )) && ! truthy "${ENABLE_SLACK:-0}"; then
|
|
79
|
+
die "Slack provisioning requires a dedicated SLACK_BOT_TOKEN and SLACK_APP_TOKEN pair"
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if truthy "${ENABLE_SLACK:-0}" && [[ -t 0 ]]; then
|
|
83
|
+
if [[ -z "$SLACK_BOT_TOKEN" ]]; then
|
|
84
|
+
read -r -s -p "Slack Bot User OAuth Token (xoxb-...): " SLACK_BOT_TOKEN
|
|
85
|
+
echo >&2
|
|
86
|
+
fi
|
|
87
|
+
if [[ -z "$SLACK_APP_TOKEN" ]]; then
|
|
88
|
+
read -r -s -p "Slack App-Level Socket Mode Token (xapp-...): " SLACK_APP_TOKEN
|
|
89
|
+
echo >&2
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
[[ -n "$SLACK_BOT_TOKEN" && -n "$SLACK_APP_TOKEN" ]] \
|
|
94
|
+
|| die "Slack provisioning requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN"
|
|
95
|
+
[[ "$SLACK_BOT_TOKEN" == xoxb-* ]] || die "SLACK_BOT_TOKEN must be a Bot User OAuth token (xoxb-...)"
|
|
96
|
+
[[ "$SLACK_APP_TOKEN" == xapp-* ]] || die "SLACK_APP_TOKEN must be an App-Level Socket Mode token (xapp-...)"
|
|
97
|
+
|
|
98
|
+
SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS//[[:space:]]/}"
|
|
99
|
+
if [[ -n "$SLACK_ALLOWED_USERS" && "$SLACK_ALLOWED_USERS" != "*" \
|
|
100
|
+
&& ! "$SLACK_ALLOWED_USERS" =~ ^[UW][A-Z0-9]+(,[UW][A-Z0-9]+)*$ ]]; then
|
|
101
|
+
die "SLACK_ALLOWED_USERS must be '*' or comma-separated Slack member IDs"
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
RUNTIME="$ROLE_DIR/runtime"
|
|
105
|
+
ENVF="$RUNTIME/.env"
|
|
106
|
+
mkdir -p "$RUNTIME"
|
|
107
|
+
[[ ! -L "$ENVF" ]] || die "refusing to write Slack credentials through symlink: $ENVF"
|
|
108
|
+
|
|
109
|
+
log "[31] verifying Slack bot identity via auth.test"
|
|
110
|
+
auth_response=$(curl -fsS \
|
|
111
|
+
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
|
112
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
113
|
+
-X POST "https://slack.com/api/auth.test") \
|
|
114
|
+
|| die "Slack auth.test request failed"
|
|
115
|
+
|
|
116
|
+
identity=$(printf '%s' "$auth_response" | python3 -c '
|
|
117
|
+
import json, sys
|
|
118
|
+
try:
|
|
119
|
+
data = json.load(sys.stdin)
|
|
120
|
+
except Exception:
|
|
121
|
+
raise SystemExit("Slack auth.test returned an invalid response")
|
|
122
|
+
if not data.get("ok"):
|
|
123
|
+
error = str(data.get("error") or "unknown_error")
|
|
124
|
+
safe = "".join(c for c in error if c.isalnum() or c in "_-.")[:80]
|
|
125
|
+
raise SystemExit("Slack auth.test rejected the bot token ({})".format(safe or "unknown_error"))
|
|
126
|
+
values = [data.get(k, "") for k in ("team_id", "team", "user_id", "bot_id", "user")]
|
|
127
|
+
if not values[0] or not values[2]:
|
|
128
|
+
raise SystemExit("Slack auth.test response omitted required identity fields")
|
|
129
|
+
print("\t".join(str(v).replace("\t", " ").replace("\n", " ") for v in values))
|
|
130
|
+
') || die "Slack bot identity verification failed"
|
|
131
|
+
IFS=$'\t' read -r slack_team_id slack_team_name slack_bot_user_id slack_bot_id slack_bot_username <<< "$identity"
|
|
132
|
+
|
|
133
|
+
# Reject credential reuse, token rotation onto an identity owned by another
|
|
134
|
+
# agent, and credentials parked in shared env files. The scan, durable identity
|
|
135
|
+
# claim, and profile credential write share one fleet-wide flock.
|
|
136
|
+
export SLACK_BOT_TOKEN SLACK_APP_TOKEN SLACK_ALLOWED_USERS
|
|
137
|
+
fleet_lock_acquire
|
|
138
|
+
trap 'fleet_lock_release' EXIT
|
|
139
|
+
python3 - "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" \
|
|
140
|
+
"$slack_team_id" "$slack_bot_user_id" "$slack_bot_id" \
|
|
141
|
+
"$ROLE_DIR" "$PROFILE_NAME" "$slack_team_name" "$slack_bot_username" <<'PYEOF'
|
|
142
|
+
import errno
|
|
143
|
+
import os
|
|
144
|
+
import pathlib
|
|
145
|
+
import re
|
|
146
|
+
import sys
|
|
147
|
+
import tempfile
|
|
148
|
+
try:
|
|
149
|
+
import yaml # type: ignore
|
|
150
|
+
except ImportError:
|
|
151
|
+
raise SystemExit("PyYAML is required for Slack fleet claims")
|
|
152
|
+
|
|
153
|
+
(
|
|
154
|
+
registry_path,
|
|
155
|
+
fleet_path,
|
|
156
|
+
target_path,
|
|
157
|
+
agent_id,
|
|
158
|
+
team_id,
|
|
159
|
+
user_id,
|
|
160
|
+
bot_id,
|
|
161
|
+
role_dir,
|
|
162
|
+
profile_name,
|
|
163
|
+
team_name,
|
|
164
|
+
bot_username,
|
|
165
|
+
) = sys.argv[1:]
|
|
166
|
+
bot_token = os.environ["SLACK_BOT_TOKEN"]
|
|
167
|
+
app_token = os.environ["SLACK_APP_TOKEN"]
|
|
168
|
+
target = pathlib.Path(target_path).resolve(strict=False)
|
|
169
|
+
|
|
170
|
+
def env_values(path):
|
|
171
|
+
try:
|
|
172
|
+
text = path.read_text(encoding="utf-8")
|
|
173
|
+
except (OSError, UnicodeError):
|
|
174
|
+
return {}
|
|
175
|
+
values = {}
|
|
176
|
+
for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"):
|
|
177
|
+
match = re.search(rf"(?m)^\s*(?:export\s+)?{key}\s*=\s*(.*)$", text)
|
|
178
|
+
if match:
|
|
179
|
+
value = match.group(1).strip()
|
|
180
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
181
|
+
value = value[1:-1]
|
|
182
|
+
values[key] = value
|
|
183
|
+
return values
|
|
184
|
+
|
|
185
|
+
owners = [("shared fleet environment", pathlib.Path(fleet_path))]
|
|
186
|
+
registry = pathlib.Path(registry_path)
|
|
187
|
+
if registry.is_symlink():
|
|
188
|
+
raise SystemExit(f"refusing to update registry symlink: {registry}")
|
|
189
|
+
data = {"schema_version": 1, "agents": {}}
|
|
190
|
+
if registry.is_file():
|
|
191
|
+
try:
|
|
192
|
+
data = yaml.safe_load(registry.read_text(encoding="utf-8")) or data
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
raise SystemExit(f"cannot safely inspect Slack ownership registry: {type(exc).__name__}")
|
|
195
|
+
if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
|
|
196
|
+
raise SystemExit("cannot safely inspect Slack ownership registry: invalid agents mapping")
|
|
197
|
+
agents = data.setdefault("agents", {})
|
|
198
|
+
for other_id, entry in agents.items():
|
|
199
|
+
if other_id == agent_id or not isinstance(entry, dict):
|
|
200
|
+
continue
|
|
201
|
+
slack = entry.get("slack") or {}
|
|
202
|
+
if isinstance(slack, dict):
|
|
203
|
+
same_user = user_id and slack.get("bot_user_id") == user_id
|
|
204
|
+
same_bot = bot_id and slack.get("bot_id") == bot_id
|
|
205
|
+
same_team_user = team_id and same_user and slack.get("team_id") == team_id
|
|
206
|
+
if same_bot or same_team_user:
|
|
207
|
+
raise SystemExit(f"Slack bot identity is already assigned to agent {other_id}")
|
|
208
|
+
other_role_dir = entry.get("role_dir")
|
|
209
|
+
if other_role_dir:
|
|
210
|
+
owners.append((f"agent {other_id}", pathlib.Path(str(other_role_dir)) / "runtime" / ".env"))
|
|
211
|
+
|
|
212
|
+
home_value = os.environ.get("HOME", "")
|
|
213
|
+
if home_value:
|
|
214
|
+
home = pathlib.Path(home_value)
|
|
215
|
+
owners.append(("shared Hermes root", home / ".hermes" / ".env"))
|
|
216
|
+
profiles = home / ".hermes" / "profiles"
|
|
217
|
+
if profiles.is_dir():
|
|
218
|
+
for profile in profiles.iterdir():
|
|
219
|
+
owners.append((f"profile {profile.name}", profile / ".env"))
|
|
220
|
+
|
|
221
|
+
seen = set()
|
|
222
|
+
for owner, path in owners:
|
|
223
|
+
resolved = path.resolve(strict=False)
|
|
224
|
+
if resolved == target or resolved in seen:
|
|
225
|
+
continue
|
|
226
|
+
seen.add(resolved)
|
|
227
|
+
values = env_values(path)
|
|
228
|
+
if values.get("SLACK_BOT_TOKEN") == bot_token:
|
|
229
|
+
raise SystemExit(f"Slack bot token is already assigned to {owner}")
|
|
230
|
+
if values.get("SLACK_APP_TOKEN") == app_token:
|
|
231
|
+
raise SystemExit(f"Slack app token is already assigned to {owner}")
|
|
232
|
+
|
|
233
|
+
claim = agents.setdefault(agent_id, {})
|
|
234
|
+
if not isinstance(claim, dict):
|
|
235
|
+
raise SystemExit(f"registry entry for {agent_id} is not a mapping")
|
|
236
|
+
claim["role_dir"] = role_dir
|
|
237
|
+
claim["profile_name"] = profile_name
|
|
238
|
+
claim["slack"] = {
|
|
239
|
+
"provisioning_status": "verified",
|
|
240
|
+
"team_id": team_id,
|
|
241
|
+
"team_name": team_name,
|
|
242
|
+
"bot_user_id": user_id,
|
|
243
|
+
"bot_id": bot_id,
|
|
244
|
+
"bot_username": bot_username,
|
|
245
|
+
}
|
|
246
|
+
registry.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
248
|
+
|
|
249
|
+
def fsync_parent(target):
|
|
250
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
251
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
252
|
+
try:
|
|
253
|
+
directory_fd = os.open(target.parent, flags)
|
|
254
|
+
except OSError as exc:
|
|
255
|
+
if exc.errno in unsupported:
|
|
256
|
+
return
|
|
257
|
+
raise
|
|
258
|
+
try:
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(directory_fd)
|
|
261
|
+
except OSError as exc:
|
|
262
|
+
if exc.errno not in unsupported:
|
|
263
|
+
raise
|
|
264
|
+
finally:
|
|
265
|
+
os.close(directory_fd)
|
|
266
|
+
|
|
267
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{registry.name}.slack-", dir=registry.parent)
|
|
268
|
+
try:
|
|
269
|
+
os.fchmod(fd, 0o600)
|
|
270
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
271
|
+
handle.write(rendered)
|
|
272
|
+
handle.flush()
|
|
273
|
+
os.fsync(handle.fileno())
|
|
274
|
+
os.replace(temporary, registry)
|
|
275
|
+
os.chmod(registry, 0o600)
|
|
276
|
+
fsync_parent(registry)
|
|
277
|
+
except BaseException:
|
|
278
|
+
try:
|
|
279
|
+
os.unlink(temporary)
|
|
280
|
+
except FileNotFoundError:
|
|
281
|
+
pass
|
|
282
|
+
raise
|
|
283
|
+
PYEOF
|
|
284
|
+
|
|
285
|
+
# Atomically replace only the Slack fields in the profile-local runtime file.
|
|
286
|
+
# Secrets stay out of argv and are never written to root .env or fleet.env.
|
|
287
|
+
python3 - "$ENVF" <<'PYEOF'
|
|
288
|
+
import errno
|
|
289
|
+
import json
|
|
290
|
+
import os
|
|
291
|
+
import pathlib
|
|
292
|
+
import re
|
|
293
|
+
import tempfile
|
|
294
|
+
|
|
295
|
+
path = pathlib.Path(__import__("sys").argv[1])
|
|
296
|
+
if path.is_symlink():
|
|
297
|
+
raise SystemExit(f"refusing to write Slack credentials through symlink: {path}")
|
|
298
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
300
|
+
for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"):
|
|
301
|
+
text = re.sub(rf"(?m)^\s*(?:export\s+)?#?\s*{key}\s*=.*(?:\n|$)", "", text)
|
|
302
|
+
values = {key: os.environ.get(key, "") for key in (
|
|
303
|
+
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"
|
|
304
|
+
)}
|
|
305
|
+
text = text.rstrip("\n")
|
|
306
|
+
if text:
|
|
307
|
+
text += "\n"
|
|
308
|
+
text += "".join(f"{key}={json.dumps(value)}\n" for key, value in values.items())
|
|
309
|
+
|
|
310
|
+
def fsync_parent(target):
|
|
311
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
312
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
313
|
+
try:
|
|
314
|
+
directory_fd = os.open(target.parent, flags)
|
|
315
|
+
except OSError as exc:
|
|
316
|
+
if exc.errno in unsupported:
|
|
317
|
+
return
|
|
318
|
+
raise
|
|
319
|
+
try:
|
|
320
|
+
try:
|
|
321
|
+
os.fsync(directory_fd)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
if exc.errno not in unsupported:
|
|
324
|
+
raise
|
|
325
|
+
finally:
|
|
326
|
+
os.close(directory_fd)
|
|
327
|
+
|
|
328
|
+
fd, temporary = tempfile.mkstemp(prefix=".env.slack-", dir=path.parent)
|
|
329
|
+
try:
|
|
330
|
+
os.fchmod(fd, 0o600)
|
|
331
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
332
|
+
handle.write(text)
|
|
333
|
+
handle.flush()
|
|
334
|
+
os.fsync(handle.fileno())
|
|
335
|
+
os.replace(temporary, path)
|
|
336
|
+
os.chmod(path, 0o600)
|
|
337
|
+
fsync_parent(path)
|
|
338
|
+
except BaseException:
|
|
339
|
+
try:
|
|
340
|
+
os.unlink(temporary)
|
|
341
|
+
except FileNotFoundError:
|
|
342
|
+
pass
|
|
343
|
+
raise
|
|
344
|
+
PYEOF
|
|
345
|
+
unset SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
346
|
+
|
|
347
|
+
slack_yaml_update \
|
|
348
|
+
provisioning_status verified \
|
|
349
|
+
team_id "$slack_team_id" \
|
|
350
|
+
team_name "$slack_team_name" \
|
|
351
|
+
bot_user_id "$slack_bot_user_id" \
|
|
352
|
+
bot_id "$slack_bot_id" \
|
|
353
|
+
bot_username "$slack_bot_username"
|
|
354
|
+
|
|
355
|
+
fleet_lock_release
|
|
356
|
+
trap - EXIT
|
|
357
|
+
|
|
358
|
+
if [[ -z "$SLACK_ALLOWED_USERS" ]]; then
|
|
359
|
+
warn " Slack is wired but denies all inbound users until SLACK_ALLOWED_USERS is set"
|
|
360
|
+
fi
|
|
361
|
+
log " verified Slack bot $slack_bot_username in $slack_team_name (profile-local credentials)"
|
|
362
|
+
mark_done 31-slack
|
|
@@ -1,76 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# Compatibility checkpoint for the fleet-shared Bloodbank gateway contract.
|
|
3
3
|
# shellcheck source=_lib.sh
|
|
4
4
|
source "$(dirname "$0")/_lib.sh"
|
|
5
5
|
load_role_env
|
|
6
6
|
|
|
7
|
-
already_done 60-bloodbank && { log "[60] bloodbank already
|
|
8
|
-
[[ "${SKIP_BLOODBANK:-0}" == "1" ]] && { log "[60] bloodbank — SKIPPED"; mark_done 60-bloodbank; exit 0; }
|
|
7
|
+
already_done 60-bloodbank && { log "[60] bloodbank fleet-routing checkpoint already complete — skipping"; exit 0; }
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
# Ensure the runtime consumer exists AND is rendered.
|
|
16
|
-
#
|
|
17
|
-
# Step 20 (20-runtime-repo.sh) only renders into a TMP dir during the
|
|
18
|
-
# initial GH-repo push — the on-disk $RUNTIME_SCAFFOLD_DIR copy that lands
|
|
19
|
-
# in the project repo keeps its `{{agent_id}}`/`{{repo}}`/`{{role}}`
|
|
20
|
-
# placeholders. If $RUNTIME/bloodbank-consumer.py was ever lost (runtime
|
|
21
|
-
# submodule wiped, manual rm, etc.) and then someone restored it by
|
|
22
|
-
# copying from $RUNTIME_SCAFFOLD_DIR, they'd end up with an un-rendered
|
|
23
|
-
# consumer whose envelope routing filter compares against literal `{{repo}}`
|
|
24
|
-
# and `{{agent_id}}` values.
|
|
25
|
-
# This block handles both failure modes idempotently.
|
|
26
|
-
CONSUMER="$RUNTIME/bloodbank-consumer.py"
|
|
27
|
-
SCAFFOLD_CONSUMER="$RUNTIME_SCAFFOLD_DIR/bloodbank-consumer.py"
|
|
28
|
-
|
|
29
|
-
needs_restore=0
|
|
30
|
-
if [[ ! -f "$CONSUMER" ]]; then
|
|
31
|
-
log " consumer missing in runtime — restoring from scaffold"
|
|
32
|
-
needs_restore=1
|
|
33
|
-
elif grep -qE '\{\{(agent_id|repo|role|display_name)\}\}' "$CONSUMER"; then
|
|
34
|
-
warn " consumer in runtime has un-rendered placeholders — re-rendering"
|
|
35
|
-
needs_restore=1
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
if [[ "$needs_restore" == "1" ]]; then
|
|
39
|
-
[[ -f "$SCAFFOLD_CONSUMER" ]] || die "scaffold source missing: $SCAFFOLD_CONSUMER"
|
|
40
|
-
cp "$SCAFFOLD_CONSUMER" "$CONSUMER"
|
|
41
|
-
# Same placeholder set + values as 20-runtime-repo.sh's render pass.
|
|
42
|
-
python3 - "$CONSUMER" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" <<'PYEOF'
|
|
43
|
-
import pathlib, sys
|
|
44
|
-
p = pathlib.Path(sys.argv[1])
|
|
45
|
-
agent_id, repo, role, display = sys.argv[2:6]
|
|
46
|
-
mapping = {
|
|
47
|
-
"{{agent_id}}": agent_id, "{{repo}}": repo, "{{role}}": role,
|
|
48
|
-
"{{display_name}}": display,
|
|
49
|
-
}
|
|
50
|
-
t = p.read_text()
|
|
51
|
-
for k, v in mapping.items():
|
|
52
|
-
t = t.replace(k, v)
|
|
53
|
-
p.write_text(t)
|
|
54
|
-
PYEOF
|
|
55
|
-
fi
|
|
56
|
-
chmod +x "$CONSUMER"
|
|
57
|
-
|
|
58
|
-
# Health check: is NATS up?
|
|
59
|
-
if (echo > /dev/tcp/$BLOODBANK_NATS_HOST/$BLOODBANK_NATS_PORT) 2>/dev/null; then
|
|
60
|
-
log " NATS reachable at $BLOODBANK_NATS_HOST:$BLOODBANK_NATS_PORT"
|
|
9
|
+
# SKIP_BLOODBANK used to suppress per-profile consumer installation. Keep the
|
|
10
|
+
# flag harmless for existing unattended provisioning commands; there is no
|
|
11
|
+
# profile-local process, dependency, broker probe, or file to skip anymore.
|
|
12
|
+
if [[ "${SKIP_BLOODBANK:-0}" == "1" ]]; then
|
|
13
|
+
log "[60] bloodbank — SKIP_BLOODBANK accepted as a compatibility no-op"
|
|
61
14
|
else
|
|
62
|
-
|
|
63
|
-
warn " bring up: cd $BLOODBANK_COMPOSE_DIR && docker compose -f compose/docker-compose.yml up -d"
|
|
64
|
-
fi
|
|
65
|
-
|
|
66
|
-
# Ensure nats-py is available in the hermes venv (uv-managed, no pip binary)
|
|
67
|
-
if ! "$HERMES_AGENT_REPO/.venv/bin/python" -c "import nats" 2>/dev/null; then
|
|
68
|
-
warn " python nats-py not installed in hermes venv; installing via uv"
|
|
69
|
-
if command -v uv >/dev/null 2>&1; then
|
|
70
|
-
(cd "$HERMES_AGENT_REPO" && uv pip install --quiet --python .venv/bin/python nats-py 2>&1 | tail -3) || true
|
|
71
|
-
else
|
|
72
|
-
warn " uv not available either — install nats-py manually: cd $HERMES_AGENT_REPO && uv pip install nats-py"
|
|
73
|
-
fi
|
|
15
|
+
log "[60] bloodbank — fleet-shared gateway routes target_agent_id=$AGENT_ID"
|
|
74
16
|
fi
|
|
75
17
|
|
|
76
18
|
mark_done 60-bloodbank
|
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Install systemd --user units: gateway
|
|
2
|
+
# Install systemd --user units: profile gateway and fused heartbeat timer
|
|
3
3
|
# (board-reconciliation sentinel pass + gated runtime checkpoint, one tick).
|
|
4
4
|
# shellcheck source=_lib.sh
|
|
5
5
|
source "$(dirname "$0")/_lib.sh"
|
|
6
6
|
load_role_env
|
|
7
7
|
|
|
8
|
-
already_done 70-systemd && { log "[70] systemd already installed — skipping"; exit 0; }
|
|
9
|
-
[[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
|
|
10
|
-
|
|
11
8
|
RUNTIME="$ROLE_DIR/runtime"
|
|
12
9
|
REPO_ROOT="$(project_repo_path)" || REPO_ROOT="$ROLE_DIR"
|
|
13
10
|
SYS_DIR="$HOME/.config/systemd/user"
|
|
14
11
|
mkdir -p "$SYS_DIR" "$RUNTIME/logs"
|
|
15
12
|
|
|
13
|
+
# Upgrade remediation must run before honoring a legacy done marker. Older
|
|
14
|
+
# templates installed one per-profile Bloodbank consumer; leaving even one
|
|
15
|
+
# enabled races the fleet-shared durable gateway and can duplicate execution.
|
|
16
|
+
LEGACY_CONSUMER_UNIT="hermes-${AGENT_ID}-consumer.service"
|
|
17
|
+
LEGACY_CONSUMER_PATH="$SYS_DIR/$LEGACY_CONSUMER_UNIT"
|
|
18
|
+
legacy_consumer_present=0
|
|
19
|
+
[[ -e "$LEGACY_CONSUMER_PATH" || -L "$LEGACY_CONSUMER_PATH" ]] \
|
|
20
|
+
&& legacy_consumer_present=1
|
|
21
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
22
|
+
legacy_active_result="$(systemctl_user_unit_state is-active "$LEGACY_CONSUMER_UNIT")"
|
|
23
|
+
legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$LEGACY_CONSUMER_UNIT")"
|
|
24
|
+
[[ "$legacy_active_result" != error\|* ]] \
|
|
25
|
+
|| die "cannot safely query legacy consumer activity; preserving unit: ${legacy_active_result#*|}"
|
|
26
|
+
[[ "$legacy_enabled_result" != error\|* ]] \
|
|
27
|
+
|| die "cannot safely query legacy consumer enablement; preserving unit: ${legacy_enabled_result#*|}"
|
|
28
|
+
legacy_active_state="${legacy_active_result#*|}"
|
|
29
|
+
legacy_enabled_state="${legacy_enabled_result#*|}"
|
|
30
|
+
[[ "$legacy_active_state" == "not-found" && "$legacy_enabled_state" == "not-found" ]] \
|
|
31
|
+
|| legacy_consumer_present=1
|
|
32
|
+
elif [[ $legacy_consumer_present -eq 1 ]]; then
|
|
33
|
+
die "systemctl is unavailable; cannot safely retire legacy consumer: $LEGACY_CONSUMER_UNIT"
|
|
34
|
+
fi
|
|
35
|
+
if [[ $legacy_consumer_present -eq 1 ]]; then
|
|
36
|
+
systemctl --user disable --now "$LEGACY_CONSUMER_UNIT" >/dev/null 2>&1 \
|
|
37
|
+
|| die "legacy consumer disable failed; preserving unit: $LEGACY_CONSUMER_UNIT"
|
|
38
|
+
legacy_active_result="$(systemctl_user_unit_state is-active "$LEGACY_CONSUMER_UNIT")"
|
|
39
|
+
legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$LEGACY_CONSUMER_UNIT")"
|
|
40
|
+
[[ "$legacy_active_result" == "ok|inactive" ]] \
|
|
41
|
+
|| die "legacy consumer is not proven inactive; preserving unit: ${legacy_active_result#*|}"
|
|
42
|
+
[[ "$legacy_enabled_result" == "ok|disabled" ]] \
|
|
43
|
+
|| die "legacy consumer is not proven disabled; preserving unit: ${legacy_enabled_result#*|}"
|
|
44
|
+
rm -f -- "$LEGACY_CONSUMER_PATH"
|
|
45
|
+
if systemd_user_available; then
|
|
46
|
+
systemctl --user daemon-reload >/dev/null 2>&1 \
|
|
47
|
+
|| warn " systemd daemon-reload failed after legacy consumer retirement"
|
|
48
|
+
fi
|
|
49
|
+
log " retired legacy per-profile Bloodbank consumer: $LEGACY_CONSUMER_UNIT"
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
already_done 70-systemd && { log "[70] systemd already installed — legacy cleanup checked"; exit 0; }
|
|
53
|
+
[[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
|
|
54
|
+
|
|
16
55
|
# The heartbeat runner (board-reconciliation sentinel pass + gated checkpoint)
|
|
17
56
|
# and the checkpoint helper both render into the role dir; just ensure they are
|
|
18
57
|
# executable. heartbeat.sh calls checkpoint.sh internally.
|
|
@@ -43,31 +82,6 @@ StandardError=append:$RUNTIME/logs/gateway.systemd.log
|
|
|
43
82
|
WantedBy=default.target
|
|
44
83
|
UNIT
|
|
45
84
|
|
|
46
|
-
# Consumer unit
|
|
47
|
-
CSM_UNIT="hermes-${AGENT_ID}-consumer.service"
|
|
48
|
-
cat > "$SYS_DIR/$CSM_UNIT" <<UNIT
|
|
49
|
-
[Unit]
|
|
50
|
-
Description=Bloodbank Consumer — $DISPLAY_NAME
|
|
51
|
-
After=network-online.target $GW_UNIT
|
|
52
|
-
Wants=network-online.target
|
|
53
|
-
|
|
54
|
-
[Service]
|
|
55
|
-
Type=simple
|
|
56
|
-
WorkingDirectory=$RUNTIME
|
|
57
|
-
Environment=HERMES_HOME=$RUNTIME
|
|
58
|
-
Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
|
|
59
|
-
Environment=CODEX_HOME=$CODEX_HOME
|
|
60
|
-
EnvironmentFile=-$RUNTIME/.env
|
|
61
|
-
ExecStart=$HERMES_AGENT_REPO/.venv/bin/python $RUNTIME/bloodbank-consumer.py
|
|
62
|
-
Restart=on-failure
|
|
63
|
-
RestartSec=5
|
|
64
|
-
StandardOutput=append:$RUNTIME/logs/consumer.log
|
|
65
|
-
StandardError=append:$RUNTIME/logs/consumer.log
|
|
66
|
-
|
|
67
|
-
[Install]
|
|
68
|
-
WantedBy=default.target
|
|
69
|
-
UNIT
|
|
70
|
-
|
|
71
85
|
# Fused heartbeat: board-reconciliation sentinel pass + gated runtime checkpoint.
|
|
72
86
|
# Frequent ticks (1 min); heartbeat.sh's own cooldown/lock logic rate-limits the
|
|
73
87
|
# full Hermes pass, and the checkpoint is gated to ~hourly inside the runner.
|
|
@@ -115,7 +129,7 @@ if systemd_user_available; then
|
|
|
115
129
|
# `enable --now` both enables (persist across login) AND starts the unit now, so a
|
|
116
130
|
# freshly provisioned agent comes up live instead of dormant. Units with missing
|
|
117
131
|
# creds (e.g. a gateway with no Telegram token yet) fail softly via Restart=on-failure.
|
|
118
|
-
for u in "$GW_UNIT" "$
|
|
132
|
+
for u in "$GW_UNIT" "$HB_TIMER"; do
|
|
119
133
|
systemctl --user enable --now "$u" >/dev/null 2>&1 && log " enabled + started: $u" || warn " failed to enable/start: $u"
|
|
120
134
|
done
|
|
121
135
|
else
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
source "$(dirname "$0")/_lib.sh"
|
|
5
5
|
load_role_env
|
|
6
6
|
|
|
7
|
+
fleet_lock_acquire
|
|
8
|
+
trap 'fleet_lock_release' EXIT
|
|
9
|
+
|
|
10
|
+
[[ ! -L "$REGISTRY_FILE" ]] || die "refusing to update registry symlink: $REGISTRY_FILE"
|
|
7
11
|
mkdir -p "$(dirname "$REGISTRY_FILE")"
|
|
8
12
|
if [[ ! -f "$REGISTRY_FILE" ]]; then
|
|
9
13
|
cat > "$REGISTRY_FILE" <<'YAML'
|
|
@@ -21,33 +25,109 @@ log "[80] appending to fleet registry: $REGISTRY_FILE"
|
|
|
21
25
|
|
|
22
26
|
python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
|
|
23
27
|
"$PROJECT_PATH" "$ROLE_DIR" "$PROFILE_NAME" \
|
|
24
|
-
"$BOT_HANDLE" \
|
|
28
|
+
"$(yaml_get telegram.provisioning_status)" "$BOT_HANDLE" "$(yaml_get telegram.bot_id)" \
|
|
29
|
+
"$(yaml_get slack.provisioning_status)" "$(yaml_get slack.team_id)" \
|
|
30
|
+
"$(yaml_get slack.team_name)" "$(yaml_get slack.bot_user_id)" \
|
|
31
|
+
"$(yaml_get slack.bot_id)" "$(yaml_get slack.bot_username)" \
|
|
32
|
+
"$(yaml_get bloodbank.gateway_scope)" "$(yaml_get bloodbank.target_agent_id)" \
|
|
25
33
|
"$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
|
|
26
|
-
"$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$
|
|
27
|
-
"
|
|
28
|
-
"hermes-${AGENT_ID}-heartbeat.timer" <<'PYEOF'
|
|
29
|
-
import
|
|
34
|
+
"$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
|
|
35
|
+
"$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
|
|
36
|
+
"hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer" <<'PYEOF'
|
|
37
|
+
import datetime
|
|
38
|
+
import errno
|
|
39
|
+
import os
|
|
40
|
+
import pathlib
|
|
41
|
+
import sys
|
|
42
|
+
import tempfile
|
|
30
43
|
try:
|
|
31
44
|
import yaml # type: ignore
|
|
32
45
|
except ImportError:
|
|
33
46
|
sys.exit("PyYAML required; pip install pyyaml")
|
|
34
|
-
(path, agent_id, repo, role, display, project, role_dir, profile,
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
(path, agent_id, repo, role, display, project, role_dir, profile,
|
|
48
|
+
telegram_status, bot, telegram_bot_id,
|
|
49
|
+
slack_status, slack_team_id, slack_team_name, slack_user_id, slack_bot_id,
|
|
50
|
+
slack_username, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
|
|
51
|
+
plane_ident, runtime_repo, hermes_bin, hermes_repo, hermes_git_url,
|
|
52
|
+
hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:32]
|
|
37
53
|
p = pathlib.Path(path)
|
|
54
|
+
if p.is_symlink():
|
|
55
|
+
raise SystemExit(f"refusing to update registry symlink: {p}")
|
|
38
56
|
data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
|
|
39
57
|
data.setdefault("agents", {})[agent_id] = {
|
|
40
58
|
"repo": repo, "role": role, "display_name": display,
|
|
41
59
|
"project_path": project, "role_dir": role_dir,
|
|
42
60
|
"profile_name": profile,
|
|
43
|
-
"telegram": {
|
|
61
|
+
"telegram": {
|
|
62
|
+
"provisioning_status": telegram_status,
|
|
63
|
+
"bot_username": bot,
|
|
64
|
+
"bot_id": telegram_bot_id,
|
|
65
|
+
},
|
|
66
|
+
"slack": {
|
|
67
|
+
"provisioning_status": slack_status,
|
|
68
|
+
"team_id": slack_team_id,
|
|
69
|
+
"team_name": slack_team_name,
|
|
70
|
+
"bot_user_id": slack_user_id,
|
|
71
|
+
"bot_id": slack_bot_id,
|
|
72
|
+
"bot_username": slack_username,
|
|
73
|
+
},
|
|
74
|
+
"bloodbank": {
|
|
75
|
+
"gateway_scope": bloodbank_scope,
|
|
76
|
+
"target_agent_id": bloodbank_target,
|
|
77
|
+
},
|
|
44
78
|
"plane": {"workspace": plane_ws, "project_id": plane_id, "identifier": plane_ident},
|
|
45
79
|
"runtime_repo": runtime_repo,
|
|
46
|
-
"hermes": {
|
|
47
|
-
|
|
48
|
-
|
|
80
|
+
"hermes": {
|
|
81
|
+
"bin": hermes_bin,
|
|
82
|
+
"repo": hermes_repo,
|
|
83
|
+
"git_url": hermes_git_url,
|
|
84
|
+
"git_ref": hermes_git_ref,
|
|
85
|
+
"git_sha": hermes_git_sha,
|
|
86
|
+
"fleet_env": fleet_env,
|
|
87
|
+
},
|
|
88
|
+
"systemd": {"gateway_unit": gw, "heartbeat_timer": heartbeat},
|
|
89
|
+
"provisioned_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
49
90
|
}
|
|
50
|
-
|
|
91
|
+
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
92
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
def fsync_parent(target):
|
|
95
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
96
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
97
|
+
try:
|
|
98
|
+
directory_fd = os.open(target.parent, flags)
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
if exc.errno in unsupported:
|
|
101
|
+
return
|
|
102
|
+
raise
|
|
103
|
+
try:
|
|
104
|
+
try:
|
|
105
|
+
os.fsync(directory_fd)
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
if exc.errno not in unsupported:
|
|
108
|
+
raise
|
|
109
|
+
finally:
|
|
110
|
+
os.close(directory_fd)
|
|
111
|
+
|
|
112
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{p.name}.registry-", dir=p.parent)
|
|
113
|
+
try:
|
|
114
|
+
os.fchmod(fd, 0o600)
|
|
115
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
116
|
+
handle.write(rendered)
|
|
117
|
+
handle.flush()
|
|
118
|
+
os.fsync(handle.fileno())
|
|
119
|
+
os.replace(temporary, p)
|
|
120
|
+
os.chmod(p, 0o600)
|
|
121
|
+
fsync_parent(p)
|
|
122
|
+
except BaseException:
|
|
123
|
+
try:
|
|
124
|
+
os.unlink(temporary)
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
pass
|
|
127
|
+
raise
|
|
51
128
|
PYEOF
|
|
52
129
|
|
|
130
|
+
fleet_lock_release
|
|
131
|
+
trap - EXIT
|
|
132
|
+
|
|
53
133
|
mark_done 80-registry
|