@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0
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/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py
CHANGED
|
@@ -50,9 +50,9 @@ def _objects(value: Any) -> List[Any]:
|
|
|
50
50
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
from fabric_jsvalue import indexed_objects as _indexed # noqa: E402 - the core's (FAA-65)
|
|
54
|
+
import fabric_time as _ftime # noqa: E402 - the core (FAA-65)
|
|
55
|
+
import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def is_share_grant_id(value: Any) -> bool:
|
|
@@ -91,8 +91,8 @@ def grant_findings(grant: Any, at: str = "") -> List[Dict[str, str]]:
|
|
|
91
91
|
def _agents_under(record: Dict[str, Any], context: Any, grant_id: Any) -> set:
|
|
92
92
|
"""The agentRefs the placements of ``record`` in ``context`` run under the grant ``grant_id`` (by normal form)."""
|
|
93
93
|
placements = [placement for machine in _objects(record.get("machines")) for placement in _objects(machine.get("packages"))]
|
|
94
|
-
return {
|
|
95
|
-
if
|
|
94
|
+
return {_jsv.show(placement.get("agentRef", _jsv.MISSING)) for placement in placements
|
|
95
|
+
if _jsv.same(placement.get("context", _jsv.MISSING), context)
|
|
96
96
|
and isinstance(grant_id, str) and _ids.same_identifier(placement.get("grant"), grant_id)}
|
|
97
97
|
|
|
98
98
|
|
|
@@ -116,7 +116,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
|
|
|
116
116
|
if parent is None:
|
|
117
117
|
continue
|
|
118
118
|
before = next((old for old in _objects(parent.get("shareGrants"))
|
|
119
|
-
if
|
|
119
|
+
if _jsv.same(old.get("context", _jsv.MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
|
|
120
120
|
and isinstance(grant_id, str) and _ids.same_identifier(old["meta"].get("id"), grant_id)), None)
|
|
121
121
|
earlier = _agents_under(parent, before.get("context"), grant_id) if before is not None else set()
|
|
122
122
|
if earlier and any(ref not in earlier for ref in refs):
|
|
@@ -126,7 +126,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
|
|
|
126
126
|
|
|
127
127
|
def _time(value: Any) -> float:
|
|
128
128
|
"""An instant as milliseconds, or NaN: the schema's pattern and a day that exists."""
|
|
129
|
-
return
|
|
129
|
+
return _ftime.parse_rfc3339_ms(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
|
|
130
130
|
|
|
131
131
|
|
|
132
132
|
def registration_findings(value: Any) -> List[Dict[str, str]]:
|
|
@@ -152,7 +152,7 @@ def grant_usage_context_findings(value: Any) -> List[Dict[str, str]]:
|
|
|
152
152
|
for position, event in enumerate(_objects(batch.get("events"))): # the index among the objects, as the contract counts it
|
|
153
153
|
if event.get("kind") != "grant.usage":
|
|
154
154
|
continue
|
|
155
|
-
context = event["data"].get("context",
|
|
155
|
+
context = event["data"].get("context", _jsv.MISSING) if isinstance(event.get("data"), dict) else _jsv.MISSING
|
|
156
156
|
if not isinstance(org, str) or not isinstance(context, str) or context != "org:" + org:
|
|
157
157
|
findings.append(_finding("FAC-SEM-045", "/batch/events/%d/data/context" % position, "a grant's usage carries its grant's own context, org: and the client certificate's org.id; another organization's is refused as attribution"))
|
|
158
158
|
return findings
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Supervisors (DEC-0032, LC-14): launchd plists, systemd user units and Task Scheduler tasks, installed and
|
|
2
|
+
removed without touching what the operator turned off; release pruning (LC-15). Part of the service kit
|
|
3
|
+
(FAA-65, layer L3)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import plistlib
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path, PurePath
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
import sys as _sys # noqa: E402
|
|
21
|
+
from pathlib import Path as _Path # noqa: E402
|
|
22
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
23
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
24
|
+
from fabric_base import (
|
|
25
|
+
DRAIN_SECONDS,
|
|
26
|
+
SUPERVISOR_ENV,
|
|
27
|
+
ServiceError,
|
|
28
|
+
_TASK,
|
|
29
|
+
_UNIT,
|
|
30
|
+
)
|
|
31
|
+
from fabric_paths import (
|
|
32
|
+
atomic_write,
|
|
33
|
+
service_dirs,
|
|
34
|
+
)
|
|
35
|
+
from fabric_lock import (
|
|
36
|
+
SUPERVISOR_ARG,
|
|
37
|
+
_marker_arguments,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
DEFAULT_EXIT_TIMEOUT = 15
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _no_marker_argument(argv: Sequence[str]) -> None:
|
|
48
|
+
"""The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
|
|
49
|
+
if _marker_arguments(list(argv)):
|
|
50
|
+
raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
|
|
54
|
+
"""No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
|
|
55
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
56
|
+
for arg in argv:
|
|
57
|
+
if fabric_secrets.secret_argument(arg):
|
|
58
|
+
raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
|
|
59
|
+
for name, value in (environment or {}).items():
|
|
60
|
+
if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
|
|
61
|
+
raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plain(value: str, what: str) -> str:
|
|
65
|
+
"""A supervisor file is line- and XML-structured: no control character in anything written into it."""
|
|
66
|
+
if re.search(r"[\x00-\x1f\x7f]", value):
|
|
67
|
+
raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _systemd_quote(value: str, dollar: bool = True) -> str:
|
|
72
|
+
"""A quoted systemd value. ExecStart= expands $VAR, so its $ is doubled; Environment= expands none, so `dollar`
|
|
73
|
+
False leaves it alone (finding 17). Both expand % specifiers."""
|
|
74
|
+
_plain(value, "an argument")
|
|
75
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
76
|
+
return '"%s"' % (escaped.replace("$", "$$") if dollar else escaped)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
80
|
+
environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
|
|
81
|
+
stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
|
|
82
|
+
"""A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
83
|
+
WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
|
|
84
|
+
if not _UNIT.match(unit):
|
|
85
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
86
|
+
argv = [str(a) for a in program_arguments]
|
|
87
|
+
if not argv or not argv[0].startswith("/"):
|
|
88
|
+
raise ServiceError("a systemd unit starts an absolute executable")
|
|
89
|
+
if stop_timeout <= DRAIN_SECONDS:
|
|
90
|
+
raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
|
|
91
|
+
env = dict(environment or {})
|
|
92
|
+
_no_secret_in_supervisor_file(argv, env)
|
|
93
|
+
_no_marker_argument(argv)
|
|
94
|
+
env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
|
|
95
|
+
# A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
|
|
96
|
+
cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
|
|
97
|
+
"the working directory")
|
|
98
|
+
if not cwd.startswith("/"):
|
|
99
|
+
raise ServiceError("a systemd unit's working directory is absolute")
|
|
100
|
+
lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
|
|
101
|
+
"[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
|
|
102
|
+
# Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
103
|
+
"WorkingDirectory=%s" % cwd.replace("%", "%%")]
|
|
104
|
+
for name in env:
|
|
105
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*\Z", name):
|
|
106
|
+
raise ServiceError("environment name %r is not a variable name" % name)
|
|
107
|
+
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v), dollar=False) for k, v in sorted(env.items())]
|
|
108
|
+
lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
|
|
109
|
+
"[Install]", "WantedBy=default.target", ""]
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _systemd_dir() -> Path:
|
|
114
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _check(result: Any, what: str) -> None:
|
|
118
|
+
if getattr(result, "returncode", 1) != 0:
|
|
119
|
+
raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
123
|
+
system: str = sys.platform, force_enable: bool = False) -> Path:
|
|
124
|
+
"""Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
125
|
+
|
|
126
|
+
The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
|
|
127
|
+
the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
|
|
128
|
+
if not system.startswith("linux"):
|
|
129
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
130
|
+
if not _UNIT.match(unit):
|
|
131
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
132
|
+
folder = unit_dir or _systemd_dir()
|
|
133
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
path = folder / unit
|
|
135
|
+
first = not path.exists()
|
|
136
|
+
state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
|
|
137
|
+
"stdout", "") or "").strip()
|
|
138
|
+
atomic_write(path, text.encode("utf-8"), 0o644)
|
|
139
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
140
|
+
if state == "disabled" and not force_enable:
|
|
141
|
+
return path
|
|
142
|
+
_check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
|
|
143
|
+
_check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
148
|
+
system: str = sys.platform) -> None:
|
|
149
|
+
"""Stop it and keep it off across logins (`disable --now`), then remove the unit."""
|
|
150
|
+
if not system.startswith("linux"):
|
|
151
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
152
|
+
run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
|
|
153
|
+
path = (unit_dir or _systemd_dir()) / unit
|
|
154
|
+
if path.exists():
|
|
155
|
+
path.unlink()
|
|
156
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _xml(text: str) -> str:
|
|
160
|
+
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
|
|
164
|
+
user: Optional[str] = None) -> str:
|
|
165
|
+
"""A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
|
|
166
|
+
run only when the user is logged on, created without administrator rights."""
|
|
167
|
+
if not _TASK.match(task):
|
|
168
|
+
raise ServiceError("task must match %s" % _TASK.pattern)
|
|
169
|
+
argv = [str(a) for a in program_arguments]
|
|
170
|
+
if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
|
|
171
|
+
raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
|
|
172
|
+
for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
|
|
173
|
+
(description or task, "the description")]:
|
|
174
|
+
_plain(value, what)
|
|
175
|
+
_no_secret_in_supervisor_file(argv, None)
|
|
176
|
+
_no_marker_argument(argv)
|
|
177
|
+
# A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
|
|
178
|
+
cut = argv.index("--") if "--" in argv else len(argv)
|
|
179
|
+
argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
|
|
180
|
+
who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
|
|
181
|
+
if not who:
|
|
182
|
+
raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
|
|
183
|
+
_plain(who, "the user")
|
|
184
|
+
body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
185
|
+
<RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
|
|
186
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
|
|
187
|
+
<Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
188
|
+
<Settings>
|
|
189
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
190
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
191
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
192
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
193
|
+
<Enabled>true</Enabled>
|
|
194
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
195
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
196
|
+
</Settings>
|
|
197
|
+
<Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
|
|
198
|
+
</Task>
|
|
199
|
+
""".format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
|
|
200
|
+
arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
|
|
201
|
+
return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
|
|
208
|
+
force_enable: bool = False) -> None:
|
|
209
|
+
"""Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
|
|
210
|
+
definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
|
|
211
|
+
if system != "win32":
|
|
212
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
213
|
+
query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
|
|
214
|
+
existed = getattr(query, "returncode", 1) == 0
|
|
215
|
+
keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
|
|
216
|
+
fd, name = tempfile.mkstemp(suffix=".xml")
|
|
217
|
+
try:
|
|
218
|
+
with os.fdopen(fd, "wb") as handle:
|
|
219
|
+
handle.write(xml_text.encode("utf-16"))
|
|
220
|
+
_check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
|
|
221
|
+
finally:
|
|
222
|
+
os.unlink(name)
|
|
223
|
+
if keep_off:
|
|
224
|
+
_check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
|
|
225
|
+
return
|
|
226
|
+
if existed:
|
|
227
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
228
|
+
_check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
|
|
232
|
+
"""End the running task, then delete it."""
|
|
233
|
+
if system != "win32":
|
|
234
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
235
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
236
|
+
_check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
240
|
+
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
241
|
+
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
242
|
+
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
|
|
243
|
+
|
|
244
|
+
``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
|
|
245
|
+
SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
|
|
246
|
+
instead of exiting into a respawn loop.
|
|
247
|
+
|
|
248
|
+
The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
|
|
249
|
+
not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
|
|
250
|
+
an argument shaped like a credential, and a control character in any string the plist carries."""
|
|
251
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
252
|
+
if not _LABEL.match(str(label)):
|
|
253
|
+
raise ServiceError("label must match %s" % _LABEL.pattern)
|
|
254
|
+
argv = [str(a) for a in program_arguments]
|
|
255
|
+
if not argv:
|
|
256
|
+
raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
|
|
257
|
+
if not exit_timeout > DRAIN_SECONDS:
|
|
258
|
+
raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
|
|
259
|
+
env = dict(environment or {})
|
|
260
|
+
for key, value in env.items():
|
|
261
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
|
|
262
|
+
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
263
|
+
if fabric_secrets.secret_value(str(value)):
|
|
264
|
+
raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
|
|
265
|
+
for arg in argv:
|
|
266
|
+
if fabric_secrets.secret_argument(arg):
|
|
267
|
+
raise ServiceError("An argument carries a credential; a plist never does (read it from the token file or a secret reference).")
|
|
268
|
+
for value in [str(label), str(working_directory), str(stdout_path)] + argv + [str(x) for pair in env.items() for x in pair]:
|
|
269
|
+
_plain(value, "a plist string")
|
|
270
|
+
env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
271
|
+
_no_marker_argument(argv)
|
|
272
|
+
env[SUPERVISOR_ENV] = "launchd"
|
|
273
|
+
plist = {
|
|
274
|
+
"Label": label,
|
|
275
|
+
"ProgramArguments": argv,
|
|
276
|
+
"WorkingDirectory": str(working_directory),
|
|
277
|
+
"RunAtLoad": True,
|
|
278
|
+
"KeepAlive": True,
|
|
279
|
+
"ThrottleInterval": 10,
|
|
280
|
+
"ExitTimeOut": exit_timeout,
|
|
281
|
+
# Standard, never Background: a host probes the service and agents call it while the Mac
|
|
282
|
+
# is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
|
|
283
|
+
# under load, and every host then reports an outage that never happened.
|
|
284
|
+
"ProcessType": "Standard",
|
|
285
|
+
"StandardOutPath": str(stdout_path),
|
|
286
|
+
"StandardErrorPath": str(stdout_path),
|
|
287
|
+
"EnvironmentVariables": env,
|
|
288
|
+
}
|
|
289
|
+
return plistlib.dumps(plist)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
293
|
+
return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _domain() -> str:
|
|
297
|
+
if sys.platform == "win32":
|
|
298
|
+
# DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
|
|
299
|
+
raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
|
|
300
|
+
return "gui/%d" % os.getuid()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def launchd_loaded(label: str) -> bool:
|
|
304
|
+
return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
308
|
+
try:
|
|
309
|
+
with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
|
|
310
|
+
return json.loads(response.read().decode())
|
|
311
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def launchd_override(label: str) -> Optional[str]:
|
|
319
|
+
"""The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
|
|
320
|
+
none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
|
|
321
|
+
|
|
322
|
+
Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
|
|
323
|
+
older releases ``=> true|false`` (true means disabled)."""
|
|
324
|
+
result = _launchctl("print-disabled", _domain())
|
|
325
|
+
if result.returncode != 0:
|
|
326
|
+
return "unknown"
|
|
327
|
+
for line in result.stdout.splitlines():
|
|
328
|
+
match = _OVERRIDE_ROW.match(line)
|
|
329
|
+
if match and match.group(1) == label:
|
|
330
|
+
return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
335
|
+
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
336
|
+
force_enable: bool = False) -> Dict[str, Any]:
|
|
337
|
+
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
338
|
+
poll the well-known document until it answers with this identity.
|
|
339
|
+
|
|
340
|
+
The operator's intent wins (LC-14): only a first install (no override recorded) enables the
|
|
341
|
+
label. A label the operator disabled stays disabled and unloaded — the plist is still
|
|
342
|
+
rewritten, so the new release is what starts once they turn it back on — and the answer is
|
|
343
|
+
``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
|
|
344
|
+
``force_enable`` is for an operator who asked for the service to be switched on."""
|
|
345
|
+
plist_path = Path(plist_path)
|
|
346
|
+
atomic_write(plist_path, plist_bytes, 0o644)
|
|
347
|
+
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
348
|
+
if lint.returncode != 0:
|
|
349
|
+
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
350
|
+
target = "%s/%s" % (_domain(), label)
|
|
351
|
+
override = launchd_override(label)
|
|
352
|
+
if force_enable or override is None:
|
|
353
|
+
_launchctl("enable", target)
|
|
354
|
+
elif override == "disabled":
|
|
355
|
+
return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
|
|
356
|
+
_launchctl("bootout", target)
|
|
357
|
+
deadline = time.time() + 15
|
|
358
|
+
while launchd_loaded(label) and time.time() < deadline:
|
|
359
|
+
time.sleep(0.25)
|
|
360
|
+
last = ""
|
|
361
|
+
for _ in range(5):
|
|
362
|
+
result = _launchctl("bootstrap", _domain(), str(plist_path))
|
|
363
|
+
if result.returncode == 0:
|
|
364
|
+
break
|
|
365
|
+
last = (result.stderr or result.stdout).strip()
|
|
366
|
+
time.sleep(1.0)
|
|
367
|
+
else:
|
|
368
|
+
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
369
|
+
deadline = time.time() + timeout
|
|
370
|
+
while time.time() < deadline:
|
|
371
|
+
doc = fetch_well_known(origin)
|
|
372
|
+
if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
|
|
373
|
+
return doc
|
|
374
|
+
time.sleep(0.5)
|
|
375
|
+
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
|
|
379
|
+
service_id: Optional[str] = None) -> Dict[str, Any]:
|
|
380
|
+
"""Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
|
|
381
|
+
the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
|
|
382
|
+
override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
|
|
383
|
+
unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
|
|
384
|
+
loaded at ``timeout`` raises, leaving the plist and data in place."""
|
|
385
|
+
if purge and not service_id:
|
|
386
|
+
raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
|
|
387
|
+
target = "%s/%s" % (_domain(), label)
|
|
388
|
+
_launchctl("bootout", target)
|
|
389
|
+
deadline = time.monotonic() + timeout
|
|
390
|
+
while launchd_loaded(label):
|
|
391
|
+
if time.monotonic() >= deadline:
|
|
392
|
+
raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
|
|
393
|
+
time.sleep(0.1)
|
|
394
|
+
try:
|
|
395
|
+
Path(plist_path).unlink()
|
|
396
|
+
plist_removed = True
|
|
397
|
+
except FileNotFoundError:
|
|
398
|
+
plist_removed = False
|
|
399
|
+
_launchctl("enable", target)
|
|
400
|
+
purged: List[str] = []
|
|
401
|
+
if purge:
|
|
402
|
+
for directory in service_dirs(str(service_id)).values():
|
|
403
|
+
if directory.is_symlink():
|
|
404
|
+
directory.unlink()
|
|
405
|
+
elif directory.exists():
|
|
406
|
+
shutil.rmtree(directory)
|
|
407
|
+
else:
|
|
408
|
+
continue
|
|
409
|
+
purged.append(str(directory))
|
|
410
|
+
return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
|
|
414
|
+
"""Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
|
|
415
|
+
older release directories, newest first by modification time. ``current`` (a release or a
|
|
416
|
+
symlink to one) is never removed, even after a rollback to an older release. Symlinks and
|
|
417
|
+
files in ``releases_dir`` are left alone. Returns what was removed."""
|
|
418
|
+
if keep < 2:
|
|
419
|
+
raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
|
|
420
|
+
root = Path(releases_dir)
|
|
421
|
+
if not root.is_dir():
|
|
422
|
+
return []
|
|
423
|
+
releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
|
|
424
|
+
releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
425
|
+
keepers: List[Path] = []
|
|
426
|
+
if current is not None:
|
|
427
|
+
resolved = Path(current).resolve()
|
|
428
|
+
keepers.extend(p for p in releases if p.resolve() == resolved)
|
|
429
|
+
for release in releases:
|
|
430
|
+
if len(keepers) >= keep:
|
|
431
|
+
break
|
|
432
|
+
if release not in keepers:
|
|
433
|
+
keepers.append(release)
|
|
434
|
+
removed = [p for p in releases if p not in keepers]
|
|
435
|
+
for release in removed:
|
|
436
|
+
shutil.rmtree(release)
|
|
437
|
+
return removed
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Dates and times the way the contract reads them, once for the whole kit (FAA-65, layer L0).
|
|
2
|
+
|
|
3
|
+
- ``parse_rfc3339_ms(text)``: strict RFC 3339 (a ``T``, an offset, a day that exists; no space, no leap second) as
|
|
4
|
+
milliseconds since the epoch, or NaN. ``estate_time`` in older code is the same function.
|
|
5
|
+
- ``clock_ms(at)``: a verifier's clock (a timezone-aware ``datetime`` or epoch seconds) as milliseconds, or NaN.
|
|
6
|
+
- ``utc_date_ms(text)``: a ``YYYY-MM-DD`` that exists as midnight UTC in milliseconds, or NaN.
|
|
7
|
+
- ``utc_day(ms)``, ``format_rfc3339(ms)``, ``day_window(today_ms, days)``, ``now_ms(clock)``: the helpers a service
|
|
8
|
+
needs to write dates and to walk the last N UTC days.
|
|
9
|
+
|
|
10
|
+
NaN, never an exception, is the answer for what cannot be read: callers compare it and get a finding.
|
|
11
|
+
Standard library only; imports only ``fabric_jsvalue``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Callable, List, Optional
|
|
19
|
+
import sys as _sys # noqa: E402
|
|
20
|
+
from pathlib import Path as _Path # noqa: E402
|
|
21
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
22
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
23
|
+
from fabric_jsvalue import is_number
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ASCII digits only: Python's \d also matches other scripts' digits, which Date.parse never reads.
|
|
27
|
+
_RFC3339 = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))", re.ASCII)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def days_from_civil(year: int, month: int, day: int) -> int:
|
|
31
|
+
year -= month <= 2
|
|
32
|
+
era = (year if year >= 0 else year - 399) // 400
|
|
33
|
+
yoe = year - era * 400
|
|
34
|
+
doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
35
|
+
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
36
|
+
return era * 146097 + doe - 719468
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_rfc3339_ms(value: Any) -> float:
|
|
40
|
+
"""An RFC 3339 date-time as milliseconds since the epoch, or NaN: `T`, an offset and a day that exists are required;
|
|
41
|
+
a space and a leap second `:60` are refused (ER-10). Fractions count to the millisecond, as Date.parse reads them."""
|
|
42
|
+
match = _RFC3339.fullmatch(value) if isinstance(value, str) else None
|
|
43
|
+
if not match:
|
|
44
|
+
return math.nan
|
|
45
|
+
year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
|
|
46
|
+
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
47
|
+
days_in = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
48
|
+
if not (1 <= month <= 12 and 1 <= day <= days_in[month - 1]) or hour > 23 or minute > 59 or second > 59:
|
|
49
|
+
return math.nan
|
|
50
|
+
if match.group(9):
|
|
51
|
+
oh, om = int(match.group(10)), int(match.group(11))
|
|
52
|
+
if oh > 23 or om > 59:
|
|
53
|
+
return math.nan
|
|
54
|
+
offset = (oh * 60 + om) * (1 if match.group(9) == "+" else -1)
|
|
55
|
+
else:
|
|
56
|
+
offset = 0
|
|
57
|
+
millis = int((match.group(7) or ".")[1:4].ljust(3, "0"))
|
|
58
|
+
seconds = days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset * 60
|
|
59
|
+
return float(seconds * 1000 + millis)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clock_ms(at: Any) -> float:
|
|
63
|
+
"""The verifier's clock as milliseconds: a timezone-aware datetime or epoch seconds; NaN for anything else."""
|
|
64
|
+
if isinstance(at, _dt.datetime) and at.tzinfo is not None:
|
|
65
|
+
return at.timestamp() * 1000.0
|
|
66
|
+
if is_number(at):
|
|
67
|
+
try:
|
|
68
|
+
seconds = float(at)
|
|
69
|
+
except OverflowError:
|
|
70
|
+
return math.nan
|
|
71
|
+
return seconds * 1000.0 if math.isfinite(seconds) else math.nan
|
|
72
|
+
return math.nan
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DAY_MS = 86_400_000
|
|
76
|
+
_UTC_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", re.ASCII)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def utc_date_ms(value: Any) -> float:
|
|
80
|
+
"""A ``YYYY-MM-DD`` date that exists, as midnight UTC in milliseconds; NaN for anything else (the contract's
|
|
81
|
+
``utcDate``)."""
|
|
82
|
+
match = _UTC_DATE.fullmatch(value) if isinstance(value, str) else None
|
|
83
|
+
if not match:
|
|
84
|
+
return math.nan
|
|
85
|
+
try:
|
|
86
|
+
day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
87
|
+
except ValueError:
|
|
88
|
+
return math.nan
|
|
89
|
+
return float((day - _dt.date(1970, 1, 1)).days * DAY_MS)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def utc_day(ms: float) -> str:
|
|
93
|
+
"""The UTC calendar day (``YYYY-MM-DD``) of an instant in milliseconds. Raises ValueError for NaN or Infinity."""
|
|
94
|
+
if not math.isfinite(ms):
|
|
95
|
+
raise ValueError("not a finite instant")
|
|
96
|
+
return (_dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=ms)).strftime("%Y-%m-%d")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_rfc3339(ms: float) -> str:
|
|
100
|
+
"""An instant as RFC 3339 in UTC with milliseconds (``2026-10-21T09:00:00.000Z``), which ``parse_rfc3339_ms``
|
|
101
|
+
reads back to the same value. Raises ValueError for NaN or Infinity."""
|
|
102
|
+
if not math.isfinite(ms):
|
|
103
|
+
raise ValueError("not a finite instant")
|
|
104
|
+
moment = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=int(ms))
|
|
105
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (moment.microsecond // 1000)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def day_window(today_ms: float, days: int) -> List[str]:
|
|
109
|
+
"""The last ``days`` UTC days ending with the day of ``today_ms``, oldest first (a usage report's window is 31)."""
|
|
110
|
+
if days < 1:
|
|
111
|
+
return []
|
|
112
|
+
start = (today_ms // DAY_MS) * DAY_MS - (days - 1) * DAY_MS
|
|
113
|
+
return [utc_day(start + i * DAY_MS) for i in range(days)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def now_ms(clock: Optional[Callable[[], float]] = None) -> float:
|
|
117
|
+
"""Milliseconds since the epoch: from ``clock`` (seconds, as ``time.time``) when given, so a test fixes the time."""
|
|
118
|
+
import time
|
|
119
|
+
return float((clock or time.time)()) * 1000.0
|
|
120
|
+
|