@topy-ai/maggie 0.7.45 → 0.7.47
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-zh-TW.md +15 -2
- package/README.md +55 -3
- package/bin/maggie.js +22 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
- package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
- package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
- package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
- package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
- package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
- package/bundled-references/browser-inspection.md +17 -0
- package/bundled-references/google-integrations-runbook.md +8 -1
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-skills/maggie-clone/SKILL.md +7 -0
- package/bundled-skills/maggie-deployment/SKILL.md +55 -15
- package/bundled-skills/maggie-feedback/SKILL.md +26 -1
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-ops/SKILL.md +22 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +4 -1
- package/bundled-tools/clis/maggie_analytics.py +8 -0
- package/bundled-tools/clis/maggie_browser_audit.py +13 -2
- package/bundled-tools/clis/maggie_deployment.py +214 -6
- package/bundled-tools/clis/maggie_feedback.py +116 -1
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_ops.py +33 -0
- package/bundled-tools/clis/maggie_release.py +124 -19
- package/bundled-tools/runtime/browser_capability.py +143 -0
- package/bundled-tools/runtime/external_write.py +122 -0
- package/bundled-tools/runtime/google_capabilities.py +37 -5
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +17 -0
- package/references/google-integrations-runbook.md +8 -1
- package/references/memory-hook.md +18 -5
|
@@ -219,6 +219,12 @@ files and the application remain separated (`maggie-deploy` versus
|
|
|
219
219
|
Install and review that narrow sudoers policy on the host before executing a
|
|
220
220
|
runner.
|
|
221
221
|
|
|
222
|
+
Generated release runners include their Maggie version and a template
|
|
223
|
+
fingerprint. VPS preflight checks runners under `.maggie/deployment/` and warns
|
|
224
|
+
when they are unmarked or stale; use `--check-runner PATH` for a runner stored
|
|
225
|
+
elsewhere. Regenerate from the reviewed VPS plan, inspect the diff, and replace
|
|
226
|
+
the checked-in runner only after review.
|
|
227
|
+
|
|
222
228
|
VPS plans include a bounded retention policy: keep two immutable releases,
|
|
223
229
|
preserve the `current` target and rollback target, and review prune candidates
|
|
224
230
|
before any operator executes cleanup. Generate a read-only candidate report:
|
|
@@ -301,28 +307,41 @@ output/state artifact and must never be used as the provider source argument.
|
|
|
301
307
|
When validating a project schedule, pass `--project` so the URL is compared
|
|
302
308
|
exactly with `.maggie/booking/services.json` `sourceUrl`.
|
|
303
309
|
|
|
304
|
-
For a single read-only staging release gate,
|
|
305
|
-
|
|
306
|
-
|
|
310
|
+
For a single read-only staging release gate, first declare the project's release
|
|
311
|
+
surface in `.maggie/release-profile.json`. The manifest is the source of truth;
|
|
312
|
+
Maggie does not infer Booking, MaggieDash, migration, analytics, service
|
|
313
|
+
catalogue, or editorial gates from incidental files. A minimal public Astro
|
|
314
|
+
site can use:
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"schemaVersion": "maggie-release-profile.v1",
|
|
319
|
+
"profile": "public-astro",
|
|
320
|
+
"projectType": "astro",
|
|
321
|
+
"capabilities": ["deployment"]
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Add `migration`, `maggiedash-health`, `schedule`, `analytics-contract`,
|
|
326
|
+
`service-facts`, `editorial-review`, or `durable-site-evidence` only when that
|
|
327
|
+
project actually owns the corresponding artifact. For a single read-only
|
|
328
|
+
staging release gate, run:
|
|
307
329
|
|
|
308
330
|
```bash
|
|
309
331
|
maggie tool maggie_release.py /path/to/project \
|
|
332
|
+
--profile /path/to/project/.maggie/release-profile.json \
|
|
310
333
|
--environment staging --target vps-with-cloudflare-dns \
|
|
311
334
|
--base-url https://staging.example.com
|
|
312
335
|
```
|
|
313
336
|
|
|
314
|
-
If
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
service-fact, editorial approval, durable SEO/route/category evidence,
|
|
323
|
-
MaggieDash schema compatibility, and live runtime security-header checks. It writes
|
|
324
|
-
`.maggie/release-preflight.json`; a failed gate blocks release. It never
|
|
325
|
-
deploys, migrates, publishes, or sends provider requests.
|
|
337
|
+
If `scenario-qa` is declared, the preflight consumes the latest matching
|
|
338
|
+
`maggie qa` run for the requested environment (and `--base-url`, when
|
|
339
|
+
supplied). It blocks when a scenario is not passed, has no browser-adapter
|
|
340
|
+
evidence, or the matching run is missing. The report records every undeclared
|
|
341
|
+
optional gate as `skipped`, so a public site is not blocked by Booking- or
|
|
342
|
+
MaggieDash-only evidence. It writes `.maggie/release-preflight.json`; a failed
|
|
343
|
+
gate blocks release. It never deploys, migrates, publishes, or sends provider
|
|
344
|
+
requests.
|
|
326
345
|
|
|
327
346
|
Generate a repeatable VPS runner as part of the plan. A deploy step that exists
|
|
328
347
|
only in an operator's shell history is not a release contract:
|
|
@@ -338,6 +357,27 @@ Review the generated runner before execution. Its order is install → typecheck
|
|
|
338
357
|
→ migrate → build → carry agent state → switch → restart → verify → prune;
|
|
339
358
|
retention keeps the current release and one rollback candidate.
|
|
340
359
|
|
|
360
|
+
Before writing a VPS plan or running the release gate, validate the real
|
|
361
|
+
least-privilege deploy account. This check is read-only: it verifies SSH access,
|
|
362
|
+
write permission for both the release root and `releases/`, and the exact
|
|
363
|
+
non-interactive systemd allowlist. A failed check does not create the plan or
|
|
364
|
+
runner:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
maggie deployment --verify-deployer \
|
|
368
|
+
--deployer-host "$DEPLOY_SERVER_IP" \
|
|
369
|
+
--deployer-user "$DEPLOY_SERVER_SSH_USER" \
|
|
370
|
+
--release-root /var/www/example \
|
|
371
|
+
--service example \
|
|
372
|
+
--output .maggie/deployment/deployer-delegation.json
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
For a VPS `maggie release`, this evidence is required at
|
|
376
|
+
`.maggie/deployment/deployer-delegation.json` (or pass
|
|
377
|
+
`--deployer-evidence`). The generated runner waits for bounded transient
|
|
378
|
+
`000/502/503/504` startup responses after restart, while returning a clear
|
|
379
|
+
failure immediately for permanent HTTP responses.
|
|
380
|
+
|
|
341
381
|
Production additionally requires `docs/deployment-rollback-smoke.json` with
|
|
342
382
|
`passed: true`, `environment: production`, and `testedAt`. For an upgrade it
|
|
343
383
|
must include a non-empty `previousRelease`. For the first production release,
|
|
@@ -30,10 +30,29 @@ maggie feedback --project . collect \
|
|
|
30
30
|
maggie feedback preview .maggie/feedback/<feedback-id>.json --format markdown
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The draft is written to `.maggie/feedback/`.
|
|
33
|
+
The draft is written to `.maggie/feedback/`. List local drafts with
|
|
34
|
+
`maggie feedback list --project .`; `--project` works before or after the
|
|
35
|
+
subcommand. A draft contains the Maggie version,
|
|
34
36
|
skill, run ID, phase, error fingerprint, expected/actual result, reproduction
|
|
35
37
|
steps, resolution, validation, and metadata-only screenshot references.
|
|
36
38
|
|
|
39
|
+
Attach bounded, privacy-safe evidence and relationships when a maintainer will
|
|
40
|
+
need to connect a report to a test, deployment, issue, or related run. Only
|
|
41
|
+
identifiers are stored; file contents, logs, credentials, and local paths are
|
|
42
|
+
never copied:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
maggie feedback collect --project . --summary "Adapter unavailable" \
|
|
46
|
+
--evidence-ref test:browser-capability:passed \
|
|
47
|
+
--evidence-ref report:browser-capability:v1 \
|
|
48
|
+
--relationship same-run=clone-001
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Evidence references are bounded and normalized into the
|
|
52
|
+
`maggie-feedback-evidence-bundle.v1` contract. The accepted kinds are
|
|
53
|
+
`test`, `report`, `screenshot`, `command`, `deployment`, `issue`, `commit`,
|
|
54
|
+
and `artifact`; relationships connect safe IDs such as feedback or run IDs.
|
|
55
|
+
|
|
37
56
|
If a draft is marked fixed with `--fixed` (or the supplied run report says
|
|
38
57
|
`fixed`), both `--resolution` and `--validation` are required. Feedback text
|
|
39
58
|
also scrubs common POSIX/home/temp and Windows absolute paths by default while
|
|
@@ -78,6 +97,12 @@ This writes `.maggie/feedback/batches/<batch-id>-review.json` under the
|
|
|
78
97
|
duplicate fingerprints/summaries, while retaining only redacted observations;
|
|
79
98
|
review the aggregate and each draft before submitting.
|
|
80
99
|
|
|
100
|
+
Batch review also emits a bounded `evidenceGraph` and
|
|
101
|
+
`remediationSuggestions`. The graph links feedback IDs to evidence IDs and
|
|
102
|
+
safe related references, while suggestions flag duplicate fingerprints,
|
|
103
|
+
missing batch indexes, shared evidence, or a batch with no validation
|
|
104
|
+
references. These are review aids, not automatic issue or memory writes.
|
|
105
|
+
|
|
81
106
|
Example:
|
|
82
107
|
|
|
83
108
|
{
|
|
@@ -29,7 +29,7 @@ truth, content state, or audit log.
|
|
|
29
29
|
|
|
30
30
|
## Workflow
|
|
31
31
|
|
|
32
|
-
1. At the start of a relevant workflow, run `maggie memory context --project . --skill <skill>` and load only matching active items.
|
|
32
|
+
1. At the start of a relevant workflow, run `maggie memory context --project . --skill <skill>` and load only matching active project-scope items. Use `--include-shared` only after explicit opt-in.
|
|
33
33
|
2. Show material preferences, conventions, and prevention lessons before making a decision.
|
|
34
34
|
3. When the user gives a durable preference or confirms a repaired pitfall, save it explicitly as `candidate` or `active` with scope and evidence.
|
|
35
35
|
4. Record every meaningful failure with `record-error`; include a stable fingerprint, resolution, and validation result when fixed.
|
|
@@ -45,6 +45,15 @@ truth, content state, or audit log.
|
|
|
45
45
|
preserving project scope. Expired active items remain inspectable through
|
|
46
46
|
explicit status listing but are excluded from normal context.
|
|
47
47
|
|
|
48
|
+
Project-scope `maggie init` creates the repository's `.maggie/memory/` store.
|
|
49
|
+
Repeated initialization preserves existing memory files without rewriting
|
|
50
|
+
their contents or timestamps. A context response distinguishes a ready store
|
|
51
|
+
from an uninitialized project; the shared memory hook falls back to the
|
|
52
|
+
installed CLI when local Python tools are absent.
|
|
53
|
+
|
|
54
|
+
Project `context`, `search`, and `list` read only project-scope items by
|
|
55
|
+
default. `--include-shared` explicitly opts into user/workspace-scoped entries.
|
|
56
|
+
|
|
48
57
|
```bash
|
|
49
58
|
maggie memory init --project .
|
|
50
59
|
maggie memory context --project . --skill maggie-clone
|
|
@@ -88,6 +88,14 @@ maggie ops google-capabilities --project . \
|
|
|
88
88
|
--report .maggie/google-capability-input.json
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
The input must use `maggie-google-capability-report.v2`: each row records the
|
|
92
|
+
redacted active account, the selected target (`property`, `container`,
|
|
93
|
+
`customer`, `project`, or `site`), and the exact scopes. Both account and target
|
|
94
|
+
must be explicitly verified, and `target.id` must equal `resource`. The command
|
|
95
|
+
prints these safe identifiers and scopes before any separate provider adapter
|
|
96
|
+
is allowed to perform a write. A v1 report is rejected because it cannot prove
|
|
97
|
+
that the browser/session is on the intended Google account and property.
|
|
98
|
+
|
|
91
99
|
The result separates `read`, `report`, `edit`, and `publish` for each
|
|
92
100
|
provider/resource. Write and publish remain `not_tested` until the operator
|
|
93
101
|
explicitly confirms the exact mutation and a successful read-back is captured.
|
|
@@ -164,6 +172,20 @@ Every mutation must validate the session, role, resource ownership/project
|
|
|
164
172
|
scope, input schema, legal state transition, and idempotency/correlation key.
|
|
165
173
|
Record actor, timestamp, previous state, next state, reason, and result.
|
|
166
174
|
|
|
175
|
+
For a GA4/GTM or other Google provider write, validate a hash-only
|
|
176
|
+
write/readback bundle before treating the operation as complete:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
maggie ops external-write-gate --project . \
|
|
180
|
+
--evidence .maggie/external-write-readback-input.json \
|
|
181
|
+
--output .maggie/external-write-readback.json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The bundle requires one stable idempotency key across bounded retries, explicit
|
|
185
|
+
confirmation, successful readback fingerprints, an empty duplicate list, and
|
|
186
|
+
an empty orphaned-workspace list. It never stores provider payloads; an
|
|
187
|
+
incomplete or mismatched readback blocks the release gate.
|
|
188
|
+
|
|
167
189
|
### Operate
|
|
168
190
|
|
|
169
191
|
Use the dashboard or API to perform an explicitly requested operation. Show an
|
|
@@ -125,7 +125,10 @@ maggie analytics release-gate --project . --environment staging \
|
|
|
125
125
|
|
|
126
126
|
Use `contracts/maggie-seo/gsc-readiness-v1.schema.json` as the evidence
|
|
127
127
|
boundary. Credentials and mutation scopes remain host-owned; a passing local
|
|
128
|
-
contract is not proof of property ownership or live readback.
|
|
128
|
+
contract is not proof of property ownership or live readback. If a GA4/GTM
|
|
129
|
+
write is part of the workflow, also pass hash-only
|
|
130
|
+
`.maggie/external-write-readback.json` evidence to the analytics release gate;
|
|
131
|
+
duplicate or orphaned provider objects fail closed.
|
|
129
132
|
|
|
130
133
|
## Freeze and compare a reviewed site
|
|
131
134
|
|
|
@@ -13,6 +13,7 @@ from urllib.parse import urlsplit
|
|
|
13
13
|
import sys
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
15
15
|
from analytics_traffic import audit_events # noqa: E402
|
|
16
|
+
from external_write import validate as validate_external_write # noqa: E402
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
|
|
@@ -122,6 +123,12 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
122
123
|
errors.append("gsc-evidence-schema")
|
|
123
124
|
elif args.require_gsc:
|
|
124
125
|
errors.append("gsc-evidence-required")
|
|
126
|
+
if args.write_readback:
|
|
127
|
+
write_readback, write_errors = load_json(Path(args.write_readback).resolve(), "external write readback")
|
|
128
|
+
errors.extend(write_errors)
|
|
129
|
+
write_result = validate_external_write(write_readback or {})
|
|
130
|
+
check("external-write-readback", write_result["passed"], "idempotency, readback, duplicate, and cleanup evidence", checks)
|
|
131
|
+
errors.extend(write_result["errors"])
|
|
125
132
|
if browser:
|
|
126
133
|
check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
|
|
127
134
|
check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
|
|
@@ -191,6 +198,7 @@ def main() -> int:
|
|
|
191
198
|
parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
|
|
192
199
|
parser.add_argument("--gsc-evidence", help="versioned GSC readiness evidence for release-gate")
|
|
193
200
|
parser.add_argument("--require-gsc", action="store_true", help="require GSC readiness evidence in release-gate")
|
|
201
|
+
parser.add_argument("--write-readback", help="hash-only external write/readback evidence for release-gate")
|
|
194
202
|
parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
|
|
195
203
|
args = parser.parse_args()
|
|
196
204
|
if args.command == "gsc-readiness":
|
|
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
|
|
|
10
10
|
RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
|
|
11
11
|
sys.path.insert(0, str(RUNTIME))
|
|
12
12
|
from browser_behavior import validate_samples
|
|
13
|
+
from browser_capability import discover_browser_adapter, require_browser_adapter
|
|
13
14
|
from localization_runner import checkpoint
|
|
14
15
|
|
|
15
16
|
|
|
@@ -108,10 +109,17 @@ def run_interactions(call, steps: list[dict]) -> list[dict]:
|
|
|
108
109
|
def audit(args):
|
|
109
110
|
if urlparse(args.url).scheme not in {"http", "https", "file"}:
|
|
110
111
|
raise ValueError("URL must use http, https or file")
|
|
111
|
-
if not args.required:
|
|
112
|
+
if not args.required and not args.check_browser:
|
|
112
113
|
raise ValueError("at least one --required selector is necessary")
|
|
113
114
|
output = args.output.resolve()
|
|
114
115
|
output.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
capability = discover_browser_adapter(args.browse)
|
|
117
|
+
capability_path = args.capability_report.resolve() if args.capability_report else output / "browser-capability.json"
|
|
118
|
+
checkpoint(capability_path, capability)
|
|
119
|
+
if args.check_browser:
|
|
120
|
+
print(json.dumps(capability, indent=2))
|
|
121
|
+
return 0 if capability["status"] == "ready" else 1
|
|
122
|
+
require_browser_adapter(args.browse)
|
|
115
123
|
interactions = load_interactions(args.interactions)
|
|
116
124
|
def call(*command):
|
|
117
125
|
result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
|
|
@@ -120,7 +128,8 @@ def audit(args):
|
|
|
120
128
|
return result.stdout
|
|
121
129
|
report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
|
|
122
130
|
"passed": False, "viewports": [], "evidence": "browser-captured",
|
|
123
|
-
"interactionManifest": str(args.interactions.resolve()) if args.interactions else None
|
|
131
|
+
"interactionManifest": str(args.interactions.resolve()) if args.interactions else None,
|
|
132
|
+
"browserCapability": str(capability_path)}
|
|
124
133
|
try:
|
|
125
134
|
for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
|
|
126
135
|
width, height = [int(value) for value in viewport.split("x")]
|
|
@@ -158,6 +167,8 @@ def main():
|
|
|
158
167
|
parser.add_argument("url")
|
|
159
168
|
parser.add_argument("--browse", type=Path, required=True)
|
|
160
169
|
parser.add_argument("--output", type=Path, required=True, help="new directory for evidence")
|
|
170
|
+
parser.add_argument("--capability-report", type=Path, help="write the redacted browser adapter capability report here")
|
|
171
|
+
parser.add_argument("--check-browser", action="store_true", help="only check adapter capability; do not navigate or write page evidence")
|
|
161
172
|
parser.add_argument("--viewport", action="append")
|
|
162
173
|
parser.add_argument("--required", action="append", default=[])
|
|
163
174
|
parser.add_argument("--sticky", action="append", default=[])
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import inspect
|
|
6
8
|
import json
|
|
9
|
+
import os
|
|
7
10
|
import re
|
|
11
|
+
import shlex
|
|
8
12
|
import subprocess
|
|
9
13
|
import sys
|
|
10
14
|
from datetime import datetime, timezone
|
|
@@ -181,7 +185,103 @@ def worker_secret_preflight_main(argv: list[str]) -> int:
|
|
|
181
185
|
return 0 if result["passed"] else 1
|
|
182
186
|
|
|
183
187
|
|
|
184
|
-
def
|
|
188
|
+
def deployer_delegation_preflight(
|
|
189
|
+
host: str,
|
|
190
|
+
user: str,
|
|
191
|
+
release_root: str,
|
|
192
|
+
service: str,
|
|
193
|
+
ssh_command: list[str] | None = None,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Read-only SSH check for release-directory and systemd delegation."""
|
|
196
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,253}", host):
|
|
197
|
+
raise ValueError("deployer host must be a hostname, address, or IPv6-safe token")
|
|
198
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", user):
|
|
199
|
+
raise ValueError("deployer user must be a safe local account name")
|
|
200
|
+
if not release_root.startswith("/") or "\n" in release_root or "\r" in release_root:
|
|
201
|
+
raise ValueError("release root must be an absolute path without newlines")
|
|
202
|
+
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
203
|
+
raise ValueError("service must contain only safe systemd name characters")
|
|
204
|
+
|
|
205
|
+
root = release_root.rstrip("/") or "/"
|
|
206
|
+
releases = f"{root}/releases" if root != "/" else "/releases"
|
|
207
|
+
remote_script = "\n".join((
|
|
208
|
+
"set -eu",
|
|
209
|
+
f"test -d {shlex.quote(root)} && test -w {shlex.quote(root)}",
|
|
210
|
+
f"test -d {shlex.quote(releases)} && test -w {shlex.quote(releases)}",
|
|
211
|
+
f"sudo -n -l /bin/systemctl restart {shlex.quote(service)} >/dev/null 2>&1",
|
|
212
|
+
f"sudo -n -l /bin/systemctl is-active {shlex.quote(service)} >/dev/null 2>&1",
|
|
213
|
+
)) + "\n"
|
|
214
|
+
command = list(ssh_command or ["ssh"])
|
|
215
|
+
command.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", f"{user}@{host}", "sh", "-s"])
|
|
216
|
+
try:
|
|
217
|
+
completed = subprocess.run(command, input=remote_script, capture_output=True, text=True, check=False, timeout=20)
|
|
218
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
219
|
+
return {
|
|
220
|
+
"schemaVersion": "maggie-deployment-delegation.v1",
|
|
221
|
+
"host": host,
|
|
222
|
+
"user": user,
|
|
223
|
+
"releaseRoot": root,
|
|
224
|
+
"service": service,
|
|
225
|
+
"state": "transient",
|
|
226
|
+
"passed": False,
|
|
227
|
+
"checks": {},
|
|
228
|
+
"errors": [f"SSH delegation check could not start: {type(error).__name__}"],
|
|
229
|
+
"mutation": "not executed",
|
|
230
|
+
}
|
|
231
|
+
stderr = (completed.stderr or "").lower()
|
|
232
|
+
transient = completed.returncode in {124, 255} or any(marker in stderr for marker in (
|
|
233
|
+
"timed out", "connection refused", "connection reset", "no route", "could not resolve", "temporary failure",
|
|
234
|
+
))
|
|
235
|
+
state = "passed" if completed.returncode == 0 else "transient" if transient else "failed"
|
|
236
|
+
return {
|
|
237
|
+
"schemaVersion": "maggie-deployment-delegation.v1",
|
|
238
|
+
"host": host,
|
|
239
|
+
"user": user,
|
|
240
|
+
"releaseRoot": root,
|
|
241
|
+
"service": service,
|
|
242
|
+
"state": state,
|
|
243
|
+
"passed": completed.returncode == 0,
|
|
244
|
+
"checks": {
|
|
245
|
+
"sshConnection": completed.returncode == 0,
|
|
246
|
+
"releaseRootWritable": completed.returncode == 0,
|
|
247
|
+
"releasesDirectoryWritable": completed.returncode == 0,
|
|
248
|
+
"sudoSystemctlRestart": completed.returncode == 0,
|
|
249
|
+
"sudoSystemctlStatus": completed.returncode == 0,
|
|
250
|
+
},
|
|
251
|
+
"errors": [] if completed.returncode == 0 else [
|
|
252
|
+
"deploy-user delegation check is transient; retry after SSH/host readiness" if transient
|
|
253
|
+
else "deploy-user cannot write the release directory or use the exact systemd allowlist"
|
|
254
|
+
],
|
|
255
|
+
"mutation": "not executed",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validate_deployer_delegation(value: object, expected_root: str | None = None, expected_service: str | None = None) -> dict:
|
|
260
|
+
if not isinstance(value, dict):
|
|
261
|
+
return {"passed": False, "state": "failed", "errors": ["delegation evidence must be an object"]}
|
|
262
|
+
errors: list[str] = []
|
|
263
|
+
if value.get("schemaVersion") != "maggie-deployment-delegation.v1":
|
|
264
|
+
errors.append("delegation evidence schemaVersion is unsupported")
|
|
265
|
+
if value.get("state") != "passed" or value.get("passed") is not True:
|
|
266
|
+
errors.append("deploy-user delegation evidence is not passed")
|
|
267
|
+
if expected_root and value.get("releaseRoot") != expected_root.rstrip("/"):
|
|
268
|
+
errors.append("delegation releaseRoot does not match the deployment plan")
|
|
269
|
+
if expected_service and value.get("service") != expected_service:
|
|
270
|
+
errors.append("delegation service does not match the deployment plan")
|
|
271
|
+
checks = value.get("checks") if isinstance(value.get("checks"), dict) else {}
|
|
272
|
+
for name in ("sshConnection", "releaseRootWritable", "releasesDirectoryWritable", "sudoSystemctlRestart", "sudoSystemctlStatus"):
|
|
273
|
+
if checks.get(name) is not True:
|
|
274
|
+
errors.append(f"delegation check is not passed: {name}")
|
|
275
|
+
return {"passed": not errors, "state": "passed" if not errors else "failed", "errors": errors}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def preflight(
|
|
279
|
+
project: Path,
|
|
280
|
+
target: str,
|
|
281
|
+
environment: str,
|
|
282
|
+
require_deployer: bool = False,
|
|
283
|
+
delegation_evidence: Path | None = None,
|
|
284
|
+
) -> dict:
|
|
185
285
|
package_path = project / "package.json"
|
|
186
286
|
package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
|
|
187
287
|
scripts = package.get("scripts") or {}
|
|
@@ -240,10 +340,27 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
240
340
|
data_required = bool(migration.get("dataDependencies"))
|
|
241
341
|
data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
|
|
242
342
|
retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
|
|
243
|
-
|
|
343
|
+
delegation = {"passed": True, "state": "not-required", "errors": []}
|
|
344
|
+
delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
|
|
345
|
+
if require_deployer:
|
|
346
|
+
try:
|
|
347
|
+
delegation_value = json.loads(delegation_path.read_text(encoding="utf-8"))
|
|
348
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
349
|
+
delegation_value = {}
|
|
350
|
+
delegation = {"passed": False, "state": "inconclusive", "errors": [f"delegation evidence is unavailable: {error}"]}
|
|
351
|
+
else:
|
|
352
|
+
delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
|
|
353
|
+
delegation["path"] = str(delegation_path)
|
|
354
|
+
result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
|
|
355
|
+
result["delegation"] = delegation
|
|
244
356
|
result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
|
|
245
357
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
358
|
+
runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
|
|
359
|
+
result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
|
|
360
|
+
result["warnings"] = [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
246
361
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
362
|
+
if require_deployer:
|
|
363
|
+
result["blocking_checks"].append("deployer_delegation")
|
|
247
364
|
else:
|
|
248
365
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
249
366
|
return result
|
|
@@ -438,15 +555,51 @@ WantedBy=multi-user.target
|
|
|
438
555
|
(output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
|
|
439
556
|
|
|
440
557
|
|
|
558
|
+
def release_runner_generator() -> dict[str, str]:
|
|
559
|
+
version = os.environ.get("MAGGIE_VERSION", "").strip()
|
|
560
|
+
if not version:
|
|
561
|
+
version_file = Path(__file__).resolve().parents[2] / "VERSION"
|
|
562
|
+
try:
|
|
563
|
+
version = version_file.read_text(encoding="utf-8").strip()
|
|
564
|
+
except OSError:
|
|
565
|
+
version = "unknown"
|
|
566
|
+
source = inspect.getsource(release_runner).encode("utf-8")
|
|
567
|
+
return {"version": version, "fingerprint": hashlib.sha256(source).hexdigest()[:16]}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def release_runner_status(path: Path) -> dict:
|
|
571
|
+
try:
|
|
572
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
573
|
+
except OSError as error:
|
|
574
|
+
return {"path": str(path), "status": "stale", "reason": f"runner cannot be read: {error}", "refresh": "Regenerate from the reviewed VPS plan and inspect the diff."}
|
|
575
|
+
match = re.search(r"^# Maggie release runner: generator=([^ ]+) template=([a-f0-9]{16})$", content, re.MULTILINE)
|
|
576
|
+
current = release_runner_generator()
|
|
577
|
+
if not match:
|
|
578
|
+
return {"path": str(path), "status": "stale", "reason": "runner has no generator version/fingerprint", "currentGenerator": current, "refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file."}
|
|
579
|
+
generated = {"version": match.group(1), "fingerprint": match.group(2)}
|
|
580
|
+
stale = generated != current
|
|
581
|
+
return {
|
|
582
|
+
"path": str(path),
|
|
583
|
+
"status": "stale" if stale else "current",
|
|
584
|
+
"generatedBy": generated,
|
|
585
|
+
"currentGenerator": current,
|
|
586
|
+
"reason": "runner generator version or template fingerprint has changed" if stale else "runner matches the current generator",
|
|
587
|
+
"refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file." if stale else "",
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
441
591
|
def release_runner(plan: dict) -> str:
|
|
442
592
|
"""Render the ordered, reviewable VPS runner for one release."""
|
|
443
593
|
root = plan["release_root"]
|
|
444
594
|
service = plan["service"]
|
|
445
595
|
domain = plan["domain"]
|
|
446
596
|
current = plan["current_release"]
|
|
597
|
+
generator = release_runner_generator()
|
|
447
598
|
return f'''#!/usr/bin/env bash
|
|
448
599
|
set -euo pipefail
|
|
449
600
|
|
|
601
|
+
# Maggie release runner: generator={generator["version"]} template={generator["fingerprint"]}
|
|
602
|
+
|
|
450
603
|
# Migration precedes the symlink flip; later releases carry agent state.
|
|
451
604
|
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
452
605
|
RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
|
|
@@ -471,9 +624,38 @@ if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude";
|
|
|
471
624
|
|
|
472
625
|
ln -sfn "$RELEASE" "$CURRENT"
|
|
473
626
|
sudo -n /bin/systemctl restart "{service}"
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
627
|
+
|
|
628
|
+
# A restarted service can be healthy at the origin before Nginx/Cloudflare is
|
|
629
|
+
# ready. Retry only bounded transient states; fail immediately on permanent
|
|
630
|
+
# HTTP responses so an outage is not hidden as a startup delay.
|
|
631
|
+
wait_for_active() {{
|
|
632
|
+
local attempt
|
|
633
|
+
for attempt in $(seq 1 12); do
|
|
634
|
+
if sudo -n /bin/systemctl is-active --quiet "{service}"; then return 0; fi
|
|
635
|
+
if [ "$attempt" -lt 12 ]; then sleep 2; fi
|
|
636
|
+
done
|
|
637
|
+
echo "permanent service readiness failure: {service}" >&2
|
|
638
|
+
return 1
|
|
639
|
+
}}
|
|
640
|
+
wait_for_http() {{
|
|
641
|
+
local url="$1" attempt status
|
|
642
|
+
for attempt in $(seq 1 12); do
|
|
643
|
+
status="$(curl --silent --show-error --output /dev/null --write-out '%{{http_code}}' "$url" 2>/dev/null || printf '000')"
|
|
644
|
+
case "$status" in
|
|
645
|
+
2??|3??) return 0 ;;
|
|
646
|
+
000|502|503|504)
|
|
647
|
+
if [ "$attempt" -lt 12 ]; then sleep 2; continue; fi
|
|
648
|
+
echo "transient readiness exhausted: $url returned $status" >&2
|
|
649
|
+
return 1 ;;
|
|
650
|
+
*)
|
|
651
|
+
echo "permanent readiness failure: $url returned $status" >&2
|
|
652
|
+
return 1 ;;
|
|
653
|
+
esac
|
|
654
|
+
done
|
|
655
|
+
}}
|
|
656
|
+
wait_for_active
|
|
657
|
+
wait_for_http "https://{domain}/robots.txt"
|
|
658
|
+
wait_for_http "https://{domain}/sitemap.xml"
|
|
477
659
|
|
|
478
660
|
# Keep current plus one rollback candidate, and prune only after health checks.
|
|
479
661
|
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
@@ -507,8 +689,13 @@ def main() -> int:
|
|
|
507
689
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
508
690
|
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
509
691
|
parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
|
|
692
|
+
parser.add_argument("--deployer-host", help="SSH host for --verify-deployer")
|
|
693
|
+
parser.add_argument("--verify-deployer", action="store_true", help="run a read-only SSH delegation check before writing VPS artifacts")
|
|
694
|
+
parser.add_argument("--require-deployer", action="store_true", help="require passed delegation evidence during deployment preflight")
|
|
695
|
+
parser.add_argument("--deployer-evidence", help="delegation evidence JSON; defaults to .maggie/deployment/deployer-delegation.json")
|
|
510
696
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
511
697
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
698
|
+
parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
|
|
512
699
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
513
700
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
514
701
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
@@ -521,6 +708,21 @@ def main() -> int:
|
|
|
521
708
|
parser.add_argument("--has-auth", action="store_true")
|
|
522
709
|
args = parser.parse_args()
|
|
523
710
|
try:
|
|
711
|
+
if args.check_runner:
|
|
712
|
+
report = release_runner_status(Path(args.check_runner).expanduser().resolve())
|
|
713
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
714
|
+
return 0
|
|
715
|
+
if args.verify_deployer:
|
|
716
|
+
if not args.deployer_host:
|
|
717
|
+
parser.error("--deployer-host is required with --verify-deployer")
|
|
718
|
+
delegation = deployer_delegation_preflight(args.deployer_host, args.deployer_user, args.release_root, args.service)
|
|
719
|
+
if args.output:
|
|
720
|
+
output = Path(args.output).resolve()
|
|
721
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
722
|
+
output.write_text(json.dumps(delegation, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
723
|
+
print(json.dumps(delegation, indent=2, ensure_ascii=False))
|
|
724
|
+
if not delegation["passed"]:
|
|
725
|
+
return 1
|
|
524
726
|
if args.vps_plan:
|
|
525
727
|
if not args.domain:
|
|
526
728
|
parser.error("--domain is required with --vps-plan")
|
|
@@ -549,7 +751,13 @@ def main() -> int:
|
|
|
549
751
|
result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
|
|
550
752
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
551
753
|
return 0 if result["passed"] else 1
|
|
552
|
-
result = preflight(
|
|
754
|
+
result = preflight(
|
|
755
|
+
Path(args.project).resolve(),
|
|
756
|
+
args.target,
|
|
757
|
+
args.environment,
|
|
758
|
+
args.require_deployer,
|
|
759
|
+
Path(args.deployer_evidence).expanduser().resolve() if args.deployer_evidence else None,
|
|
760
|
+
)
|
|
553
761
|
if args.output:
|
|
554
762
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
555
763
|
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|