@delorenj/pjangler 1.3.0 → 1.3.7
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 +72 -0
- package/dist/index.js +4510 -2485
- package/dist/mcp-server.js +4166 -1959
- package/dist/prompt.js +404 -0
- package/package.json +7 -5
- package/templates/commonproject/copier.yml +6 -1
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- package/templates/hermes-agent/template/role.yaml.jinja +19 -19
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parse the supported fleet.env assignment grammar without executing it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ctypes
|
|
7
|
+
import errno
|
|
8
|
+
import hashlib
|
|
9
|
+
import re
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Mapping, NamedTuple
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
HEADER = b"PJANGLER_FLEET_ENV_V1"
|
|
19
|
+
FOOTER = b"PJANGLER_FLEET_ENV_END"
|
|
20
|
+
NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
21
|
+
ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=")
|
|
22
|
+
LEGACY_EXPANSION_NAME = "HERMES_FLEET_HOME"
|
|
23
|
+
LEGACY_EXPANSION = re.compile(
|
|
24
|
+
r"\$(?:\{HERMES_FLEET_HOME\}|HERMES_FLEET_HOME(?![A-Za-z0-9_]))"
|
|
25
|
+
)
|
|
26
|
+
UNICODE_ERROR = "fleet environment parse error: input and values must be valid UTF-8 Unicode"
|
|
27
|
+
INITIAL_DOCUMENT = (
|
|
28
|
+
"# Hermes fleet source of truth.\n"
|
|
29
|
+
"# All generated wrappers and provisioning scripts read this file.\n"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FleetEnvParseError(ValueError):
|
|
34
|
+
def __init__(self, line: int, message: str) -> None:
|
|
35
|
+
super().__init__(f"line {line}: {message}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FleetEnvRecoveryError(OSError):
|
|
39
|
+
"""A concurrent destination was preserved after rollback could not finish."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, recovery_path: Path) -> None:
|
|
42
|
+
super().__init__(
|
|
43
|
+
"fleet environment destination changed and recovery failed; "
|
|
44
|
+
"concurrent data preserved"
|
|
45
|
+
)
|
|
46
|
+
self.recovery_path = recovery_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ParsedRecord(NamedTuple):
|
|
50
|
+
key: str
|
|
51
|
+
value: str
|
|
52
|
+
start: int
|
|
53
|
+
end: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def line_number(text: str, offset: int) -> int:
|
|
57
|
+
return text.count("\n", 0, offset) + 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def line_end(text: str, offset: int) -> int:
|
|
61
|
+
end = text.find("\n", offset)
|
|
62
|
+
return len(text) if end < 0 else end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def next_line(text: str, end: int) -> int:
|
|
66
|
+
return end if end == len(text) else end + 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_suffix(text: str, offset: int, origin: int) -> int:
|
|
70
|
+
end = line_end(text, offset)
|
|
71
|
+
suffix = text[offset:end]
|
|
72
|
+
if not re.fullmatch(r"[ \t]*(?:#.*)?", suffix):
|
|
73
|
+
raise FleetEnvParseError(
|
|
74
|
+
line_number(text, origin), "unexpected content after quoted value"
|
|
75
|
+
)
|
|
76
|
+
return next_line(text, end)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_single_quoted(text: str, offset: int) -> tuple[str, int]:
|
|
80
|
+
origin = offset
|
|
81
|
+
closing = text.find("'", offset + 1)
|
|
82
|
+
if closing < 0:
|
|
83
|
+
raise FleetEnvParseError(line_number(text, origin), "unterminated single quote")
|
|
84
|
+
return text[offset + 1 : closing], validate_suffix(text, closing + 1, origin)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_double_quoted(text: str, offset: int) -> tuple[str, int]:
|
|
88
|
+
origin = offset
|
|
89
|
+
cursor = offset + 1
|
|
90
|
+
value: list[str] = []
|
|
91
|
+
while cursor < len(text):
|
|
92
|
+
char = text[cursor]
|
|
93
|
+
if char == '"':
|
|
94
|
+
return "".join(value), validate_suffix(text, cursor + 1, origin)
|
|
95
|
+
if char in {"$", "`"}:
|
|
96
|
+
raise FleetEnvParseError(
|
|
97
|
+
line_number(text, cursor),
|
|
98
|
+
"dynamic expansion is not supported in fleet.env",
|
|
99
|
+
)
|
|
100
|
+
if char != "\\":
|
|
101
|
+
value.append(char)
|
|
102
|
+
cursor += 1
|
|
103
|
+
continue
|
|
104
|
+
if cursor + 1 >= len(text):
|
|
105
|
+
raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
|
|
106
|
+
escaped = text[cursor + 1]
|
|
107
|
+
if escaped == "\n":
|
|
108
|
+
cursor += 2
|
|
109
|
+
continue
|
|
110
|
+
if escaped in {'"', "\\", "$", "`"}:
|
|
111
|
+
value.append(escaped)
|
|
112
|
+
else:
|
|
113
|
+
value.extend(("\\", escaped))
|
|
114
|
+
cursor += 2
|
|
115
|
+
raise FleetEnvParseError(line_number(text, origin), "unterminated double quote")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
ANSI_ESCAPES = {
|
|
119
|
+
"a": "\a",
|
|
120
|
+
"b": "\b",
|
|
121
|
+
"e": "\x1b",
|
|
122
|
+
"E": "\x1b",
|
|
123
|
+
"f": "\f",
|
|
124
|
+
"n": "\n",
|
|
125
|
+
"r": "\r",
|
|
126
|
+
"t": "\t",
|
|
127
|
+
"v": "\v",
|
|
128
|
+
"\\": "\\",
|
|
129
|
+
"'": "'",
|
|
130
|
+
'"': '"',
|
|
131
|
+
"?": "?",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_hex_escape(text: str, offset: int, maximum: int) -> tuple[str, int]:
|
|
136
|
+
cursor = offset
|
|
137
|
+
while (
|
|
138
|
+
cursor < len(text)
|
|
139
|
+
and cursor - offset < maximum
|
|
140
|
+
and text[cursor] in "0123456789abcdefABCDEF"
|
|
141
|
+
):
|
|
142
|
+
cursor += 1
|
|
143
|
+
if cursor == offset:
|
|
144
|
+
raise FleetEnvParseError(line_number(text, offset), "empty hexadecimal escape")
|
|
145
|
+
return chr(int(text[offset:cursor], 16)), cursor
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_ansi_c_quoted(text: str, offset: int) -> tuple[str, int]:
|
|
149
|
+
origin = offset
|
|
150
|
+
cursor = offset + 2
|
|
151
|
+
value: list[str] = []
|
|
152
|
+
while cursor < len(text):
|
|
153
|
+
char = text[cursor]
|
|
154
|
+
if char == "'":
|
|
155
|
+
parsed = "".join(value)
|
|
156
|
+
if "\0" in parsed:
|
|
157
|
+
raise FleetEnvParseError(
|
|
158
|
+
line_number(text, origin), "NUL is not allowed"
|
|
159
|
+
)
|
|
160
|
+
return parsed, validate_suffix(text, cursor + 1, origin)
|
|
161
|
+
if char != "\\":
|
|
162
|
+
value.append(char)
|
|
163
|
+
cursor += 1
|
|
164
|
+
continue
|
|
165
|
+
if cursor + 1 >= len(text):
|
|
166
|
+
raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
|
|
167
|
+
escaped = text[cursor + 1]
|
|
168
|
+
if escaped == "\n":
|
|
169
|
+
cursor += 2
|
|
170
|
+
continue
|
|
171
|
+
if escaped in ANSI_ESCAPES:
|
|
172
|
+
value.append(ANSI_ESCAPES[escaped])
|
|
173
|
+
cursor += 2
|
|
174
|
+
continue
|
|
175
|
+
if escaped == "x":
|
|
176
|
+
decoded, cursor = parse_hex_escape(text, cursor + 2, 2)
|
|
177
|
+
value.append(decoded)
|
|
178
|
+
continue
|
|
179
|
+
if escaped in {"u", "U"}:
|
|
180
|
+
width = 4 if escaped == "u" else 8
|
|
181
|
+
start = cursor + 2
|
|
182
|
+
digits = text[start : start + width]
|
|
183
|
+
if len(digits) != width or any(
|
|
184
|
+
c not in "0123456789abcdefABCDEF" for c in digits
|
|
185
|
+
):
|
|
186
|
+
raise FleetEnvParseError(
|
|
187
|
+
line_number(text, cursor), "invalid Unicode escape"
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
value.append(chr(int(digits, 16)))
|
|
191
|
+
except ValueError as error:
|
|
192
|
+
raise FleetEnvParseError(
|
|
193
|
+
line_number(text, cursor), "invalid Unicode code point"
|
|
194
|
+
) from error
|
|
195
|
+
cursor = start + width
|
|
196
|
+
continue
|
|
197
|
+
if escaped in "01234567":
|
|
198
|
+
start = cursor + 1
|
|
199
|
+
end = start
|
|
200
|
+
while end < len(text) and end - start < 3 and text[end] in "01234567":
|
|
201
|
+
end += 1
|
|
202
|
+
value.append(chr(int(text[start:end], 8)))
|
|
203
|
+
cursor = end
|
|
204
|
+
continue
|
|
205
|
+
value.extend(("\\", escaped))
|
|
206
|
+
cursor += 2
|
|
207
|
+
raise FleetEnvParseError(line_number(text, origin), "unterminated ANSI-C quote")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def parse_unquoted(
|
|
211
|
+
text: str,
|
|
212
|
+
offset: int,
|
|
213
|
+
expansion_values: Mapping[str, str],
|
|
214
|
+
) -> tuple[str, int]:
|
|
215
|
+
end = line_end(text, offset)
|
|
216
|
+
raw = text[offset:end]
|
|
217
|
+
comment = re.search(r"[ \t]+#", raw)
|
|
218
|
+
if comment:
|
|
219
|
+
raw = raw[: comment.start()]
|
|
220
|
+
if not raw:
|
|
221
|
+
return "", next_line(text, end)
|
|
222
|
+
if re.search(r"[ \t;&|<>()`\\'\"]", raw):
|
|
223
|
+
raise FleetEnvParseError(
|
|
224
|
+
line_number(text, offset), "unquoted value contains shell syntax"
|
|
225
|
+
)
|
|
226
|
+
if "$" not in raw:
|
|
227
|
+
return raw, next_line(text, end)
|
|
228
|
+
|
|
229
|
+
# Compatibility is intentionally narrow: exactly one allowed token must be
|
|
230
|
+
# the first byte, followed only by the already-validated literal suffix.
|
|
231
|
+
# Prefixes and repeated/partial tokens would turn this into a general
|
|
232
|
+
# expansion language and are therefore rejected.
|
|
233
|
+
match = LEGACY_EXPANSION.match(raw)
|
|
234
|
+
if match is None or match.start() != 0:
|
|
235
|
+
raise FleetEnvParseError(
|
|
236
|
+
line_number(text, offset),
|
|
237
|
+
"dynamic expansion is not supported in fleet.env",
|
|
238
|
+
)
|
|
239
|
+
suffix = raw[match.end() :]
|
|
240
|
+
if "$" in suffix:
|
|
241
|
+
raise FleetEnvParseError(
|
|
242
|
+
line_number(text, offset),
|
|
243
|
+
"dynamic expansion is not supported in fleet.env",
|
|
244
|
+
)
|
|
245
|
+
value = expansion_values.get(LEGACY_EXPANSION_NAME)
|
|
246
|
+
if value is None:
|
|
247
|
+
raise FleetEnvParseError(
|
|
248
|
+
line_number(text, offset),
|
|
249
|
+
"legacy HERMES_FLEET_HOME expansion has no value",
|
|
250
|
+
)
|
|
251
|
+
return value + suffix, next_line(text, end)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def parse_value(
|
|
255
|
+
text: str,
|
|
256
|
+
offset: int,
|
|
257
|
+
expansion_values: Mapping[str, str],
|
|
258
|
+
) -> tuple[str, int]:
|
|
259
|
+
if offset >= len(text) or text[offset] == "\n":
|
|
260
|
+
end = line_end(text, offset)
|
|
261
|
+
return "", next_line(text, end)
|
|
262
|
+
if text.startswith("$'", offset):
|
|
263
|
+
return parse_ansi_c_quoted(text, offset)
|
|
264
|
+
if text[offset] == "'":
|
|
265
|
+
return parse_single_quoted(text, offset)
|
|
266
|
+
if text[offset] == '"':
|
|
267
|
+
return parse_double_quoted(text, offset)
|
|
268
|
+
return parse_unquoted(text, offset, expansion_values)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def normalize_text(text: str) -> str:
|
|
272
|
+
if "\0" in text:
|
|
273
|
+
raise FleetEnvParseError(1, "NUL is not allowed")
|
|
274
|
+
if "\r" in text:
|
|
275
|
+
text = text.replace("\r\n", "\n")
|
|
276
|
+
if "\r" in text:
|
|
277
|
+
raise FleetEnvParseError(1, "bare carriage return is not supported")
|
|
278
|
+
return text
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def validate_unicode(value: str) -> None:
|
|
282
|
+
for char in value:
|
|
283
|
+
codepoint = ord(char)
|
|
284
|
+
if 0xD800 <= codepoint <= 0xDFFF:
|
|
285
|
+
raise UnicodeError("surrogate code point")
|
|
286
|
+
if 0xFDD0 <= codepoint <= 0xFDEF or codepoint & 0xFFFE == 0xFFFE:
|
|
287
|
+
raise UnicodeError("Unicode noncharacter")
|
|
288
|
+
value.encode("utf-8", errors="strict")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_document(
|
|
292
|
+
text: str,
|
|
293
|
+
environment: Mapping[str, str] | None = None,
|
|
294
|
+
) -> tuple[str, list[ParsedRecord]]:
|
|
295
|
+
text = normalize_text(text)
|
|
296
|
+
inherited = environment if environment is not None else os.environ
|
|
297
|
+
expansion_values: dict[str, str] = {}
|
|
298
|
+
caller_has_fleet_home = LEGACY_EXPANSION_NAME in inherited
|
|
299
|
+
if caller_has_fleet_home:
|
|
300
|
+
expansion_values[LEGACY_EXPANSION_NAME] = inherited[LEGACY_EXPANSION_NAME]
|
|
301
|
+
|
|
302
|
+
cursor = 0
|
|
303
|
+
records: list[ParsedRecord] = []
|
|
304
|
+
seen: set[str] = set()
|
|
305
|
+
while cursor < len(text):
|
|
306
|
+
origin = cursor
|
|
307
|
+
end = line_end(text, cursor)
|
|
308
|
+
physical = text[cursor:end]
|
|
309
|
+
if not physical.strip() or physical.lstrip().startswith("#"):
|
|
310
|
+
cursor = next_line(text, end)
|
|
311
|
+
continue
|
|
312
|
+
match = ASSIGNMENT.match(text, cursor)
|
|
313
|
+
if not match or match.end() > end:
|
|
314
|
+
raise FleetEnvParseError(
|
|
315
|
+
line_number(text, cursor), "expected KEY=value or export KEY=value"
|
|
316
|
+
)
|
|
317
|
+
key = match.group(1)
|
|
318
|
+
if not NAME.fullmatch(key):
|
|
319
|
+
raise FleetEnvParseError(line_number(text, cursor), "invalid variable name")
|
|
320
|
+
if key in seen:
|
|
321
|
+
raise FleetEnvParseError(
|
|
322
|
+
line_number(text, cursor), f"duplicate variable {key}"
|
|
323
|
+
)
|
|
324
|
+
value, cursor = parse_value(text, match.end(), expansion_values)
|
|
325
|
+
validate_unicode(value)
|
|
326
|
+
seen.add(key)
|
|
327
|
+
records.append(ParsedRecord(key, value, origin, cursor))
|
|
328
|
+
if key == LEGACY_EXPANSION_NAME and not caller_has_fleet_home:
|
|
329
|
+
expansion_values[key] = value
|
|
330
|
+
return text, records
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def parse(
|
|
334
|
+
text: str,
|
|
335
|
+
environment: Mapping[str, str] | None = None,
|
|
336
|
+
) -> list[tuple[str, str]]:
|
|
337
|
+
_, records = parse_document(text, environment)
|
|
338
|
+
return [(record.key, record.value) for record in records]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def serialize_literal(value: str) -> str:
|
|
342
|
+
validate_unicode(value)
|
|
343
|
+
escaped: list[str] = []
|
|
344
|
+
simple_escapes = {
|
|
345
|
+
"\\": r"\\",
|
|
346
|
+
"'": r"\'",
|
|
347
|
+
"\a": r"\a",
|
|
348
|
+
"\b": r"\b",
|
|
349
|
+
"\x1b": r"\e",
|
|
350
|
+
"\f": r"\f",
|
|
351
|
+
"\n": r"\n",
|
|
352
|
+
"\r": r"\r",
|
|
353
|
+
"\t": r"\t",
|
|
354
|
+
"\v": r"\v",
|
|
355
|
+
}
|
|
356
|
+
for char in value:
|
|
357
|
+
if char in simple_escapes:
|
|
358
|
+
escaped.append(simple_escapes[char])
|
|
359
|
+
elif ord(char) < 0x20 or ord(char) == 0x7F:
|
|
360
|
+
escaped.append(f"\\x{ord(char):02x}")
|
|
361
|
+
else:
|
|
362
|
+
escaped.append(char)
|
|
363
|
+
return "$'" + "".join(escaped) + "'"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def serialize_systemd_value(value: str) -> str:
|
|
367
|
+
"""Return one lossless, injection-safe systemd.syntax scalar."""
|
|
368
|
+
validate_unicode(value)
|
|
369
|
+
if "\0" in value:
|
|
370
|
+
raise FleetEnvParseError(1, "NUL is not allowed in a systemd value")
|
|
371
|
+
if "\r" in value or "\n" in value:
|
|
372
|
+
raise FleetEnvParseError(1, "newline control characters are not allowed in a systemd value")
|
|
373
|
+
escaped: list[str] = []
|
|
374
|
+
for char in value:
|
|
375
|
+
if char == "\\":
|
|
376
|
+
escaped.append(r"\\")
|
|
377
|
+
elif char == '"':
|
|
378
|
+
escaped.append(r'\"')
|
|
379
|
+
elif char == "%":
|
|
380
|
+
# systemd specifiers are expanded after syntax unquoting. Doubling
|
|
381
|
+
# preserves a caller-provided percent byte as literal data.
|
|
382
|
+
escaped.append("%%")
|
|
383
|
+
elif char == "\t":
|
|
384
|
+
escaped.append(r"\t")
|
|
385
|
+
elif ord(char) < 0x20 or ord(char) == 0x7F:
|
|
386
|
+
escaped.append(f"\\x{ord(char):02x}")
|
|
387
|
+
else:
|
|
388
|
+
escaped.append(char)
|
|
389
|
+
return '"' + "".join(escaped) + '"'
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def serialize_systemd_environment(name: str, value: str) -> str:
|
|
393
|
+
if not NAME.fullmatch(name):
|
|
394
|
+
raise FleetEnvParseError(1, "invalid systemd environment variable name")
|
|
395
|
+
return f"Environment={serialize_systemd_value(f'{name}={value}')}"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def serialize_systemd_exec_value(value: str) -> str:
|
|
399
|
+
"""Quote one ExecStart token while suppressing systemd $/%% expansion."""
|
|
400
|
+
return serialize_systemd_value(value.replace("$", "$$"))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def read_regular_document(
|
|
404
|
+
path: Path,
|
|
405
|
+
*,
|
|
406
|
+
allow_missing: bool = False,
|
|
407
|
+
) -> tuple[str, os.stat_result | None]:
|
|
408
|
+
try:
|
|
409
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
410
|
+
except FileNotFoundError:
|
|
411
|
+
if allow_missing:
|
|
412
|
+
return INITIAL_DOCUMENT, None
|
|
413
|
+
raise FleetEnvParseError(1, "fleet environment path is unavailable")
|
|
414
|
+
except OSError as error:
|
|
415
|
+
raise FleetEnvParseError(1, "fleet environment path must be a regular file") from error
|
|
416
|
+
try:
|
|
417
|
+
metadata = os.fstat(descriptor)
|
|
418
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
419
|
+
raise FleetEnvParseError(1, "fleet environment path must be a regular file")
|
|
420
|
+
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
|
421
|
+
content = stream.read()
|
|
422
|
+
after = os.fstat(descriptor)
|
|
423
|
+
if not _same_file_metadata(metadata, after):
|
|
424
|
+
raise FleetEnvParseError(1, "fleet environment changed while it was read")
|
|
425
|
+
return content.decode("utf-8"), after
|
|
426
|
+
finally:
|
|
427
|
+
os.close(descriptor)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def write_atomic_document(
|
|
431
|
+
path: Path,
|
|
432
|
+
content: str,
|
|
433
|
+
original: os.stat_result | None,
|
|
434
|
+
original_content: bytes | None = None,
|
|
435
|
+
) -> None:
|
|
436
|
+
parent = path.parent
|
|
437
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
438
|
+
prefix=f".{path.name}.",
|
|
439
|
+
dir=parent,
|
|
440
|
+
)
|
|
441
|
+
temporary = Path(temporary_name)
|
|
442
|
+
preserve_temporary = False
|
|
443
|
+
try:
|
|
444
|
+
desired_mode = stat.S_IMODE(original.st_mode) if original else 0o600
|
|
445
|
+
if original is not None:
|
|
446
|
+
# chown(2) may clear set-ID mode bits. Establish ownership first,
|
|
447
|
+
# then restore the exact original mode and attest both properties
|
|
448
|
+
# on the prepared inode before it can participate in an exchange.
|
|
449
|
+
# Permission failures are not an excuse to silently drift metadata.
|
|
450
|
+
os.fchown(descriptor, original.st_uid, original.st_gid)
|
|
451
|
+
encoded = content.encode("utf-8", errors="strict")
|
|
452
|
+
with os.fdopen(descriptor, "wb", closefd=False) as stream:
|
|
453
|
+
stream.write(encoded)
|
|
454
|
+
stream.flush()
|
|
455
|
+
# A content write can also clear set-ID bits. This is therefore the
|
|
456
|
+
# final metadata operation before attestation and atomic commit.
|
|
457
|
+
os.fchmod(descriptor, desired_mode)
|
|
458
|
+
prepared = os.fstat(descriptor)
|
|
459
|
+
if original is not None and (
|
|
460
|
+
prepared.st_uid != original.st_uid
|
|
461
|
+
or prepared.st_gid != original.st_gid
|
|
462
|
+
):
|
|
463
|
+
raise OSError("fleet environment ownership could not be preserved")
|
|
464
|
+
if stat.S_IMODE(prepared.st_mode) != desired_mode:
|
|
465
|
+
raise OSError("fleet environment mode could not be preserved")
|
|
466
|
+
os.fsync(descriptor)
|
|
467
|
+
os.close(descriptor)
|
|
468
|
+
descriptor = -1
|
|
469
|
+
|
|
470
|
+
if original is None:
|
|
471
|
+
# link(2) supplies portable no-clobber creation. The temporary and
|
|
472
|
+
# destination share a directory/filesystem, so successful linking
|
|
473
|
+
# commits the fully-written inode without a check/use gap.
|
|
474
|
+
os.link(temporary, path, follow_symlinks=False)
|
|
475
|
+
temporary.unlink()
|
|
476
|
+
else:
|
|
477
|
+
# Linux renameat2(RENAME_EXCHANGE) swaps the prepared file and the
|
|
478
|
+
# current destination in one syscall. Only after that atomic claim
|
|
479
|
+
# do we inspect the displaced inode. A concurrent replacement is
|
|
480
|
+
# immediately exchanged back and reported, never overwritten.
|
|
481
|
+
# Platforms without atomic exchange fail closed before mutation;
|
|
482
|
+
# there is no portable conditional-replace primitive for this CAS.
|
|
483
|
+
_exchange_paths(temporary, path)
|
|
484
|
+
displaced = os.lstat(temporary)
|
|
485
|
+
if original_content is None or not _matches_file_snapshot(
|
|
486
|
+
temporary,
|
|
487
|
+
original,
|
|
488
|
+
original_content,
|
|
489
|
+
):
|
|
490
|
+
try:
|
|
491
|
+
_exchange_paths(temporary, path)
|
|
492
|
+
except OSError as recovery_error:
|
|
493
|
+
# After the failed reverse exchange, `path` contains our
|
|
494
|
+
# prepared update and `temporary` contains the concurrent
|
|
495
|
+
# replacement. From this point onward the temporary name is
|
|
496
|
+
# data, not scratch: never let the generic finally cleanup
|
|
497
|
+
# unlink it. Give the displaced inode a deterministic,
|
|
498
|
+
# restrictive recovery name when the filesystem permits,
|
|
499
|
+
# otherwise retain the exact mkstemp name in exception
|
|
500
|
+
# metadata for operator recovery.
|
|
501
|
+
preserve_temporary = True
|
|
502
|
+
recovery_path = _preserve_displaced_recovery(
|
|
503
|
+
temporary,
|
|
504
|
+
path,
|
|
505
|
+
displaced,
|
|
506
|
+
)
|
|
507
|
+
raise FleetEnvRecoveryError(recovery_path) from recovery_error
|
|
508
|
+
raise OSError("fleet environment destination changed during update")
|
|
509
|
+
temporary.unlink()
|
|
510
|
+
|
|
511
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
512
|
+
directory_descriptor = os.open(parent, directory_flags)
|
|
513
|
+
try:
|
|
514
|
+
os.fsync(directory_descriptor)
|
|
515
|
+
finally:
|
|
516
|
+
os.close(directory_descriptor)
|
|
517
|
+
finally:
|
|
518
|
+
if descriptor >= 0:
|
|
519
|
+
os.close(descriptor)
|
|
520
|
+
if not preserve_temporary:
|
|
521
|
+
try:
|
|
522
|
+
temporary.unlink()
|
|
523
|
+
except FileNotFoundError:
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _same_file_metadata(expected: os.stat_result, actual: os.stat_result) -> bool:
|
|
528
|
+
"""Compare the stable identity, content-version, and security metadata."""
|
|
529
|
+
return (
|
|
530
|
+
stat.S_ISREG(actual.st_mode)
|
|
531
|
+
and (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino)
|
|
532
|
+
and actual.st_mode == expected.st_mode
|
|
533
|
+
and actual.st_uid == expected.st_uid
|
|
534
|
+
and actual.st_gid == expected.st_gid
|
|
535
|
+
and actual.st_size == expected.st_size
|
|
536
|
+
and actual.st_mtime_ns == expected.st_mtime_ns
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _matches_file_snapshot(
|
|
541
|
+
path: Path,
|
|
542
|
+
expected: os.stat_result,
|
|
543
|
+
expected_content: bytes,
|
|
544
|
+
) -> bool:
|
|
545
|
+
"""Attest one displaced inode without following a replacement symlink."""
|
|
546
|
+
try:
|
|
547
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
548
|
+
except OSError:
|
|
549
|
+
return False
|
|
550
|
+
try:
|
|
551
|
+
before = os.fstat(descriptor)
|
|
552
|
+
chunks: list[bytes] = []
|
|
553
|
+
while True:
|
|
554
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
555
|
+
if not chunk:
|
|
556
|
+
break
|
|
557
|
+
chunks.append(chunk)
|
|
558
|
+
after = os.fstat(descriptor)
|
|
559
|
+
finally:
|
|
560
|
+
os.close(descriptor)
|
|
561
|
+
return (
|
|
562
|
+
_same_file_metadata(expected, before)
|
|
563
|
+
and _same_file_metadata(before, after)
|
|
564
|
+
and b"".join(chunks) == expected_content
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _remove_recovery_temporary(path: Path) -> bool:
|
|
569
|
+
"""Best-effort cleanup after a durable recovery link was established."""
|
|
570
|
+
try:
|
|
571
|
+
path.unlink()
|
|
572
|
+
except OSError:
|
|
573
|
+
return False
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _preserve_displaced_recovery(
|
|
578
|
+
temporary: Path,
|
|
579
|
+
destination: Path,
|
|
580
|
+
displaced: os.stat_result,
|
|
581
|
+
) -> Path:
|
|
582
|
+
"""Keep a displaced concurrent inode reachable without overwriting data."""
|
|
583
|
+
if stat.S_ISREG(displaced.st_mode):
|
|
584
|
+
descriptor = os.open(temporary, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
585
|
+
try:
|
|
586
|
+
os.fchmod(descriptor, 0o600)
|
|
587
|
+
digest = hashlib.sha256()
|
|
588
|
+
while True:
|
|
589
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
590
|
+
if not chunk:
|
|
591
|
+
break
|
|
592
|
+
digest.update(chunk)
|
|
593
|
+
finally:
|
|
594
|
+
os.close(descriptor)
|
|
595
|
+
fingerprint = digest.hexdigest()[:16]
|
|
596
|
+
else:
|
|
597
|
+
fingerprint = f"mode-{stat.S_IFMT(displaced.st_mode):x}"
|
|
598
|
+
|
|
599
|
+
recovery = destination.parent / (
|
|
600
|
+
f".{destination.name}.pjangler-recovery-"
|
|
601
|
+
f"{displaced.st_dev:x}-{displaced.st_ino:x}-{fingerprint}"
|
|
602
|
+
)
|
|
603
|
+
try:
|
|
604
|
+
os.link(temporary, recovery, follow_symlinks=False)
|
|
605
|
+
except FileExistsError:
|
|
606
|
+
existing = os.lstat(recovery)
|
|
607
|
+
if (existing.st_dev, existing.st_ino) != (displaced.st_dev, displaced.st_ino):
|
|
608
|
+
return temporary
|
|
609
|
+
except OSError:
|
|
610
|
+
return temporary
|
|
611
|
+
_remove_recovery_temporary(temporary)
|
|
612
|
+
return recovery
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _exchange_paths(left: Path, right: Path) -> None:
|
|
616
|
+
"""Atomically exchange two paths, or fail without a portability downgrade."""
|
|
617
|
+
try:
|
|
618
|
+
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
|
|
619
|
+
except AttributeError as error:
|
|
620
|
+
raise OSError(errno.ENOTSUP, "atomic path exchange is unavailable") from error
|
|
621
|
+
renameat2.argtypes = (
|
|
622
|
+
ctypes.c_int,
|
|
623
|
+
ctypes.c_char_p,
|
|
624
|
+
ctypes.c_int,
|
|
625
|
+
ctypes.c_char_p,
|
|
626
|
+
ctypes.c_uint,
|
|
627
|
+
)
|
|
628
|
+
renameat2.restype = ctypes.c_int
|
|
629
|
+
at_fdcwd = -100
|
|
630
|
+
rename_exchange = 2
|
|
631
|
+
if renameat2(
|
|
632
|
+
at_fdcwd,
|
|
633
|
+
os.fsencode(left),
|
|
634
|
+
at_fdcwd,
|
|
635
|
+
os.fsencode(right),
|
|
636
|
+
rename_exchange,
|
|
637
|
+
) != 0:
|
|
638
|
+
code = ctypes.get_errno()
|
|
639
|
+
raise OSError(code, os.strerror(code))
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def render_upsert(
|
|
643
|
+
text: str,
|
|
644
|
+
key: str,
|
|
645
|
+
value: str,
|
|
646
|
+
environment: Mapping[str, str] | None = None,
|
|
647
|
+
) -> str:
|
|
648
|
+
if not NAME.fullmatch(key):
|
|
649
|
+
raise FleetEnvParseError(1, "invalid variable name")
|
|
650
|
+
normalized, records = parse_document(text, environment)
|
|
651
|
+
matches = [record for record in records if record.key == key]
|
|
652
|
+
if len(matches) > 1:
|
|
653
|
+
raise FleetEnvParseError(1, f"duplicate variable {key}")
|
|
654
|
+
|
|
655
|
+
replacement = f"{key}={serialize_literal(value)}\n"
|
|
656
|
+
if matches:
|
|
657
|
+
record = matches[0]
|
|
658
|
+
updated = normalized[: record.start] + replacement + normalized[record.end :]
|
|
659
|
+
else:
|
|
660
|
+
separator = "" if not normalized or normalized.endswith("\n") else "\n"
|
|
661
|
+
updated = normalized + separator + replacement
|
|
662
|
+
|
|
663
|
+
# Validate the complete prospective document before opening a temporary
|
|
664
|
+
# output. This prevents a malformed legacy record from being partially
|
|
665
|
+
# repaired or hidden by an otherwise valid upsert.
|
|
666
|
+
parse_document(updated, environment)
|
|
667
|
+
return updated
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def atomic_upsert(path: Path, key: str, value: str) -> None:
|
|
671
|
+
text, original = read_regular_document(path, allow_missing=True)
|
|
672
|
+
updated = render_upsert(text, key, value)
|
|
673
|
+
original_content = text.encode("utf-8", errors="strict") if original is not None else None
|
|
674
|
+
write_atomic_document(path, updated, original, original_content)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def emit_records(records: list[tuple[str, str]]) -> None:
|
|
678
|
+
framed = [HEADER]
|
|
679
|
+
framed.extend(f"{key}={value}".encode("utf-8") for key, value in records)
|
|
680
|
+
framed.extend((FOOTER, b"", b""))
|
|
681
|
+
sys.stdout.buffer.write(b"\0".join(framed))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def main() -> int:
|
|
685
|
+
parse_mode = len(sys.argv) == 2
|
|
686
|
+
upsert_mode = len(sys.argv) == 5 and sys.argv[1] == "--upsert"
|
|
687
|
+
systemd_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-value"
|
|
688
|
+
systemd_exec_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-exec-value"
|
|
689
|
+
systemd_environment_mode = len(sys.argv) == 4 and sys.argv[1] == "--systemd-environment"
|
|
690
|
+
if not parse_mode and not upsert_mode and not systemd_value_mode and not systemd_exec_value_mode and not systemd_environment_mode:
|
|
691
|
+
print(
|
|
692
|
+
"usage: parse-fleet-env.py PATH | --upsert PATH KEY VALUE | "
|
|
693
|
+
"--systemd-value VALUE | --systemd-exec-value VALUE | --systemd-environment NAME VALUE",
|
|
694
|
+
file=sys.stderr,
|
|
695
|
+
)
|
|
696
|
+
return 2
|
|
697
|
+
try:
|
|
698
|
+
if systemd_value_mode:
|
|
699
|
+
print(serialize_systemd_value(sys.argv[2]), end="")
|
|
700
|
+
return 0
|
|
701
|
+
if systemd_exec_value_mode:
|
|
702
|
+
print(serialize_systemd_exec_value(sys.argv[2]), end="")
|
|
703
|
+
return 0
|
|
704
|
+
if systemd_environment_mode:
|
|
705
|
+
print(serialize_systemd_environment(sys.argv[2], sys.argv[3]), end="")
|
|
706
|
+
return 0
|
|
707
|
+
if upsert_mode:
|
|
708
|
+
atomic_upsert(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
|
|
709
|
+
return 0
|
|
710
|
+
path = Path(sys.argv[1])
|
|
711
|
+
text, _ = read_regular_document(path)
|
|
712
|
+
records = parse(text)
|
|
713
|
+
emit_records(records)
|
|
714
|
+
except UnicodeError:
|
|
715
|
+
print(UNICODE_ERROR, file=sys.stderr)
|
|
716
|
+
return 2
|
|
717
|
+
except FleetEnvParseError as error:
|
|
718
|
+
print(f"fleet environment parse error: {error}", file=sys.stderr)
|
|
719
|
+
return 2
|
|
720
|
+
except FleetEnvRecoveryError:
|
|
721
|
+
print(
|
|
722
|
+
"fleet environment write error: concurrent replacement preserved "
|
|
723
|
+
"for operator recovery",
|
|
724
|
+
file=sys.stderr,
|
|
725
|
+
)
|
|
726
|
+
return 2
|
|
727
|
+
except OSError:
|
|
728
|
+
print("fleet environment write error: update was not committed", file=sys.stderr)
|
|
729
|
+
return 2
|
|
730
|
+
return 0
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
if __name__ == "__main__":
|
|
734
|
+
raise SystemExit(main())
|