@topy-ai/maggie 0.7.48 → 0.7.49
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 +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-social-share
|
|
3
3
|
description: Turn approved AI CMO articles and content assets into platform-native social drafts, scheduled posts, and measured distribution. Use for Social Share planning, LinkedIn/Facebook copy, CTA mapping, calendars, approvals, publish retries, or social performance readback.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Social Share
|
|
@@ -39,6 +39,22 @@ maggie tool maggie_social_share.py adapters .maggie/social-adapters.json
|
|
|
39
39
|
maggie tool maggie_social_share.py connections .maggie/social-connections.json
|
|
40
40
|
maggie tool maggie_social_share.py campaigns .maggie/social-campaigns.json
|
|
41
41
|
maggie tool maggie_social_share.py queue .maggie/social-queue.json --slots 14
|
|
42
|
+
maggie tool maggie_social_share.py reconcile .maggie/social-attempts.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A publishing adapter also declares limits, media rules, pending behaviour,
|
|
46
|
+
retry classes (a timeout is an unknown outcome, never retryable), idempotency,
|
|
47
|
+
and readback. `reconcile` turns the attempt log plus provider readback into
|
|
48
|
+
adopt / await / retry-with-the-same-key / fail decisions, so a
|
|
49
|
+
timeout-after-success never creates a duplicate post.
|
|
50
|
+
|
|
51
|
+
When social work is part of a wider campaign, record it in a
|
|
52
|
+
[marketing run](references/marketing-run.md) with named schedule layers
|
|
53
|
+
(infrastructure, content calendar, campaign flight, measurement window) and,
|
|
54
|
+
for regulated content, a review that gates only external publication:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
|
|
42
58
|
```
|
|
43
59
|
|
|
44
60
|
1. Select an approved or explicitly shareable source article/content asset.
|
|
@@ -54,7 +70,8 @@ maggie tool maggie_social_share.py queue .maggie/social-queue.json --slots 14
|
|
|
54
70
|
overdue posts). Campaign ideas stay in the idea stage until written, and a
|
|
55
71
|
campaign starts only through the Start gate.
|
|
56
72
|
6. Publish only after the required approval and entitlement checks. Record the
|
|
57
|
-
publication attempt and provider ID;
|
|
73
|
+
publication attempt and provider ID; reconcile unknown outcomes against
|
|
74
|
+
readback and retry only with the original idempotency key.
|
|
58
75
|
7. Read back clicks, engagement, assisted conversions, and failures separately
|
|
59
76
|
from article SEO/GEO metrics.
|
|
60
77
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Marketing runs, schedule layers, and regulated-content review
|
|
2
|
+
|
|
3
|
+
A marketing run joins content, Social Share, Ads, and measurement under one
|
|
4
|
+
project-scoped `runId`. It is a reference record: it points at source
|
|
5
|
+
revisions, capability evidence, approvals, mutations, and readbacks by ID and
|
|
6
|
+
never stores provider secrets or large payloads.
|
|
7
|
+
|
|
8
|
+
Schema: `contracts/maggie-ops/marketing-run-v1.schema.json`
|
|
9
|
+
(`schemaVersion: maggie-marketing-run-v1`). Validate with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
|
|
13
|
+
maggie tool maggie_marketing_run.py layers # print the layer model
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run fields
|
|
17
|
+
|
|
18
|
+
| Field | Purpose |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `runId`, `projectRef` | One project-scoped correlation ID across every channel. |
|
|
21
|
+
| `objective`, `conversion` | What the run is for and which event (and source) proves it. |
|
|
22
|
+
| `channels`, `timezone`, `mode` | Channels involved, the run's IANA zone, and `draft`/`scheduled`/`executing`/`completed`/`cancelled`. |
|
|
23
|
+
| `sourceRevisions` | `{sourceId, revision}` of every content source used. |
|
|
24
|
+
| `capabilityEvidence` | IDs/paths of capability reports (for example a Google capability report). |
|
|
25
|
+
| `approval` | `state`, `actor`, `approvalId`; external mutations require `approved`. |
|
|
26
|
+
| `scheduleLayers` | Named layers (below); a run never carries a single `scheduled_at`. |
|
|
27
|
+
| `mutations` | `{mutationId, kind, channel, status, idempotencyKey, approvalRef}`; external publication and Ads changes need an idempotency key. |
|
|
28
|
+
| `readbacks` | `{readbackId, mutationRef}`; a succeeded mutation needs one before the run is `completed`. |
|
|
29
|
+
| `policy`, `stages` | Regulated-content review (below). |
|
|
30
|
+
|
|
31
|
+
## Schedule layers
|
|
32
|
+
|
|
33
|
+
| Layer | Meaning | Owner | Notes |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| `infrastructure` | When a worker wakes up (cron, systemd timer, queue). | Operations | Points at a `maggie_schedule.py` job via `scheduleRef`; never a publish time. |
|
|
36
|
+
| `content_calendar` | When reviewed content or social posts may publish. | Editor/marketer | Social Share's slot queue; `start` required. |
|
|
37
|
+
| `campaign_flight` | When paid campaigns may run and spend. | Ads owner | `start` and `end` required. |
|
|
38
|
+
| `measurement_window` | Collection and attribution period. | Analytics | `start`, `end`, and `lagDays` for conversion lag. |
|
|
39
|
+
|
|
40
|
+
Every layer declares `timezone`, `owner`, `status`, and a `reconciliation`
|
|
41
|
+
rule describing how its real state is read back. `maggie_schedule.py` jobs are
|
|
42
|
+
always the `infrastructure` layer and reject publish-time fields.
|
|
43
|
+
|
|
44
|
+
## Regulated-content review
|
|
45
|
+
|
|
46
|
+
`policy.regulatedContent` is off by default, so existing runs are unchanged.
|
|
47
|
+
When it is on, only the **external publication** action waits for
|
|
48
|
+
`policy.reviewState: approved`. Content delivery and pull (including legacy
|
|
49
|
+
plugins), draft creation, SEO/GEO work, and measurement continue; policy
|
|
50
|
+
labels are additive metadata, never a gate in the delivery path.
|
|
51
|
+
|
|
52
|
+
The validator fails when:
|
|
53
|
+
|
|
54
|
+
- a stage other than `external_publication` is `blocked` with reason
|
|
55
|
+
`policy_review`;
|
|
56
|
+
- an `external_publication` mutation is pending or succeeded before the review
|
|
57
|
+
is approved.
|
|
@@ -99,3 +99,54 @@ Rules the planner applies, in order:
|
|
|
99
99
|
|
|
100
100
|
The plan is advisory output for the host scheduler, which re-plans after every
|
|
101
101
|
publish, approval, or queue edit instead of persisting stale per-post times.
|
|
102
|
+
|
|
103
|
+
## Publish contract — `adapters` with `"publishes": true`
|
|
104
|
+
|
|
105
|
+
An adapter that publishes must declare, in addition to its scope set:
|
|
106
|
+
|
|
107
|
+
| Field | Meaning |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `limits.text_max` | Provider text limit used by `validate`. |
|
|
110
|
+
| `media.types`, `media.max_bytes` | Accepted media and size budget. |
|
|
111
|
+
| `pending.states`, `pending.max_pending_seconds` | Provider "processing" states and how long a post may stay there before it is treated as failed. |
|
|
112
|
+
| `retry_classes.retryable` / `.terminal` / `.unknown_outcome` | Provider error classes. A timeout is always `unknown_outcome`: the post may already exist. |
|
|
113
|
+
| `idempotency.key_source`, `idempotency.provider_supported` | How the stable key is derived (for example `queue_item_id+revision`) and whether the provider honours it. |
|
|
114
|
+
| `readback.lookup` | `by_idempotency_key` (only when the provider supports keys), `by_marker` (key embedded in the post), or `by_recent_posts`. |
|
|
115
|
+
|
|
116
|
+
Adapters without `publishes` keep the scope-only shape.
|
|
117
|
+
|
|
118
|
+
## Publish reconciliation — `reconcile`
|
|
119
|
+
|
|
120
|
+
`reconcile` reads the publish-attempt log and a provider readback and returns
|
|
121
|
+
one safe action per queue item. It never publishes.
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"attempts": [{"queue_item_id": "q1", "idempotency_key": "q1:r3", "status": "timeout"}],
|
|
126
|
+
"readback": {"complete": true, "posts": [{"id": "urn:li:share:1", "marker": "#mg-q1:r3"}]}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
maggie tool maggie_social_share.py reconcile attempts.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Situation | Action |
|
|
135
|
+
|---|---|
|
|
136
|
+
| A readback post carries the item's key or marker | `adopt` it as the published post (timeout-after-success). |
|
|
137
|
+
| An attempt reported a post ID that readback does not show | `verify_readback` before anything else. |
|
|
138
|
+
| Last outcome `timeout`/`pending`/`unknown`, readback incomplete | `await_readback`; no retry. |
|
|
139
|
+
| Same, readback complete and no match | `retry_same_key`. |
|
|
140
|
+
| `failed_retryable` | `retry_same_key`. |
|
|
141
|
+
| `failed_terminal` | `fail`. |
|
|
142
|
+
| Two provider posts for one item, or retries that changed the key | invalid; `resolve_duplicate` (keep the first, delete extras). |
|
|
143
|
+
|
|
144
|
+
Immediate and scheduled publishing share this one path, so a retry can never
|
|
145
|
+
create a second post.
|
|
146
|
+
|
|
147
|
+
## Marketing runs and schedule layers
|
|
148
|
+
|
|
149
|
+
Social work that belongs to a wider campaign is recorded in a
|
|
150
|
+
[marketing run](marketing-run.md) (`maggie-marketing-run-v1`). The slot queue
|
|
151
|
+
above is the `content_calendar` layer; it is never the infrastructure timer
|
|
152
|
+
that wakes the publish worker.
|
|
@@ -566,7 +566,7 @@ def preflight(
|
|
|
566
566
|
rollback_passed = False
|
|
567
567
|
if rollback_evidence.exists():
|
|
568
568
|
try:
|
|
569
|
-
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8")))
|
|
569
|
+
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8")), plan.get("retention", {}).get("keep"))["passed"]
|
|
570
570
|
except (OSError, json.JSONDecodeError):
|
|
571
571
|
rollback_passed = False
|
|
572
572
|
migration_manifest = project / ".maggie" / "migration-manifest.json"
|
|
@@ -579,7 +579,12 @@ def preflight(
|
|
|
579
579
|
data_path = project / ".maggie" / "deployment" / "data-release.json"
|
|
580
580
|
data_required = bool(migration.get("dataDependencies"))
|
|
581
581
|
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 []}
|
|
582
|
-
|
|
582
|
+
retention = plan.get("retention", {}) if isinstance(plan.get("retention"), dict) else {}
|
|
583
|
+
keep = retention.get("keep")
|
|
584
|
+
retention_ok = bool(keep in (1, 2, 3, 4, 5) and retention.get("prune") and retention.get("preserve"))
|
|
585
|
+
rollback_command = (plan.get("commands") or {}).get("rollback")
|
|
586
|
+
# Never advertise a rollback target that the retention policy deletes.
|
|
587
|
+
rollback_consistent = retention_ok and (bool(rollback_command) if keep >= 2 else not rollback_command and not any("<previous-release>" in str(item) for item in retention.get("preserve", [])))
|
|
583
588
|
delegation = {"passed": True, "state": "not-required", "errors": []}
|
|
584
589
|
delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
|
|
585
590
|
if require_deployer:
|
|
@@ -591,14 +596,14 @@ def preflight(
|
|
|
591
596
|
else:
|
|
592
597
|
delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
|
|
593
598
|
delegation["path"] = str(delegation_path)
|
|
594
|
-
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})
|
|
599
|
+
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, "rollback_target_retained": rollback_consistent, "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})
|
|
595
600
|
result["delegation"] = delegation
|
|
596
601
|
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"]}
|
|
597
602
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
598
603
|
runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
|
|
599
604
|
result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
|
|
600
605
|
result["warnings"] = isolation_warnings + [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
601
|
-
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"]
|
|
606
|
+
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", "rollback_target_retained", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
602
607
|
if require_deployer:
|
|
603
608
|
result["blocking_checks"].append("deployer_delegation")
|
|
604
609
|
else:
|
|
@@ -608,7 +613,21 @@ def preflight(
|
|
|
608
613
|
return result
|
|
609
614
|
|
|
610
615
|
|
|
611
|
-
def
|
|
616
|
+
def retention_policy(root: str, current: str, keep: int) -> dict:
|
|
617
|
+
"""One retention policy shared by the plan, runner, preflight, prune, and rollback evidence."""
|
|
618
|
+
if not isinstance(keep, int) or isinstance(keep, bool) or not 1 <= keep <= 5:
|
|
619
|
+
raise ValueError("keep must be between 1 and 5")
|
|
620
|
+
preserve = [current] + ([f"{root}/releases/<previous-release>"] if keep >= 2 else [])
|
|
621
|
+
return {
|
|
622
|
+
"keep": keep,
|
|
623
|
+
"preserve": preserve,
|
|
624
|
+
"prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +{keep + 1} | cut -d' ' -f2- | xargs -r rm -rf --",
|
|
625
|
+
"rollbackAvailable": keep >= 2,
|
|
626
|
+
"rollbackUnavailableReason": "" if keep >= 2 else "retention keeps only the active release; recover by redeploying the previous commit as a new release and restoring its data checkpoint",
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy", keep_releases: int = 2) -> dict:
|
|
612
631
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
613
632
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
614
633
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
@@ -622,6 +641,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
622
641
|
raise ValueError("node port must be between 1024 and 65535")
|
|
623
642
|
root = release_root.rstrip("/")
|
|
624
643
|
current = f"{root}/current"
|
|
644
|
+
retention = retention_policy(root, current, keep_releases)
|
|
625
645
|
privileged_commands = [
|
|
626
646
|
f"/bin/systemctl restart {service}",
|
|
627
647
|
f"/bin/systemctl is-active {service}",
|
|
@@ -649,7 +669,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
649
669
|
"allowed_domains_required": True,
|
|
650
670
|
},
|
|
651
671
|
"release_layout": f"{root}/releases/<immutable-release> plus current symlink",
|
|
652
|
-
"retention":
|
|
672
|
+
"retention": retention,
|
|
653
673
|
"staging_first": True,
|
|
654
674
|
"secrets": "external environment file; no secrets in generated artifacts",
|
|
655
675
|
"commands": {
|
|
@@ -657,7 +677,11 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
657
677
|
"switch": f"ln -sfn {root}/releases/<release> {current}",
|
|
658
678
|
"restart": f"sudo -n /bin/systemctl restart {service}",
|
|
659
679
|
"verify": f"sudo -n /bin/systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
660
|
-
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}",
|
|
680
|
+
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}" if retention["rollbackAvailable"] else None,
|
|
681
|
+
# The database owner (e.g. postgres) often cannot traverse a release
|
|
682
|
+
# under a deploy user's home; stream the file instead of widening
|
|
683
|
+
# filesystem permissions.
|
|
684
|
+
"migrate_as_db_owner": f"sudo cat {root}/releases/<release>/<migration.sql> | sudo -u postgres psql -v ON_ERROR_STOP=1 --single-transaction -d <database> -f -",
|
|
661
685
|
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
662
686
|
},
|
|
663
687
|
"files": {
|
|
@@ -692,8 +716,12 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
692
716
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
693
717
|
|
|
694
718
|
|
|
695
|
-
def validate_rollback_evidence(value: object) -> dict:
|
|
696
|
-
"""Validate release rollback evidence, including the explicitly safe first release.
|
|
719
|
+
def validate_rollback_evidence(value: object, keep: int | None = None) -> dict:
|
|
720
|
+
"""Validate release rollback evidence, including the explicitly safe first release.
|
|
721
|
+
|
|
722
|
+
With a one-release retention policy no previous release stays on disk, so
|
|
723
|
+
upgrade evidence must say the code rollback is unavailable and name the
|
|
724
|
+
recovery plan instead of advertising a removed target."""
|
|
697
725
|
if not isinstance(value, dict):
|
|
698
726
|
return {"passed": False, "errors": ["rollback evidence must be an object"]}
|
|
699
727
|
errors = []
|
|
@@ -713,6 +741,13 @@ def validate_rollback_evidence(value: object) -> dict:
|
|
|
713
741
|
errors.append("first release evidence must not claim a previous release")
|
|
714
742
|
if not value.get("notApplicableReason"):
|
|
715
743
|
errors.append("first release evidence needs notApplicableReason")
|
|
744
|
+
elif keep == 1:
|
|
745
|
+
if value.get("codeRollback") != "unavailable":
|
|
746
|
+
errors.append("retention keeps one release; rollback evidence must declare codeRollback: unavailable")
|
|
747
|
+
if not value.get("recoveryPlan"):
|
|
748
|
+
errors.append("retention keeps one release; rollback evidence needs a recoveryPlan (redeploy previous commit + data checkpoint)")
|
|
749
|
+
elif value.get("codeRollback") == "unavailable":
|
|
750
|
+
errors.append("codeRollback unavailable contradicts a retention policy that keeps a previous release")
|
|
716
751
|
elif not value.get("previousRelease"):
|
|
717
752
|
errors.append("upgrade rollback evidence requires previousRelease")
|
|
718
753
|
return {"passed": not errors, "errors": errors}
|
|
@@ -740,15 +775,14 @@ def verify_infrastructure(units_dir: Path | None, service: str, timer: str | Non
|
|
|
740
775
|
|
|
741
776
|
|
|
742
777
|
def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
|
|
743
|
-
|
|
744
|
-
raise ValueError("keep must be between 2 and 5")
|
|
778
|
+
policy = retention_policy(release_root.rstrip("/"), current_link, keep)
|
|
745
779
|
root = Path(release_root).resolve()
|
|
746
780
|
releases = sorted((path for path in root.iterdir() if path.is_dir() and not path.is_symlink()), key=lambda path: path.stat().st_mtime, reverse=True) if root.is_dir() else []
|
|
747
781
|
current_target = Path(current_link).resolve() if Path(current_link).exists() else None
|
|
748
782
|
preserved = {str(current_target)} if current_target else set()
|
|
749
783
|
preserved.update(str(path) for path in releases[:keep])
|
|
750
784
|
candidates = [str(path) for path in releases if str(path) not in preserved]
|
|
751
|
-
return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
|
|
785
|
+
return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "rollbackAvailable": policy["rollbackAvailable"], "rollbackUnavailableReason": policy["rollbackUnavailableReason"], "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
|
|
752
786
|
|
|
753
787
|
|
|
754
788
|
def write_vps_plan(output_dir: Path, plan: dict) -> None:
|
|
@@ -836,6 +870,7 @@ def release_runner(plan: dict) -> str:
|
|
|
836
870
|
service = plan["service"]
|
|
837
871
|
domain = plan["domain"]
|
|
838
872
|
current = plan["current_release"]
|
|
873
|
+
keep = retention_policy(root, current, plan.get("retention", {}).get("keep", 2))["keep"]
|
|
839
874
|
generator = release_runner_generator()
|
|
840
875
|
return f'''#!/usr/bin/env bash
|
|
841
876
|
set -euo pipefail
|
|
@@ -899,13 +934,19 @@ wait_for_active
|
|
|
899
934
|
wait_for_http "https://{domain}/robots.txt"
|
|
900
935
|
wait_for_http "https://{domain}/sitemap.xml"
|
|
901
936
|
|
|
902
|
-
#
|
|
937
|
+
# Retention policy: keep {keep} release(s), and prune only after health checks.
|
|
938
|
+
KEEP_RELEASES={keep}
|
|
903
939
|
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
904
|
-
for candidate in "${{RELEASES[@]
|
|
905
|
-
[ "$candidate" = "$PREVIOUS" ] && continue
|
|
940
|
+
for candidate in "${{RELEASES[@]:$KEEP_RELEASES}}"; do
|
|
941
|
+
[ "$KEEP_RELEASES" -ge 2 ] && [ "$candidate" = "$PREVIOUS" ] && continue
|
|
906
942
|
[ "$candidate" = "$RELEASE" ] && continue
|
|
907
943
|
rm -rf -- "$candidate"
|
|
908
944
|
done
|
|
945
|
+
if [ "$KEEP_RELEASES" -ge 2 ] && [ -n "$PREVIOUS" ]; then
|
|
946
|
+
echo "rollback target retained: $PREVIOUS"
|
|
947
|
+
else
|
|
948
|
+
echo "rollback target: unavailable (retention keeps only the active release; redeploy the previous commit and restore its data checkpoint)"
|
|
949
|
+
fi
|
|
909
950
|
'''
|
|
910
951
|
|
|
911
952
|
|
|
@@ -946,7 +987,7 @@ def main() -> int:
|
|
|
946
987
|
parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
|
|
947
988
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
948
989
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
949
|
-
parser.add_argument("--keep-releases", type=int, default=2)
|
|
990
|
+
parser.add_argument("--keep-releases", type=int, default=2, help="releases to keep (1-5); 1 means no on-disk rollback target, used by --vps-plan and --retention-plan")
|
|
950
991
|
parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
|
|
951
992
|
parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
|
|
952
993
|
parser.add_argument("--timer", help="systemd timer unit name")
|
|
@@ -974,7 +1015,7 @@ def main() -> int:
|
|
|
974
1015
|
if args.vps_plan:
|
|
975
1016
|
if not args.domain:
|
|
976
1017
|
parser.error("--domain is required with --vps-plan")
|
|
977
|
-
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user)
|
|
1018
|
+
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user, args.keep_releases)
|
|
978
1019
|
if args.plan_dir:
|
|
979
1020
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
980
1021
|
if args.runner_output:
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run declared, role-scoped authenticated API canaries without storing credentials or bodies.
|
|
3
|
+
|
|
4
|
+
Public route canaries cannot see authenticated failures such as database
|
|
5
|
+
permission errors, schema mismatches, or 403s on account and business APIs.
|
|
6
|
+
This command executes a reviewed probe plan with credentials read only from
|
|
7
|
+
environment variable *names*, and records status codes and contract checks
|
|
8
|
+
only: never cookies, tokens, headers, response bodies, or personal data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
PLAN_SCHEMA = "maggie-auth-canary-plan.v1"
|
|
25
|
+
EVIDENCE_SCHEMA = "maggie-deployment-auth-canary.v1"
|
|
26
|
+
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
|
27
|
+
ALLOWED_METHODS = {"GET", "HEAD", "OPTIONS"} | MUTATING
|
|
28
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
29
|
+
ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
|
30
|
+
SECRET_VALUE = re.compile(r"(?:Bearer\s+\S{8,}|eyJ[A-Za-z0-9_-]{10,}\.|sk[-_](?:live|test)[-_]|session=|sid=|[A-Fa-f0-9]{32,})")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlanError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify(status: int | None) -> str:
|
|
38
|
+
if status is None:
|
|
39
|
+
return "network"
|
|
40
|
+
if status in (401, 403):
|
|
41
|
+
return "authorization"
|
|
42
|
+
if status >= 500:
|
|
43
|
+
return "server"
|
|
44
|
+
if status == 404:
|
|
45
|
+
return "route"
|
|
46
|
+
return "contract"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def contains_literal_secret(value: object) -> bool:
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return any(contains_literal_secret(item) for item in value.values())
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return any(contains_literal_secret(item) for item in value)
|
|
54
|
+
return isinstance(value, str) and bool(SECRET_VALUE.search(value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_plan(plan: object) -> dict:
|
|
58
|
+
"""Return the normalized plan or raise PlanError for an unsafe/invalid plan."""
|
|
59
|
+
if not isinstance(plan, dict) or plan.get("schemaVersion") != PLAN_SCHEMA:
|
|
60
|
+
raise PlanError(f"plan schemaVersion must be {PLAN_SCHEMA}")
|
|
61
|
+
if contains_literal_secret(plan):
|
|
62
|
+
raise PlanError("plan contains a literal credential; reference environment variable names only")
|
|
63
|
+
base = str(plan.get("baseUrl") or "")
|
|
64
|
+
parsed = urlparse(base)
|
|
65
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
|
|
66
|
+
raise PlanError("baseUrl must be an http(s) origin without query or fragment")
|
|
67
|
+
if parsed.scheme == "http" and parsed.hostname not in LOCAL_HOSTS:
|
|
68
|
+
raise PlanError("baseUrl must use https outside localhost")
|
|
69
|
+
roles = plan.get("roles")
|
|
70
|
+
if not isinstance(roles, dict) or not roles:
|
|
71
|
+
raise PlanError("roles must declare at least one role")
|
|
72
|
+
for name, role in roles.items():
|
|
73
|
+
if not isinstance(role, dict):
|
|
74
|
+
raise PlanError(f"role {name} must be an object")
|
|
75
|
+
keys = [key for key in ("tokenEnv", "cookieEnv") if role.get(key)]
|
|
76
|
+
if len(keys) != 1:
|
|
77
|
+
raise PlanError(f"role {name} must declare exactly one of tokenEnv or cookieEnv")
|
|
78
|
+
if not ENV_NAME.fullmatch(str(role[keys[0]])):
|
|
79
|
+
raise PlanError(f"role {name} {keys[0]} must be an environment variable name")
|
|
80
|
+
unknown = set(role) - {"tokenEnv", "cookieEnv", "scheme", "description"}
|
|
81
|
+
if unknown:
|
|
82
|
+
raise PlanError(f"role {name} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
83
|
+
probes = plan.get("probes")
|
|
84
|
+
if not isinstance(probes, list) or not probes:
|
|
85
|
+
raise PlanError("probes must be a non-empty list")
|
|
86
|
+
seen = set()
|
|
87
|
+
for probe in probes:
|
|
88
|
+
if not isinstance(probe, dict):
|
|
89
|
+
raise PlanError("each probe must be an object")
|
|
90
|
+
probe_id = str(probe.get("id") or "")
|
|
91
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", probe_id) or probe_id in seen:
|
|
92
|
+
raise PlanError(f"probe id is missing, invalid, or duplicated: {probe_id or '<empty>'}")
|
|
93
|
+
seen.add(probe_id)
|
|
94
|
+
if probe.get("role") not in roles:
|
|
95
|
+
raise PlanError(f"probe {probe_id} references an undeclared role")
|
|
96
|
+
method = str(probe.get("method", "GET")).upper()
|
|
97
|
+
if method not in ALLOWED_METHODS:
|
|
98
|
+
raise PlanError(f"probe {probe_id} method is unsupported")
|
|
99
|
+
path = str(probe.get("path") or "")
|
|
100
|
+
if not path.startswith("/") or "//" in path or "?" in path or "#" in path:
|
|
101
|
+
raise PlanError(f"probe {probe_id} path must be an absolute path without query or fragment")
|
|
102
|
+
expect = probe.get("expectStatus", [200])
|
|
103
|
+
if not isinstance(expect, list) or not expect or not all(isinstance(code, int) and 100 <= code <= 599 for code in expect):
|
|
104
|
+
raise PlanError(f"probe {probe_id} expectStatus must be a list of HTTP status codes")
|
|
105
|
+
if method in MUTATING and probe.get("fixture") != "disposable":
|
|
106
|
+
raise PlanError(f"probe {probe_id} mutates state and must declare fixture: disposable")
|
|
107
|
+
keys = probe.get("expectJsonKeys", [])
|
|
108
|
+
if not isinstance(keys, list) or not all(isinstance(key, str) and key for key in keys):
|
|
109
|
+
raise PlanError(f"probe {probe_id} expectJsonKeys must be a list of key names")
|
|
110
|
+
return plan
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def credential_headers(role: dict) -> dict[str, str] | None:
|
|
114
|
+
if role.get("tokenEnv"):
|
|
115
|
+
value = os.environ.get(role["tokenEnv"], "")
|
|
116
|
+
return {"Authorization": f"{role.get('scheme', 'Bearer')} {value}"} if value else None
|
|
117
|
+
value = os.environ.get(role["cookieEnv"], "")
|
|
118
|
+
return {"Cookie": value} if value else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_probe(base: str, probe: dict, role: dict, timeout: float) -> dict:
|
|
122
|
+
method = str(probe.get("method", "GET")).upper()
|
|
123
|
+
record = {"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "expectStatus": probe.get("expectStatus", [200])}
|
|
124
|
+
if probe.get("fixture"):
|
|
125
|
+
record["fixture"] = probe["fixture"]
|
|
126
|
+
headers = credential_headers(role)
|
|
127
|
+
if headers is None:
|
|
128
|
+
record.update({"state": "inconclusive", "reason": "credential environment variable is not set"})
|
|
129
|
+
return record
|
|
130
|
+
headers["User-Agent"] = "maggie-deployment-auth-canary/1.0"
|
|
131
|
+
headers["Accept"] = "application/json"
|
|
132
|
+
data = None
|
|
133
|
+
if method in MUTATING:
|
|
134
|
+
headers["Content-Type"] = "application/json"
|
|
135
|
+
data = json.dumps(probe.get("body", {})).encode("utf-8")
|
|
136
|
+
request = urllib.request.Request(base.rstrip("/") + probe["path"], data=data, headers=headers, method=method)
|
|
137
|
+
status: int | None = None
|
|
138
|
+
body = b""
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
141
|
+
status = response.status
|
|
142
|
+
body = response.read(1_000_000)
|
|
143
|
+
except urllib.error.HTTPError as error:
|
|
144
|
+
status = error.code
|
|
145
|
+
except (urllib.error.URLError, OSError) as error:
|
|
146
|
+
record.update({"state": "failed", "status": None, "failureClass": "network", "reason": type(error).__name__})
|
|
147
|
+
return record
|
|
148
|
+
record["status"] = status
|
|
149
|
+
errors = []
|
|
150
|
+
if status not in record["expectStatus"]:
|
|
151
|
+
errors.append(f"status {status} not in {record['expectStatus']}")
|
|
152
|
+
keys = probe.get("expectJsonKeys", [])
|
|
153
|
+
if keys and not errors:
|
|
154
|
+
try:
|
|
155
|
+
payload = json.loads(body.decode("utf-8") or "null")
|
|
156
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
157
|
+
payload = None
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
errors.append("response is not a JSON object")
|
|
160
|
+
else:
|
|
161
|
+
missing = [key for key in keys if key not in payload]
|
|
162
|
+
record["contractKeys"] = {"expected": len(keys), "present": len(keys) - len(missing)}
|
|
163
|
+
if missing:
|
|
164
|
+
errors.append("missing contract keys: " + ", ".join(missing))
|
|
165
|
+
if errors:
|
|
166
|
+
record.update({"state": "failed", "failureClass": classify(status) if status not in record["expectStatus"] else "contract", "reason": "; ".join(errors)})
|
|
167
|
+
else:
|
|
168
|
+
record["state"] = "passed"
|
|
169
|
+
return record
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def auth_canary(plan: dict, allow_mutations: bool, timeout: float = 15.0) -> dict:
|
|
173
|
+
plan = validate_plan(plan)
|
|
174
|
+
results = []
|
|
175
|
+
for probe in plan["probes"]:
|
|
176
|
+
method = str(probe.get("method", "GET")).upper()
|
|
177
|
+
if method in MUTATING and not allow_mutations:
|
|
178
|
+
results.append({"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "fixture": probe.get("fixture"), "state": "blocked", "reason": "mutating probe requires --allow-mutations"})
|
|
179
|
+
continue
|
|
180
|
+
results.append(run_probe(plan["baseUrl"], probe, plan["roles"][probe["role"]], timeout))
|
|
181
|
+
states = {item["state"] for item in results}
|
|
182
|
+
status = "failed" if "failed" in states else "inconclusive" if states & {"inconclusive", "blocked"} else "passed"
|
|
183
|
+
return {
|
|
184
|
+
"schemaVersion": EVIDENCE_SCHEMA,
|
|
185
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
186
|
+
"origin": urlparse(plan["baseUrl"]).netloc,
|
|
187
|
+
"status": status,
|
|
188
|
+
"passed": status == "passed",
|
|
189
|
+
"roles": sorted(plan["roles"]),
|
|
190
|
+
"probes": results,
|
|
191
|
+
"privacy": "status codes and contract-key counts only; no credentials, headers, bodies, or personal data",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> int:
|
|
196
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
197
|
+
parser.add_argument("--plan", type=Path, required=True, help=f"{PLAN_SCHEMA} probe plan")
|
|
198
|
+
parser.add_argument("--allow-mutations", action="store_true", help="run probes that mutate disposable fixtures")
|
|
199
|
+
parser.add_argument("--timeout", type=float, default=15.0)
|
|
200
|
+
parser.add_argument("--output", type=Path)
|
|
201
|
+
args = parser.parse_args()
|
|
202
|
+
try:
|
|
203
|
+
report = auth_canary(json.loads(args.plan.read_text(encoding="utf-8")), args.allow_mutations, args.timeout)
|
|
204
|
+
except (OSError, json.JSONDecodeError, PlanError) as error:
|
|
205
|
+
print(f"BLOCKED: maggie deployment auth-canary: {error}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
|
208
|
+
if args.output:
|
|
209
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
args.output.write_text(encoded, encoding="utf-8")
|
|
211
|
+
print(encoded, end="")
|
|
212
|
+
return 0 if report["passed"] else 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|