nocturne-memory 0.1.12__tar.gz
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.
- nocturne_memory-0.1.12/.gitignore +9 -0
- nocturne_memory-0.1.12/Dockerfile +28 -0
- nocturne_memory-0.1.12/PKG-INFO +46 -0
- nocturne_memory-0.1.12/README.md +22 -0
- nocturne_memory-0.1.12/api_contract_fingerprints.json +14 -0
- nocturne_memory-0.1.12/infra/billing-breaker/README.md +545 -0
- nocturne_memory-0.1.12/infra/billing-breaker/billing_breaker.py +221 -0
- nocturne_memory-0.1.12/infra/billing-breaker/deploy.sh +692 -0
- nocturne_memory-0.1.12/infra/billing-breaker/deployment_checks.py +934 -0
- nocturne_memory-0.1.12/infra/billing-breaker/main.py +51 -0
- nocturne_memory-0.1.12/infra/billing-breaker/requirements.txt +2 -0
- nocturne_memory-0.1.12/pyproject.toml +78 -0
- nocturne_memory-0.1.12/src/spine/__init__.py +3 -0
- nocturne_memory-0.1.12/src/spine/api_contract.py +47 -0
- nocturne_memory-0.1.12/src/spine/auth.py +38 -0
- nocturne_memory-0.1.12/src/spine/config.py +69 -0
- nocturne_memory-0.1.12/src/spine/contracts.py +210 -0
- nocturne_memory-0.1.12/src/spine/curation/__init__.py +2 -0
- nocturne_memory-0.1.12/src/spine/curation/contracts.py +117 -0
- nocturne_memory-0.1.12/src/spine/curation/diagnostics.py +307 -0
- nocturne_memory-0.1.12/src/spine/curation/provider.py +236 -0
- nocturne_memory-0.1.12/src/spine/curation/router.py +48 -0
- nocturne_memory-0.1.12/src/spine/curation/service.py +504 -0
- nocturne_memory-0.1.12/src/spine/curation/worker.py +49 -0
- nocturne_memory-0.1.12/src/spine/db/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/db/engine.py +9 -0
- nocturne_memory-0.1.12/src/spine/db/locking.py +81 -0
- nocturne_memory-0.1.12/src/spine/db/memory.py +297 -0
- nocturne_memory-0.1.12/src/spine/db/migrate.py +73 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/README +1 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/env.py +71 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/script.py.mako +25 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0001_c2_schema.py +152 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0002_origin_path.py +27 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0003_spend_ledger.py +283 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0004_hybrid_candidate_fts.py +67 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0005_per_message_rescore.py +33 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0006_extraction_queue.py +76 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0007_seed_ingestion.py +64 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0008_scorer_console.py +43 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0009_spend_reconciliation.py +93 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0010_budget_cut_band.py +34 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0011_learner_runs.py +64 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0012_injection_event_annotations.py +75 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0013_transcript_records.py +61 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0014_location_scorer.py +121 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0015_symphony_memory_bridge.py +159 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0016_thread_index.py +124 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0017_memory_share.py +117 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0018_curator_runs.py +270 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0019_curator_pressure.py +118 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0020_optimization_runs.py +95 -0
- nocturne_memory-0.1.12/src/spine/db/migrations/versions/0021_origin_location.py +109 -0
- nocturne_memory-0.1.12/src/spine/db/models.py +1063 -0
- nocturne_memory-0.1.12/src/spine/db/session.py +9 -0
- nocturne_memory-0.1.12/src/spine/deploy_resources.py +106 -0
- nocturne_memory-0.1.12/src/spine/embeddings/__init__.py +446 -0
- nocturne_memory-0.1.12/src/spine/embeddings/router.py +88 -0
- nocturne_memory-0.1.12/src/spine/events/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/ids.py +33 -0
- nocturne_memory-0.1.12/src/spine/inject/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/inject/annotations.py +143 -0
- nocturne_memory-0.1.12/src/spine/inject/decisions.py +563 -0
- nocturne_memory-0.1.12/src/spine/inject/renderer.py +84 -0
- nocturne_memory-0.1.12/src/spine/inject/router.py +308 -0
- nocturne_memory-0.1.12/src/spine/inject/scorer.py +741 -0
- nocturne_memory-0.1.12/src/spine/inject/service.py +774 -0
- nocturne_memory-0.1.12/src/spine/learner/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/learner/contracts.py +29 -0
- nocturne_memory-0.1.12/src/spine/learner/evidence.py +321 -0
- nocturne_memory-0.1.12/src/spine/learner/locking.py +5 -0
- nocturne_memory-0.1.12/src/spine/learner/model.py +846 -0
- nocturne_memory-0.1.12/src/spine/learner/router.py +28 -0
- nocturne_memory-0.1.12/src/spine/learner/service.py +822 -0
- nocturne_memory-0.1.12/src/spine/learner/worker.py +63 -0
- nocturne_memory-0.1.12/src/spine/m2k/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/m2k/contracts.py +394 -0
- nocturne_memory-0.1.12/src/spine/m2k/router.py +141 -0
- nocturne_memory-0.1.12/src/spine/m2k/service.py +1580 -0
- nocturne_memory-0.1.12/src/spine/main.py +325 -0
- nocturne_memory-0.1.12/src/spine/memory/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/memory/router.py +393 -0
- nocturne_memory-0.1.12/src/spine/memory/service.py +1386 -0
- nocturne_memory-0.1.12/src/spine/problems.py +69 -0
- nocturne_memory-0.1.12/src/spine/queue/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/queue/contracts.py +125 -0
- nocturne_memory-0.1.12/src/spine/queue/router.py +106 -0
- nocturne_memory-0.1.12/src/spine/queue/service.py +972 -0
- nocturne_memory-0.1.12/src/spine/spend/__init__.py +6 -0
- nocturne_memory-0.1.12/src/spine/spend/contracts.py +214 -0
- nocturne_memory-0.1.12/src/spine/spend/reconciliation.py +255 -0
- nocturne_memory-0.1.12/src/spine/spend/router.py +70 -0
- nocturne_memory-0.1.12/src/spine/spend/service.py +258 -0
- nocturne_memory-0.1.12/src/spine/spend/views.py +72 -0
- nocturne_memory-0.1.12/src/spine/symphony/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/symphony/contracts.py +129 -0
- nocturne_memory-0.1.12/src/spine/symphony/router.py +75 -0
- nocturne_memory-0.1.12/src/spine/symphony/service.py +248 -0
- nocturne_memory-0.1.12/src/spine/tokens.py +19 -0
- nocturne_memory-0.1.12/src/spine/transcripts/__init__.py +1 -0
- nocturne_memory-0.1.12/src/spine/transcripts/contracts.py +107 -0
- nocturne_memory-0.1.12/src/spine/transcripts/router.py +55 -0
- nocturne_memory-0.1.12/src/spine/transcripts/service.py +118 -0
- nocturne_memory-0.1.12/src/spine/vitals/__init__.py +6 -0
- nocturne_memory-0.1.12/src/spine/vitals/contracts.py +241 -0
- nocturne_memory-0.1.12/src/spine/vitals/router.py +63 -0
- nocturne_memory-0.1.12/src/spine/vitals/service.py +396 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
FROM python:3.12.13-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS base
|
|
2
|
+
|
|
3
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
4
|
+
PYTHONUNBUFFERED=1
|
|
5
|
+
|
|
6
|
+
FROM base AS builder
|
|
7
|
+
|
|
8
|
+
WORKDIR /build
|
|
9
|
+
|
|
10
|
+
COPY pyproject.toml ./
|
|
11
|
+
COPY README.md ./
|
|
12
|
+
COPY Dockerfile ./
|
|
13
|
+
COPY src ./src
|
|
14
|
+
COPY infra ./infra
|
|
15
|
+
|
|
16
|
+
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels .
|
|
17
|
+
|
|
18
|
+
FROM base AS runtime
|
|
19
|
+
|
|
20
|
+
WORKDIR /app
|
|
21
|
+
|
|
22
|
+
COPY --from=builder /wheels /wheels
|
|
23
|
+
|
|
24
|
+
RUN pip install --no-cache-dir /wheels/nocturne_memory-*.whl && rm -rf /wheels
|
|
25
|
+
|
|
26
|
+
EXPOSE 8000
|
|
27
|
+
|
|
28
|
+
CMD ["uvicorn", "spine.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nocturne-memory
|
|
3
|
+
Version: 0.1.12
|
|
4
|
+
Summary: NOCTURNE Memory Palace service
|
|
5
|
+
Project-URL: Homepage, https://github.com/Nate0-1999/nocturne
|
|
6
|
+
Project-URL: Source, https://github.com/Nate0-1999/nocturne-memory
|
|
7
|
+
Requires-Python: <3.13,>=3.12
|
|
8
|
+
Requires-Dist: alembic<2,>=1.13
|
|
9
|
+
Requires-Dist: asyncpg<1,>=0.29
|
|
10
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
11
|
+
Requires-Dist: httpx<1,>=0.27
|
|
12
|
+
Requires-Dist: pgvector<1,>=0.3
|
|
13
|
+
Requires-Dist: pydantic-settings<3,>=2
|
|
14
|
+
Requires-Dist: pydantic<3,>=2
|
|
15
|
+
Requires-Dist: sqlalchemy[asyncio]<3,>=2
|
|
16
|
+
Requires-Dist: tiktoken<1,>=0.9
|
|
17
|
+
Requires-Dist: uvicorn[standard]<1,>=0.30
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff<1,>=0.9; extra == 'dev'
|
|
22
|
+
Requires-Dist: testcontainers[postgres]<5,>=4.8; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# nocturne-memory
|
|
26
|
+
|
|
27
|
+
`nocturne-memory` is NOCTURNE's Memory Palace service and migration package. It
|
|
28
|
+
is released in lockstep as a dependency of the public `nocturne-harness`
|
|
29
|
+
distribution.
|
|
30
|
+
|
|
31
|
+
The service owns the extraction consent boundary at `/v1/extractions` and
|
|
32
|
+
`/v1/approval-queue`. Pending units use the internal `candidate` lifecycle
|
|
33
|
+
state and are excluded from ordinary memory listing, search, and injection.
|
|
34
|
+
Queue decisions are append-only; approval enacts the proposed verdict through
|
|
35
|
+
revisioned head changes and typed lineage edges, while denial revision-
|
|
36
|
+
tombstones the candidate as rejected.
|
|
37
|
+
|
|
38
|
+
Injection treats memory room as a versioned share of the active model context
|
|
39
|
+
window. The share is a ceiling, not a quota: unused room remains available to
|
|
40
|
+
conversation, while pinned memories always inject and report any overflow in
|
|
41
|
+
the prepare allocation. Share and threshold join the ordinary replay learner
|
|
42
|
+
after 100 authentic, hygiene-filtered owner dispositions; every learned
|
|
43
|
+
generation remains proposed until the owner activates it.
|
|
44
|
+
|
|
45
|
+
New users should start at the [NOCTURNE quickstart](https://github.com/Nate0-1999/nocturne)
|
|
46
|
+
and install `nocturne-harness`; they do not need to install this package separately.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# nocturne-memory
|
|
2
|
+
|
|
3
|
+
`nocturne-memory` is NOCTURNE's Memory Palace service and migration package. It
|
|
4
|
+
is released in lockstep as a dependency of the public `nocturne-harness`
|
|
5
|
+
distribution.
|
|
6
|
+
|
|
7
|
+
The service owns the extraction consent boundary at `/v1/extractions` and
|
|
8
|
+
`/v1/approval-queue`. Pending units use the internal `candidate` lifecycle
|
|
9
|
+
state and are excluded from ordinary memory listing, search, and injection.
|
|
10
|
+
Queue decisions are append-only; approval enacts the proposed verdict through
|
|
11
|
+
revisioned head changes and typed lineage edges, while denial revision-
|
|
12
|
+
tombstones the candidate as rejected.
|
|
13
|
+
|
|
14
|
+
Injection treats memory room as a versioned share of the active model context
|
|
15
|
+
window. The share is a ceiling, not a quota: unused room remains available to
|
|
16
|
+
conversation, while pinned memories always inject and report any overflow in
|
|
17
|
+
the prepare allocation. Share and threshold join the ordinary replay learner
|
|
18
|
+
after 100 authentic, hygiene-filtered owner dispositions; every learned
|
|
19
|
+
generation remains proposed until the owner activates it.
|
|
20
|
+
|
|
21
|
+
New users should start at the [NOCTURNE quickstart](https://github.com/Nate0-1999/nocturne)
|
|
22
|
+
and install `nocturne-harness`; they do not need to install this package separately.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"0.1.0": "ebc263f97e4b357e3370d2b5a188f752bf67b1743d6cde9bd68acff853703e33",
|
|
3
|
+
"0.1.1": "ebc263f97e4b357e3370d2b5a188f752bf67b1743d6cde9bd68acff853703e33",
|
|
4
|
+
"0.1.10": "503eaed7b386b0a96ab56a444081f1a37a28f113cbc6e6617e434af82765794d",
|
|
5
|
+
"0.1.11": "304172efb12e804494edb7cccf11f2d1d00ef69d0e204fa659595dddf10dc20c",
|
|
6
|
+
"0.1.2": "ec1b87185670ac86377b389ac67c536fb295dac029e4e97c4ffcccd3017cee5b",
|
|
7
|
+
"0.1.3": "4fc4d8920ce54ea0766c933719429bf14e7372c1a5ab6f80a5b2d5d42adf699b",
|
|
8
|
+
"0.1.4": "cb7499803498e8097529f3812b9bfeb094f100ff6761b29e461c56b1674ae38d",
|
|
9
|
+
"0.1.5": "3867455375aabcf861c87af0432d0c27f710cdb13aac199e86f2227a9a16777b",
|
|
10
|
+
"0.1.6": "48d16b6a328e61dd31a9f84691b4a01fc4107b13adc99955de18dec22278dce2",
|
|
11
|
+
"0.1.7": "b8ff406c1f57d0bfc2a9a175940da48dd4d258b11d4b0695ecba44e8aec910b3",
|
|
12
|
+
"0.1.8": "d182caa605cd1430c7fba0012ade031cb78ab5308043d25e18aaf552500e182a",
|
|
13
|
+
"0.1.9": "662ac885d8a5f39f10464d2f5db545aed435fa039220cf5d65c22228e653ba94"
|
|
14
|
+
}
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
# D2 billing circuit breaker
|
|
2
|
+
|
|
3
|
+
> STATUS: DEPLOYED AND ARMED in `n8-memory-palace` on 2026-07-21. The
|
|
4
|
+
> `billing-breaker` topic, private no-retry function, isolated identities,
|
|
5
|
+
> detach-role binding, and budget wiring are live and least-privilege
|
|
6
|
+
> verified. To change or remove it, use the cleanup procedure below, then
|
|
7
|
+
> re-run `deploy.sh --apply` (it is first-deploy-only and refuses to run
|
|
8
|
+
> while these resources exist). The preflight was hardened at deploy time to
|
|
9
|
+
> run against a default-posture GCP project (recognizes Google default
|
|
10
|
+
> identities: Compute, App Engine `@appspot`, Container Registry, and
|
|
11
|
+
> `gcp-sa-*` agents); see DECISIONS 017–018.
|
|
12
|
+
|
|
13
|
+
This package is an armed, one-way cost control for `n8-memory-palace`. A valid
|
|
14
|
+
budget message with `costAmount >= budgetAmount` calls Cloud Billing
|
|
15
|
+
`updateBillingInfo` with an empty `billingAccountName`. That deliberately
|
|
16
|
+
detaches the project's billing account and can stop or irretrievably delete
|
|
17
|
+
Cloud Run, Cloud SQL, and every other billable project service. There is no
|
|
18
|
+
simulation switch in the deployed function.
|
|
19
|
+
|
|
20
|
+
Agents may build and test this directory. **Only a human may run `deploy.sh
|
|
21
|
+
--apply`, publish a live message, alter IAM, or execute recovery commands.**
|
|
22
|
+
|
|
23
|
+
## What this does
|
|
24
|
+
|
|
25
|
+
- accepts only Pub/Sub schema `1.0` messages whose `billingAccountId` and
|
|
26
|
+
`budgetId` match deploy-time settings;
|
|
27
|
+
- keeps the destructive target fixed in source as `projects/n8-memory-palace`;
|
|
28
|
+
- compares JSON numbers as decimals and detaches at equality or overage;
|
|
29
|
+
- emits one-line structured JSON decisions before and after the action;
|
|
30
|
+
- acknowledges malformed, foreign, and below-budget messages without creating
|
|
31
|
+
a Cloud Billing client;
|
|
32
|
+
- surfaces Cloud Billing failures, while the deployed trigger explicitly does
|
|
33
|
+
not retry failed events; the next periodic budget status is the next attempt;
|
|
34
|
+
- makes duplicate delivery state-idempotent by repeatedly requesting the same
|
|
35
|
+
empty billing-account assignment, without a broader status-read permission.
|
|
36
|
+
|
|
37
|
+
Google documents the [budget notification schema, periodic delivery, and
|
|
38
|
+
at-least-once semantics](https://docs.cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications),
|
|
39
|
+
the [empty-account detach operation](https://docs.cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo),
|
|
40
|
+
the [disable-billing pattern and blast radius](https://docs.cloud.google.com/billing/docs/how-to/disable-billing-with-notifications),
|
|
41
|
+
and the [function retry hazard](https://docs.cloud.google.com/run/docs/tips/function-retries).
|
|
42
|
+
Retries are disabled because a permanent IAM/configuration failure could
|
|
43
|
+
otherwise keep an old over-budget event alive and detach again after recovery.
|
|
44
|
+
|
|
45
|
+
This is not a hard or real-time $100 cap. Budget data is estimated and delayed,
|
|
46
|
+
notifications arrive only several times per day, delivery can be duplicated or
|
|
47
|
+
out of order, and already-incurred but unreported charges remain payable.
|
|
48
|
+
|
|
49
|
+
## Human preflight
|
|
50
|
+
|
|
51
|
+
Stop if any item is uncertain:
|
|
52
|
+
|
|
53
|
+
1. Put the Cloud SQL backup and D1 redeploy material **outside
|
|
54
|
+
`n8-memory-palace`**. A backup in the project can become unavailable with the
|
|
55
|
+
project it is meant to restore. Google warns that disabling billing can
|
|
56
|
+
remove resources and recovery is not guaranteed.
|
|
57
|
+
2. In Cloud Billing Reports, verify the current-period **actual** cost is below
|
|
58
|
+
USD 100 immediately before arming. If it is already at or over USD 100,
|
|
59
|
+
connecting the budget is an intentional near-term outage.
|
|
60
|
+
3. Confirm the existing budget has `ownershipScope: BILLING_ACCOUNT`, is
|
|
61
|
+
recurring monthly, specified USD 100.00, filtered to only the numeric project
|
|
62
|
+
resource for `n8-memory-palace`, and has no service, label, subaccount,
|
|
63
|
+
resource-ancestor, custom-period, or credit filter. Its Pub/Sub notification
|
|
64
|
+
topic must be disconnected.
|
|
65
|
+
4. Confirm the project-to-billing-account link is unlocked and the account has
|
|
66
|
+
no active or pending commitment that prevents detachment.
|
|
67
|
+
5. Audit inherited/custom access on both the project and the named billing
|
|
68
|
+
account for `billing.budgets.update`, `billing.resourcebudgets.write`,
|
|
69
|
+
`billing.accounts.setIamPolicy`, Pub/Sub topic/subscription update or IAM,
|
|
70
|
+
Eventarc trigger update or IAM, topic subscription attachment/detachment,
|
|
71
|
+
`pubsub.topics.publish`, `run.routes.invoke`, billing association changes,
|
|
72
|
+
`iam.serviceAccounts.actAs`, token/key minting, and IAM-policy mutation.
|
|
73
|
+
Pub/Sub attributes are not signatures, and a principal that can retarget or
|
|
74
|
+
lower the exact budget can make Google's publisher emit an authentic outage
|
|
75
|
+
event. The budget must use `ownershipScope: BILLING_ACCOUNT`, making project
|
|
76
|
+
principals read-only even when they otherwise have resource-budget write
|
|
77
|
+
permission. For every direct project and billing-account binding, the script
|
|
78
|
+
reads
|
|
79
|
+
the role's current `includedPermissions`; only the active human may directly
|
|
80
|
+
control the budget or billing-account IAM (matched case-insensitively on the
|
|
81
|
+
email, since Google account emails are case-insensitive). It rejects other
|
|
82
|
+
dangerous project permissions except the exact armed runtime binding and
|
|
83
|
+
Google-managed, project-number-pinned service agents — the default Cloud
|
|
84
|
+
Build, cloudservices, and Compute Engine accounts, a curated set of
|
|
85
|
+
Google-owned service-agent domains, and Google's reserved `gcp-sa-*` per-
|
|
86
|
+
service agents. These identities are created and controlled by Google for
|
|
87
|
+
this project and are not freely mintable, so a default-posture project is
|
|
88
|
+
deployable; a service account minted in any other project (its own
|
|
89
|
+
`<project-id>` SA domain) stays audited. It separately requires
|
|
90
|
+
empty direct policies and no user-managed keys on all fresh D2 identities,
|
|
91
|
+
only Google's budget publisher on the topic, and only the trigger identity
|
|
92
|
+
on the service. The armed path must have one target-project Eventarc
|
|
93
|
+
subscription that exactly equals the function-owned Eventarc trigger's
|
|
94
|
+
output-only transport, a healthy exact destination, no direct subscription
|
|
95
|
+
policy, and no topic or subscription Single Message Transforms because those
|
|
96
|
+
can rewrite both payload data and attributes. Inherited roles, group
|
|
97
|
+
membership, and humans who can command
|
|
98
|
+
a trusted service agent are not fully resolved by those direct policies and
|
|
99
|
+
remain a manual destructive-admin boundary.
|
|
100
|
+
6. Use a human identity, not service-account credentials. Remove all effective
|
|
101
|
+
gcloud impersonation, access-token-file, and credential-file overrides; the
|
|
102
|
+
script refuses them before trusting `gcloud auth list`. The simplest
|
|
103
|
+
auditable gate is the sole direct Project Owner on `n8-memory-palace` plus
|
|
104
|
+
Billing Account Administrator on the named account. Any other direct
|
|
105
|
+
principal with a dangerous current permission makes the script stop. The
|
|
106
|
+
named `roles/billing.projectManager` binding must be absent before arming;
|
|
107
|
+
D2 reserves that exact role for its runtime identity. Recovery still needs
|
|
108
|
+
the permissions documented by Google: Project Billing Manager + Browser +
|
|
109
|
+
Service Usage Viewer on the project and Billing Account User + Viewer on the
|
|
110
|
+
account, obtained only after the breaker is torn down if Owner is not used.
|
|
111
|
+
|
|
112
|
+
The [billing-link permissions](https://docs.cloud.google.com/billing/docs/how-to/modify-project#required_permissions)
|
|
113
|
+
matter during recovery: Billing Account Costs Manager alone cannot reattach a
|
|
114
|
+
project.
|
|
115
|
+
|
|
116
|
+
Identify the exact existing budget; never select it by display name:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
export BILLING_ACCOUNT_ID=000000-000000-000000
|
|
120
|
+
gcloud billing budgets list --billing-account="${BILLING_ACCOUNT_ID}"
|
|
121
|
+
export BUDGET_RESOURCE="billingAccounts/${BILLING_ACCOUNT_ID}/budgets/BUDGET_ID"
|
|
122
|
+
gcloud billing budgets describe "${BUDGET_RESOURCE}"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Review, then deploy
|
|
126
|
+
|
|
127
|
+
The dry run is local-only: it validates its two arguments and prints the plan
|
|
128
|
+
without looking for or invoking `gcloud`.
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
cd infra/billing-breaker
|
|
132
|
+
export BILLING_ACCOUNT_ID=000000-000000-000000
|
|
133
|
+
export BUDGET_RESOURCE="billingAccounts/${BILLING_ACCOUNT_ID}/budgets/BUDGET_ID"
|
|
134
|
+
./deploy.sh --dry-run
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`--apply` is deliberately first-deploy-only. The function, Cloud Run service,
|
|
138
|
+
topic, every Eventarc trigger that references a D2 name, and all three service
|
|
139
|
+
accounts must be absent. A failed deployment or a rearm must go through the
|
|
140
|
+
cleanup procedure instead of silently adopting unknown identities, keys,
|
|
141
|
+
triggers, or queued messages.
|
|
142
|
+
|
|
143
|
+
After reviewing the plan and completing the preflight, a human can enter the
|
|
144
|
+
interactive gate:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
export CONFIRM_D2_PROJECT=n8-memory-palace
|
|
148
|
+
./deploy.sh --apply
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The script repeats the project, budget, account, destructive action, and
|
|
152
|
+
below-USD-100 assertion in a typed confirmation before its first mutation. It
|
|
153
|
+
rejects ambient credential overrides, resolves every direct role to its current
|
|
154
|
+
permissions, and uses successful list responses for its fresh-resource checks,
|
|
155
|
+
rather than treating any failed `describe` as absence. It then:
|
|
156
|
+
|
|
157
|
+
1. validates the enabled billing link and exact disconnected budget;
|
|
158
|
+
2. creates a fresh `billing-breaker` topic;
|
|
159
|
+
3. separates the runtime, Eventarc trigger, and build identities;
|
|
160
|
+
4. gives the build identity only Google's documented build roles and removes
|
|
161
|
+
all three immediately when the build completes;
|
|
162
|
+
5. deploys a private Python 3.12 second-generation function with retries off;
|
|
163
|
+
6. gives the trigger identity Invoker only on this Cloud Run service and gives
|
|
164
|
+
only Google's budget publisher direct topic Publisher access;
|
|
165
|
+
7. validates the complete inert topology;
|
|
166
|
+
8. requires the named Project Billing Manager role to be absent, then gives its
|
|
167
|
+
sole unconditional membership to the runtime identity on this project
|
|
168
|
+
immediately before connecting the budget last; and
|
|
169
|
+
9. asserts the complete armed state. Any later error enters a rollback that
|
|
170
|
+
revokes applicable runtime/build roles and reads the project policy back.
|
|
171
|
+
If absence cannot be proved, the script exits with a `CRITICAL` warning and
|
|
172
|
+
the operator must assume detach authority remains active.
|
|
173
|
+
|
|
174
|
+
The runtime receives no billing-account, Artifact Registry, build, Eventarc,
|
|
175
|
+
or project-wide Invoker role. The trigger cannot detach billing, and the build
|
|
176
|
+
identity has no standing project role after deployment. Google documents the
|
|
177
|
+
[custom build identity roles](https://docs.cloud.google.com/functions/docs/building#secure_your_build_with_a_custom_service_account).
|
|
178
|
+
|
|
179
|
+
## Synthetic-message drill
|
|
180
|
+
|
|
181
|
+
The live drill is intentionally below budget. Equality and overage are proven
|
|
182
|
+
with fake-client unit tests; publishing either value to the armed topic would
|
|
183
|
+
perform the outage this breaker exists to cause. Do not add a new Publisher
|
|
184
|
+
binding merely to run the drill; use an already-trusted Project Owner or skip
|
|
185
|
+
the live drill.
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
export BUDGET_ID="${BUDGET_RESOURCE##*/}"
|
|
189
|
+
if ! drill_message_id="$(
|
|
190
|
+
gcloud pubsub topics publish billing-breaker \
|
|
191
|
+
--project=n8-memory-palace \
|
|
192
|
+
--attribute="billingAccountId=${BILLING_ACCOUNT_ID},budgetId=${BUDGET_ID},schemaVersion=1.0" \
|
|
193
|
+
--message='{"budgetDisplayName":"D2 safe drill","costAmount":99.99,"costIntervalStart":"2026-07-01T00:00:00Z","budgetAmount":100.00,"budgetAmountType":"SPECIFIED_AMOUNT","currencyCode":"USD"}' \
|
|
194
|
+
--format='value(messageIds)'
|
|
195
|
+
)"; then
|
|
196
|
+
echo "publish failed; drill did not pass" >&2
|
|
197
|
+
exit 1
|
|
198
|
+
fi
|
|
199
|
+
if [[ -z "${drill_message_id}" || "${drill_message_id}" == *$'\n'* ]]; then
|
|
200
|
+
echo "publish did not return exactly one message ID" >&2
|
|
201
|
+
exit 1
|
|
202
|
+
fi
|
|
203
|
+
log_filter="resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"billing-breaker\" AND jsonPayload.component=\"billing-breaker\" AND jsonPayload.action=\"below_budget\" AND jsonPayload.message_id=\"${drill_message_id}\""
|
|
204
|
+
if ! drill_log="$(
|
|
205
|
+
gcloud logging read "${log_filter}" \
|
|
206
|
+
--project=n8-memory-palace --limit=1 --format=json
|
|
207
|
+
)"; then
|
|
208
|
+
echo "log lookup failed; drill did not pass" >&2
|
|
209
|
+
exit 1
|
|
210
|
+
fi
|
|
211
|
+
DRILL_LOG="${drill_log}" python3 - <<'PY'
|
|
212
|
+
import json
|
|
213
|
+
import os
|
|
214
|
+
|
|
215
|
+
if not json.loads(os.environ["DRILL_LOG"]):
|
|
216
|
+
raise SystemExit("no below_budget decision matched the published message ID")
|
|
217
|
+
PY
|
|
218
|
+
if ! billing_json="$(
|
|
219
|
+
gcloud billing projects describe n8-memory-palace --format=json
|
|
220
|
+
)"; then
|
|
221
|
+
echo "billing lookup failed; drill did not pass" >&2
|
|
222
|
+
exit 1
|
|
223
|
+
fi
|
|
224
|
+
if ! printf '%s' "${billing_json}" | python3 deployment_checks.py billing \
|
|
225
|
+
--billing-account-id "${BILLING_ACCOUNT_ID}"; then
|
|
226
|
+
echo "billing validator failed; drill did not pass" >&2
|
|
227
|
+
exit 1
|
|
228
|
+
fi
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Pass means the exact published message ID has a `below_budget` JSON decision
|
|
232
|
+
and the machine-checked billing link remains enabled on the expected account.
|
|
233
|
+
Eventarc can take up to two minutes to propagate; an empty log result is a
|
|
234
|
+
failed/incomplete drill, so wait and repeat only the log and billing checks.
|
|
235
|
+
Do not republish merely because the log is delayed. Do not publish a message at
|
|
236
|
+
or above 100 to the live topic as a routine test. At-least-once delivery can
|
|
237
|
+
duplicate even a successful publish.
|
|
238
|
+
|
|
239
|
+
## Recovery and billing reattach
|
|
240
|
+
|
|
241
|
+
The order is a safety invariant. Do not restore the runtime role while an old
|
|
242
|
+
trigger, subscription, or topic exists, and do not reattach billing until the
|
|
243
|
+
breaker has neither authority nor a queue.
|
|
244
|
+
|
|
245
|
+
Set the exact resources first and run every block in the same Bash session.
|
|
246
|
+
Every absence proof below comes from a successful list request plus the same
|
|
247
|
+
exact-value validator used by deployment. Every validator is explicitly
|
|
248
|
+
guarded; permission, network, and parsing errors stop recovery.
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
: "${BUDGET_RESOURCE:?set the exact existing budget resource}"
|
|
252
|
+
: "${BILLING_ACCOUNT_ID:?set the billing account ID}"
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
1. In Cloud Billing **Budgets & alerts**, edit `BUDGET_RESOURCE`, disconnect
|
|
256
|
+
its Pub/Sub topic, save, and machine-check that the field is empty:
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
if ! budget_topic="$(
|
|
260
|
+
gcloud billing budgets describe "${BUDGET_RESOURCE}" \
|
|
261
|
+
--format='value(notificationsRule.pubsubTopic)'
|
|
262
|
+
)"; then
|
|
263
|
+
echo "budget lookup failed; disconnection is not proved" >&2
|
|
264
|
+
exit 1
|
|
265
|
+
fi
|
|
266
|
+
if [[ -n "${budget_topic}" ]]; then
|
|
267
|
+
printf 'budget is still connected to %s\n' "${budget_topic}" >&2
|
|
268
|
+
exit 1
|
|
269
|
+
fi
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
2. Revoke detach authority and verify the named role is completely absent:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
gcloud projects remove-iam-policy-binding n8-memory-palace \
|
|
276
|
+
--member="serviceAccount:billing-breaker-runtime@n8-memory-palace.iam.gserviceaccount.com" \
|
|
277
|
+
--role=roles/billing.projectManager || true
|
|
278
|
+
if ! project_policy="$(
|
|
279
|
+
gcloud projects get-iam-policy n8-memory-palace --format=json
|
|
280
|
+
)"; then
|
|
281
|
+
echo "project policy lookup failed; detach-role removal is not proved" >&2
|
|
282
|
+
exit 1
|
|
283
|
+
fi
|
|
284
|
+
if ! printf '%s' "${project_policy}" | \
|
|
285
|
+
python3 deployment_checks.py exact-project-role \
|
|
286
|
+
--role=roles/billing.projectManager --state=absent; then
|
|
287
|
+
echo "detach-role validator failed; removal is not proved" >&2
|
|
288
|
+
exit 1
|
|
289
|
+
fi
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The removal command is allowed to report an already-absent binding, but the
|
|
293
|
+
policy read and exact empty-role validator must both succeed.
|
|
294
|
+
|
|
295
|
+
3. Capture every subscription attached to the dedicated topic from the
|
|
296
|
+
topic-side Pub/Sub list, including subscriptions owned by another project,
|
|
297
|
+
and independently capture every matching Eventarc trigger. Then delete the
|
|
298
|
+
function, triggers, Cloud Run service, and all captured subscriptions. This
|
|
299
|
+
sweep includes an orphan left by a partial Eventarc teardown. If the human
|
|
300
|
+
cannot delete a cross-project subscription, detach it using topic authority;
|
|
301
|
+
detached messages are deleted and the subscription cannot be reattached.
|
|
302
|
+
The current `gcloud functions delete` command does not take `--gen2`.
|
|
303
|
+
Deleting a Pub/Sub topic alone does not delete its subscriptions or retained
|
|
304
|
+
backlog, so topic-side absence is proved before topic deletion. Google
|
|
305
|
+
documents [topic deletion](https://docs.cloud.google.com/pubsub/docs/delete-topic)
|
|
306
|
+
and [subscription detachment](https://docs.cloud.google.com/pubsub/docs/detach-subscriptions).
|
|
307
|
+
|
|
308
|
+
```sh
|
|
309
|
+
if ! subscription_before_json="$(
|
|
310
|
+
gcloud pubsub topics list-subscriptions billing-breaker \
|
|
311
|
+
--project=n8-memory-palace --format=json
|
|
312
|
+
)"; then
|
|
313
|
+
echo "subscription lookup failed; topic-wide queue capture is not proved" >&2
|
|
314
|
+
exit 1
|
|
315
|
+
fi
|
|
316
|
+
if ! subscription_rows="$(
|
|
317
|
+
printf '%s' "${subscription_before_json}" | \
|
|
318
|
+
python3 deployment_checks.py topic-subscriptions --state=list
|
|
319
|
+
)"; then
|
|
320
|
+
echo "topic-wide queue parsing failed; queue identity is not proved" >&2
|
|
321
|
+
exit 1
|
|
322
|
+
fi
|
|
323
|
+
if ! eventarc_before_json="$(
|
|
324
|
+
gcloud eventarc triggers list \
|
|
325
|
+
--location=us-central1 --project=n8-memory-palace --format=json
|
|
326
|
+
)"; then
|
|
327
|
+
echo "Eventarc lookup failed; transport capture is not proved" >&2
|
|
328
|
+
exit 1
|
|
329
|
+
fi
|
|
330
|
+
if ! trigger_rows="$(EVENTARC_JSON="${eventarc_before_json}" python3 - <<'PY'
|
|
331
|
+
import json
|
|
332
|
+
import os
|
|
333
|
+
|
|
334
|
+
topic = "projects/n8-memory-palace/topics/billing-breaker"
|
|
335
|
+
triggers = json.loads(os.environ["EVENTARC_JSON"])
|
|
336
|
+
if not isinstance(triggers, list):
|
|
337
|
+
raise SystemExit("Eventarc list response is not an array")
|
|
338
|
+
for trigger in triggers:
|
|
339
|
+
pubsub = trigger.get("transport", {}).get("pubsub", {})
|
|
340
|
+
if pubsub.get("topic") != topic:
|
|
341
|
+
continue
|
|
342
|
+
name = trigger.get("name")
|
|
343
|
+
if not isinstance(name, str) or not name.startswith(
|
|
344
|
+
"projects/n8-memory-palace/locations/us-central1/triggers/"
|
|
345
|
+
):
|
|
346
|
+
raise SystemExit("matching Eventarc trigger name is malformed")
|
|
347
|
+
print(name)
|
|
348
|
+
PY
|
|
349
|
+
)"; then
|
|
350
|
+
echo "Eventarc transport parsing failed; trigger identity is not proved" >&2
|
|
351
|
+
exit 1
|
|
352
|
+
fi
|
|
353
|
+
gcloud functions delete billing-breaker \
|
|
354
|
+
--region=us-central1 --project=n8-memory-palace --quiet || true
|
|
355
|
+
while IFS= read -r trigger_resource; do
|
|
356
|
+
[[ -z "${trigger_resource}" ]] && continue
|
|
357
|
+
gcloud eventarc triggers delete "${trigger_resource##*/}" \
|
|
358
|
+
--location=us-central1 --project=n8-memory-palace --quiet || true
|
|
359
|
+
done <<<"${trigger_rows}"
|
|
360
|
+
gcloud run services delete billing-breaker \
|
|
361
|
+
--region=us-central1 --project=n8-memory-palace --quiet || true
|
|
362
|
+
|
|
363
|
+
if ! function_list="$(
|
|
364
|
+
gcloud functions list --v2 --regions=us-central1 \
|
|
365
|
+
--project=n8-memory-palace --format=json
|
|
366
|
+
)"; then
|
|
367
|
+
echo "function list failed; deletion is not proved" >&2
|
|
368
|
+
exit 1
|
|
369
|
+
fi
|
|
370
|
+
if ! printf '%s' "${function_list}" | python3 deployment_checks.py absent \
|
|
371
|
+
--field=name \
|
|
372
|
+
--value=projects/n8-memory-palace/locations/us-central1/functions/billing-breaker; then
|
|
373
|
+
echo "function validator failed; deletion is not proved" >&2
|
|
374
|
+
exit 1
|
|
375
|
+
fi
|
|
376
|
+
if ! run_service_list="$(
|
|
377
|
+
gcloud run services list --region=us-central1 \
|
|
378
|
+
--project=n8-memory-palace --format=json
|
|
379
|
+
)"; then
|
|
380
|
+
echo "Cloud Run list failed; deletion is not proved" >&2
|
|
381
|
+
exit 1
|
|
382
|
+
fi
|
|
383
|
+
if ! printf '%s' "${run_service_list}" | python3 deployment_checks.py absent \
|
|
384
|
+
--field=metadata.name --value=billing-breaker; then
|
|
385
|
+
echo "Cloud Run validator failed; deletion is not proved" >&2
|
|
386
|
+
exit 1
|
|
387
|
+
fi
|
|
388
|
+
if ! eventarc_after_json="$(
|
|
389
|
+
gcloud eventarc triggers list \
|
|
390
|
+
--location=us-central1 --project=n8-memory-palace --format=json
|
|
391
|
+
)"; then
|
|
392
|
+
echo "Eventarc lookup failed; trigger removal is not proved" >&2
|
|
393
|
+
exit 1
|
|
394
|
+
fi
|
|
395
|
+
if ! printf '%s' "${eventarc_after_json}" | \
|
|
396
|
+
python3 deployment_checks.py eventarc-isolation \
|
|
397
|
+
--topic-resource=projects/n8-memory-palace/topics/billing-breaker \
|
|
398
|
+
--function-resource=projects/n8-memory-palace/locations/us-central1/functions/billing-breaker \
|
|
399
|
+
--run-service-name=billing-breaker \
|
|
400
|
+
--run-service-resource=projects/n8-memory-palace/locations/us-central1/services/billing-breaker; then
|
|
401
|
+
echo "Eventarc validator failed; trigger removal is not proved" >&2
|
|
402
|
+
exit 1
|
|
403
|
+
fi
|
|
404
|
+
while IFS= read -r subscription_resource; do
|
|
405
|
+
[[ -z "${subscription_resource}" ]] && continue
|
|
406
|
+
if ! gcloud pubsub subscriptions delete "${subscription_resource}" --quiet; then
|
|
407
|
+
gcloud pubsub topics detach-subscription "${subscription_resource}" \
|
|
408
|
+
--quiet || true
|
|
409
|
+
fi
|
|
410
|
+
done <<<"${subscription_rows}"
|
|
411
|
+
if ! subscription_after_json="$(
|
|
412
|
+
gcloud pubsub topics list-subscriptions billing-breaker \
|
|
413
|
+
--project=n8-memory-palace --format=json
|
|
414
|
+
)"; then
|
|
415
|
+
echo "topic-side subscription list failed; queue removal is not proved" >&2
|
|
416
|
+
exit 1
|
|
417
|
+
fi
|
|
418
|
+
if ! printf '%s' "${subscription_after_json}" | \
|
|
419
|
+
python3 deployment_checks.py topic-subscriptions --state=empty; then
|
|
420
|
+
echo "a topic-attached subscription remains; queue removal is not proved" >&2
|
|
421
|
+
exit 1
|
|
422
|
+
fi
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Stop if any successful list still contains the function, service, matching
|
|
426
|
+
trigger, or topic-attached subscription. Ignored delete/detach status supports
|
|
427
|
+
a partially completed cleanup; it never substitutes for the later list proof.
|
|
428
|
+
|
|
429
|
+
4. Delete the dedicated topic to destroy the old delivery path. Remove any
|
|
430
|
+
temporary build roles left by a failed apply, verify no D2 project binding
|
|
431
|
+
remains, then delete all three identities. Successful lists must prove every
|
|
432
|
+
exact resource absent:
|
|
433
|
+
|
|
434
|
+
```sh
|
|
435
|
+
gcloud pubsub topics delete billing-breaker \
|
|
436
|
+
--project=n8-memory-palace --quiet || true
|
|
437
|
+
for role in roles/artifactregistry.writer roles/logging.logWriter roles/storage.objectViewer; do
|
|
438
|
+
gcloud projects remove-iam-policy-binding n8-memory-palace \
|
|
439
|
+
--member="serviceAccount:billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com" \
|
|
440
|
+
--role="${role}" || true
|
|
441
|
+
done
|
|
442
|
+
if ! project_policy="$(
|
|
443
|
+
gcloud projects get-iam-policy n8-memory-palace --format=json
|
|
444
|
+
)"; then
|
|
445
|
+
echo "project policy lookup failed; D2 IAM cleanup is not proved" >&2
|
|
446
|
+
exit 1
|
|
447
|
+
fi
|
|
448
|
+
if ! printf '%s' "${project_policy}" | \
|
|
449
|
+
python3 deployment_checks.py exact-project-role \
|
|
450
|
+
--role=roles/billing.projectManager --state=absent; then
|
|
451
|
+
echo "detach-role validator failed; D2 IAM cleanup is not proved" >&2
|
|
452
|
+
exit 1
|
|
453
|
+
fi
|
|
454
|
+
for role in roles/artifactregistry.writer roles/logging.logWriter roles/storage.objectViewer; do
|
|
455
|
+
if ! printf '%s' "${project_policy}" | \
|
|
456
|
+
python3 deployment_checks.py project-role \
|
|
457
|
+
--role="${role}" \
|
|
458
|
+
--member="serviceAccount:billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com" \
|
|
459
|
+
--state=absent; then
|
|
460
|
+
echo "build-role validator failed; D2 IAM cleanup is not proved" >&2
|
|
461
|
+
exit 1
|
|
462
|
+
fi
|
|
463
|
+
done
|
|
464
|
+
gcloud iam service-accounts delete \
|
|
465
|
+
billing-breaker-runtime@n8-memory-palace.iam.gserviceaccount.com \
|
|
466
|
+
--project=n8-memory-palace --quiet || true
|
|
467
|
+
gcloud iam service-accounts delete \
|
|
468
|
+
billing-breaker-trigger@n8-memory-palace.iam.gserviceaccount.com \
|
|
469
|
+
--project=n8-memory-palace --quiet || true
|
|
470
|
+
gcloud iam service-accounts delete \
|
|
471
|
+
billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com \
|
|
472
|
+
--project=n8-memory-palace --quiet || true
|
|
473
|
+
if ! topic_list="$(
|
|
474
|
+
gcloud pubsub topics list --project=n8-memory-palace --format=json
|
|
475
|
+
)"; then
|
|
476
|
+
echo "topic list failed; deletion is not proved" >&2
|
|
477
|
+
exit 1
|
|
478
|
+
fi
|
|
479
|
+
if ! printf '%s' "${topic_list}" | python3 deployment_checks.py absent \
|
|
480
|
+
--field=name \
|
|
481
|
+
--value=projects/n8-memory-palace/topics/billing-breaker; then
|
|
482
|
+
echo "topic validator failed; deletion is not proved" >&2
|
|
483
|
+
exit 1
|
|
484
|
+
fi
|
|
485
|
+
if ! service_account_list="$(
|
|
486
|
+
gcloud iam service-accounts list \
|
|
487
|
+
--project=n8-memory-palace --format=json
|
|
488
|
+
)"; then
|
|
489
|
+
echo "service-account list failed; deletion is not proved" >&2
|
|
490
|
+
exit 1
|
|
491
|
+
fi
|
|
492
|
+
for service_account in billing-breaker-runtime billing-breaker-trigger billing-breaker-build; do
|
|
493
|
+
if ! printf '%s' "${service_account_list}" | \
|
|
494
|
+
python3 deployment_checks.py absent \
|
|
495
|
+
--field=email \
|
|
496
|
+
--value="${service_account}@n8-memory-palace.iam.gserviceaccount.com"; then
|
|
497
|
+
echo "service-account validator failed; deletion is not proved" >&2
|
|
498
|
+
exit 1
|
|
499
|
+
fi
|
|
500
|
+
done
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Stop before identity deletion if any exact IAM absence check fails.
|
|
504
|
+
|
|
505
|
+
5. Only after steps 1–4 pass, reattach the known open billing account and
|
|
506
|
+
verify both fields:
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
export BILLING_ACCOUNT_ID=000000-000000-000000
|
|
510
|
+
gcloud billing projects link n8-memory-palace \
|
|
511
|
+
--billing-account="${BILLING_ACCOUNT_ID}"
|
|
512
|
+
if ! billing_json="$(
|
|
513
|
+
gcloud billing projects describe n8-memory-palace --format=json
|
|
514
|
+
)"; then
|
|
515
|
+
echo "billing lookup failed; reattachment is not proved" >&2
|
|
516
|
+
exit 1
|
|
517
|
+
fi
|
|
518
|
+
if ! printf '%s' "${billing_json}" | python3 deployment_checks.py billing \
|
|
519
|
+
--billing-account-id="${BILLING_ACCOUNT_ID}"; then
|
|
520
|
+
echo "billing validator failed; reattachment is not proved" >&2
|
|
521
|
+
exit 1
|
|
522
|
+
fi
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The validator requires `billingEnabled: true` and exactly
|
|
526
|
+
`billingAccounts/${BILLING_ACCOUNT_ID}`.
|
|
527
|
+
|
|
528
|
+
6. Inspect Cloud SQL and Cloud Run, restore/redeploy D1 where necessary, and
|
|
529
|
+
repeat its cloud round trip. Reattaching billing does not guarantee every
|
|
530
|
+
resource resumes automatically.
|
|
531
|
+
7. Rearm only when the current budget period is below its limit (or a new
|
|
532
|
+
period has begun), every old D2 resource is absent, and recovery is verified.
|
|
533
|
+
Run the full preflight and `deploy.sh --apply` again; it will create fresh
|
|
534
|
+
identities and a fresh queue.
|
|
535
|
+
|
|
536
|
+
If `deploy.sh --apply` fails, its trap attempts to revoke applicable detach and
|
|
537
|
+
temporary build roles and then proves their absence from a fresh project-policy
|
|
538
|
+
read. A `CRITICAL` message or exit 99 means the proof failed: assume detach
|
|
539
|
+
authority remains active and disconnect the budget immediately. The script
|
|
540
|
+
deliberately leaves created resources for inspection. Verify billing is still
|
|
541
|
+
enabled, disconnect the budget if it was wired, then perform steps 1–4 before
|
|
542
|
+
any retry. Never work around the script by reusing those resources.
|
|
543
|
+
|
|
544
|
+
Revisit this package when Google Spend Caps becomes generally available and
|
|
545
|
+
covers the project's complete Cloud Run plus Cloud SQL spend surface.
|