Skip to content

provision infrastructure at deploy time, not on application start - #339

Merged
adenhertog merged 2 commits into
masterfrom
issue-333-deploy-time-provisioning
Oct 6, 2026
Merged

adenhertog merged 2 commits into
masterfrom
issue-333-deploy-time-provisioning

Conversation

@adenhertog

@adenhertog adenhertog commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #333

Summary

A bus no longer creates anything at initialize(): bus provision <module> (bus-cli) / bus.provision() create queues, topics, subscriptions, exchanges, tables and indexes at deploy time, initialize() verifies they exist with read-only calls, and withAutoProvision() keeps startup provisioning for local dev and tests.

Background

Every adapter provisioned in initialize(), so every service needed permission to create and change its infrastructure, and teams using CDK/Terraform had no clean way to own it. The maintainer decided: refuse by default, document runtime permissions per adapter, and (groomed) verify on startup, CLI takes a configuration or factory, send-only buses provision topics for their message types, human-readable plan with --json.

Problem

Verified against master fb4ede6 before changing anything:

  • bus-sqs (confirmed): initialize() created the DLQ, service queue, topics and subscriptions, always wrote the queue policy, and synced attributes; publish also ran CreateTopic on first send. autoProvision: false switched these to GetQueueUrl/GetTopicAttributes/ListSubscriptionsByTopic checks (topics only for handled messages).
  • bus-rabbitmq (confirmed): initialize() declared exchanges/queues/bindings, every first publish declared its exchange, retry queues were declared lazily in returnMessage, and channel recovery re-declared everything.
  • bus-postgres (confirmed): initialize() created the schema and the Delayed / deferred message delivery #258 outgoing_messages table + index; initializeWorkflow() created each workflow table and its indexes.
  • bus-mongodb (confirmed): initialize() created the outgoingmessages index; initializeWorkflow() created each collection and its indexes.

Approach

  • bus-core: provisioning/ adds ProvisioningPlan (adapter, resources, runtimePermissions), ProvisionOptions and ResourcesNotProvisioned (names each missing resource; help points at bus provision, withAutoProvision() and withResourceVerification(false)). Transport.provision?() and Persistence.provision?() are new; TransportInitializationOptions gains messageNames, verifyResources, autoProvision; Persistence.initialize(options) gets { workflows, verifyResources } and initializeWorkflow is removed (WorkflowRegistry now just records getPersistedWorkflows()). BusInstance.provision({ dryRun }) registers workflows, provisions persistence then transport (connecting once), never initializes. BusConfiguration.withAutoProvision() / withResourceVerification(verify).
  • bus-sqs: autoProvision option removed. provision() creates the bus' topics (every message name), DLQ, queue, subscriptions (custom-handler topics are only subscribed to, never created), sets the queue policy (only here) and syncs attributes; dry run makes no calls; plan includes an IAM runtime policy (sns:Publish on its topics, or every topic for a scheduler). initialize() checks what the bus receives through (queues, subscriptions, at most 10 calls at once; an auth error on an external topic is a warning); opt-in verifyQueuePolicy checks the queue policy. publish only creates topics when auto-provisioned, and not again for provisioned ones.
  • bus-rabbitmq: provision() declares the whole topology, including all 33 retry bucket queues up front. Queues are declared from one definition (durable + exact x- arguments) that the plan lists as is, so tooling declaring from the plan gets identical queues. initialize() checks what it receives through with passive declares on throwaway channels. Without auto-provisioning, first publish to an exchange / first use of a retry queue checks it exists (so messages aren't silently dropped) instead of declaring; recovery only re-declares when auto-provisioned. Plan includes configure: ^$, write on amq.default + message exchanges (.* for a scheduler), read on the service, legacy retry and dead letter queues and ^<queue>-retry-\d+ms$ (what RabbitMQ 4.3.1+ needs for the passive checks; the <queue>/<queue>-retry exchanges share those names). A refused check throws RabbitMqResourceCheckRefused; withResourceVerification(false) skips the startup and first-use checks alike.
  • bus-postgres / bus-mongodb: table/collection/index creation moved into provision() (no change to the SQL/index logic); initialize() verifies (Postgres: pg_namespace + one to_regclass query, legacy index names count; Mongo: listCollections/listIndexes). Plans include SQL grants / role privileges.
  • bus-cli: bus provision <module> [--export name] [--dry-run] [--permissions] [--json]. Loads with native import() (JS, or TS Node can type-strip), accepts a BusConfiguration or sync/async factory, builds, provision()s, always disposes. --json emits a versioned ProvisionReport (exported type, documented).
  • bus-test: the shared suites now bus.provision() then initialize() without provisioning, so every adapter is tested provision-then-run with verification on. Every other test bus on real infra uses .withAutoProvision(); adapters have new tests for missing resources, dry runs and plans.
  • Docs: new /guide/provisioning page (sidebar, snippets/provisioning.ts, CLI flags, JSON format); each adapter page has a Provisioning section with deploy-time and runtime permissions (incl. describe permissions) and the SQS queue policy; quick starts opt in with withAutoProvision(); custom adapter pages/snippets updated. Minor changeset with Breaking: note and MIGRATING.md entries.

Decisions for review

  1. Names: withAutoProvision() and withResourceVerification(verify: boolean) (default true); error ResourcesNotProvisioned.
  2. Topics for every message type on every bus, not only send-only ones (decision 5 extended): any bus provisions a topic/exchange for each handled message and each withMessageTypes() entry, excluding workflow state (registered state names, or classes extending WorkflowState, matched by base class name too for another copy of bus-core). Startup checks only what the bus receives through (queues, handled topics/exchanges, subscriptions); topics a bus only sends to aren't checked, and a send-only bus checks nothing on its transport.
  3. Persistence.initializeWorkflow removed in favour of initialize({ workflows, verifyResources }) + provision({ workflows, dryRun }) (breaking for custom persistences; no shim).
  4. RabbitMQ retry queues: all 33 power-of-two buckets (1 ms…2³² ms) are provisioned up front, since delays come from an arbitrary policy and a send to a missing queue is silently dropped. The alternative was keeping lazy declares, which needs runtime configure.
  5. Schedulers get a wildcard publish grant (sendsAnyMessage on TransportProvisionOptions); on SQS it's narrowed to resolveTopicName's fixed prefix when probing shows the resolver is prefix + default name, otherwise *. Buses sharing a store that aren't schedulers only get their own topics; the docs say to grant the others' or use a scheduler. A send-only bus with no message types logs a warning when provisioned.
  6. Runtime checks on send: without auto-provisioning, RabbitMQ checks an exchange/retry queue exists once (passive declare on a throwaway channel) and throws ResourcesNotProvisioned; SQS just publishes and lets SNS reject a missing topic. With withAutoProvision() both still create missing topics/exchanges lazily (today's dev behaviour, e.g. send-only buses without message types).
  7. Not verified: RabbitMQ bindings (needs the management API), SQS queue attributes (the policy only with verifyQueuePolicy). Indexes are verified for Postgres (legacy-named indexes count only with matching keys) and Mongo.
  8. RabbitMQ passive declares need no permission before 4.3.1 (checked on the 3.13 test broker); from 4.3.1 they need some permission, so the generated read covers them by default and the docs say it can be narrowed to the service queue on older brokers. A 403 throws a new bus-rabbitmq error, RabbitMqResourceCheckRefused (not ResourcesNotProvisioned, since the resource may exist).
  9. Dry run doesn't connect and doesn't diff against what exists; it lists everything provision ensures. It still runs the module's top-level code (documented).
  10. Runtime permissions formats: iam-policy, rabbitmq-permissions, sql (with a <runtime_role> placeholder), mongodb-privileges. Replies to other services' SQS queues aren't in the generated IAM policy (unknown targets); documented.
  11. Reply-only handlers (item 6): deferred to mark reply-only handlers so provisioning skips their subscriptions #340 (registration API + subscription filter in every transport's plan). Today replies are still subscribed like any handled message.

Review fixes

RabbitMQ plan queues carry their exact arguments (reproduced: without x-dead-letter-routing-key: '' a retry from a plan-declared queue is dropped; new integration test declares from the plan, round-trips a retry and then auto-provisions against the same queues), narrower RabbitMQ permissions, scheduler grants, external SQS topics only subscribed, bounded SQS checks and opt-in policy check, registerWorkflows caches its promise so a failure is rethrown not skipped, CLI reports dispose failures as a warning without hiding the provisioning error, Postgres verification compares a legacy index's keys, MIGRATING note for send-only withMessageTypes(). Re-review: RabbitMQ 4.3.1 check permissions and RabbitMqResourceCheckRefused, first-use checks honour withResourceVerification(false), SQS scheduler wildcard narrowed to a topic prefix, verifyQueuePolicy docs say it only checks a policy exists. Rebased onto master 3e30f9b (#338).

Rebase

Rebased onto master b72419b (#335 transactional outbox, after #338): #335's initializeWorkflow uses moved to initialize({ workflows }) (the in-memory workflow buckets are still created there), the outgoing table's destination column is created by provision(), outboxTests provisions then initializes, postgres-transaction.integration.ts uses withAutoProvision(), and the Postgres grants cover the outbox's transactions (they run the same statements; tables handlers write with postgresTransaction(ctx) are theirs to grant).

🤖 Generated with Claude Code

@adenhertog
adenhertog force-pushed the issue-333-deploy-time-provisioning branch from 9f801d1 to 626f5ab Compare October 4, 2026 19:45
adenhertog and others added 2 commits October 6, 2026 20:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

provision infrastructure at deploy time, not on application start

1 participant