227 lines
11 KiB
Markdown
227 lines
11 KiB
Markdown
# dev-deploy
|
|
|
|
A Saltcorn plugin that migrates **metadata** changes across Dev, Test, and
|
|
Prod environments.
|
|
|
|
dev-deploy treats your schema as something you promote, not something you
|
|
hand-copy. As you edit metadata in one Saltcorn instance, the plugin records
|
|
each change as an immutable entry in an append-only **ops journal**. You then
|
|
promote those ops to a peer instance (Test, Prod, or another tenant) over an
|
|
HMAC-authenticated HTTP endpoint. Each tracked entity carries a **stable UUID**
|
|
so the same table/view/page resolves to the same identity on every instance,
|
|
even though local integer ids differ. When a peer and the local instance have
|
|
both touched the same entity since the last sync, the incoming op is held as a
|
|
**conflict** for you to resolve (theirs / mine / per-field merge) rather than
|
|
silently overwriting.
|
|
|
|
## What it migrates
|
|
|
|
The plugin tracks these Saltcorn metadata entity kinds (see
|
|
`lib/constants.js`, `ENTITY_KINDS`):
|
|
|
|
- `table`, `field`, `constraint`
|
|
- `view`, `page`, `page_group`, `page_group_member`
|
|
- `trigger`, `workflow_step`
|
|
- `role`
|
|
- `library`
|
|
- `tag`
|
|
- `file`
|
|
|
|
Children point at their parent via `parent_uuid`, so a field's identity is
|
|
anchored to its table, and so on.
|
|
|
|
## What it does NOT touch
|
|
|
|
**User-table row data is left alone by default.** A table's rows belong to the
|
|
local environment and deploys never touch them unless you explicitly change the
|
|
table's `data_mode` away from the default `user`:
|
|
|
|
- `user` (default) -- rows belong to the local environment; deploys never
|
|
touch them. The only safe choice for end-user-entered data.
|
|
- `starter` -- rows ship to the target on first install, then the target owns
|
|
them; later changes on the source side do not propagate.
|
|
- `managed` -- rows always sync from source; the source is canonical and the
|
|
target's edits get overwritten or surface as conflicts.
|
|
|
|
Switching a table to `managed` or `starter` rewrites that table's schema (it
|
|
adds a hidden `_dd_row_uuid` column for stable cross-environment row identity)
|
|
and ships the current rows. The Saltcorn `users` table is **locked** to
|
|
`data_mode=user` and cannot be changed. See
|
|
[docs/managed-rows.md](docs/managed-rows.md).
|
|
|
|
## How it works (at a glance)
|
|
|
|
1. **Bootstrap.** On first load the plugin creates its six `_dd_*` tables,
|
|
assigns this instance an `env_id` (a UUID), and backfills stable UUIDs for
|
|
all existing metadata entities. Two environments that bootstrap from the
|
|
same metadata population assign identical UUIDs to entities with the same
|
|
`(kind, name)`, because UUIDs are derived from a frozen namespace
|
|
(`ID_NAMESPACE` in `lib/constants.js`).
|
|
2. **Capture.** Saltcorn metadata mutations are wrapped so each change appends
|
|
one op to `_dd_ops` (op types like `update_*`, `set_table_mode`,
|
|
`insert_row`, etc.). History is append-only.
|
|
3. **Promote / pull.** From the admin UI you generate a **plan** (ops not yet
|
|
sent to a peer, tracked by a per-peer outbound **anchor**) and **promote**
|
|
them, or **pull** a peer's ops inbound. Transport is HMAC-signed.
|
|
4. **Apply.** The receiver applies each op, resolving UUIDs to its own local
|
|
ids. Concurrent edits are detected and parked as conflicts.
|
|
5. **Resolve.** Pending conflicts are resolved from the admin UI as
|
|
theirs / mine / per-field merge.
|
|
6. **Revert.** Reverting an op appends a compensating op rather than rewriting
|
|
history.
|
|
|
|
## Features and status
|
|
|
|
- **Append-only ops journal** with stable per-entity UUIDs -- done.
|
|
- **HMAC-SHA256 authenticated peer transport** (timestamp skew window, nonce,
|
|
host-bound canonical) -- done.
|
|
- **Plan / promote / pull** with per-peer, per-direction anchors -- done.
|
|
- **Concurrent-edit conflict detection** with theirs / mine / per-field
|
|
merge -- done.
|
|
- **Revert** via compensating ops -- done.
|
|
- **Per-table data modes** (`user` / `starter` / `managed`) with hidden
|
|
`_dd_row_uuid` row identity -- done.
|
|
- **File-entity sync** (files identified by a stable id derived from disk
|
|
location) -- done.
|
|
- **Multi-tenant**: each tenant schema is its own dev-deploy environment with
|
|
its own `env_id` and schema-qualified `_dd_*` tables; HMAC canonical is
|
|
host-bound so a request signed for one tenant is rejected against another on
|
|
the same server -- done. See [docs/multitenancy.md](docs/multitenancy.md).
|
|
- **Plugin-version drift warnings**: promote/pull compare the local and peer
|
|
plugin lists and surface mismatches as warnings (non-blocking) -- done.
|
|
- **Engines**: Node `>=20`. Saltcorn plugin API version 1.
|
|
|
|
## The six `_dd_*` tables
|
|
|
|
All six are created idempotently in `onLoad` (`lib/schema.js`) and are
|
|
schema-qualified per tenant. Strings, JSON payloads, and ISO 8601 timestamps
|
|
are `TEXT`; booleans and surrogate ids are `INTEGER`.
|
|
|
|
| Table | Purpose | Key columns |
|
|
| --- | --- | --- |
|
|
| `_dd_env` | This instance's dev-deploy identity (singleton row). | `env_id` (PK), `env_label`, `on_destructive_op` (default `confirm`), `require_tls`, `created_at`, `bootstrapped_at` |
|
|
| `_dd_peers` | Configured peers and their encrypted shared secret. | `peer_id` (PK), `env_id` (UNIQUE), `label`, `base_url`, `peer_secret_ciphertext`, `peer_secret_iv`, `peer_secret_tag`, `require_tls`, `created_at`, `last_seen_at` |
|
|
| `_dd_entity_ids` | Maps stable UUID to the local entity. | `uuid` (PK), `kind`, `current_name`, `current_id`, `parent_uuid`, `created_at`, `UNIQUE(kind, current_id)` |
|
|
| `_dd_ops` | The append-only ops journal. | `op_id` (PK), `source_env_id`, `op_type`, `entity_kind`, `entity_uuid`, `payload`, `parent_op_id`, `correlation_id`, `schema_version`, `created_at`, `applied_at`, `status` (default `committed`), `conflict_with_op_id` |
|
|
| `_dd_anchors` | Per-peer, per-direction sync watermark. | `(peer_id, direction)` (PK), `last_op_id`, `updated_at` |
|
|
| `_dd_table_modes` | Per-table `data_mode` and ship state. | `table_uuid` (PK), `data_mode`, `updated_at`, `starter_shipped_at` |
|
|
|
|
## HTTP endpoints
|
|
|
|
### Admin UI (session, admin role `role_id === 1`)
|
|
|
|
| Method | Path | Purpose |
|
|
| --- | --- | --- |
|
|
| GET | `/admin/dev-deploy/` | Dashboard: env identity, op/entity counts, pending conflicts. |
|
|
| GET | `/admin/dev-deploy/ops` | Journal viewer (supports `?limit=`, `?since=op_id`; JSON via `Accept: application/json`). |
|
|
| POST | `/admin/dev-deploy/revert` | Append a compensating op to revert an op. |
|
|
| GET | `/admin/dev-deploy/peers` | List peers; show this instance's `env_id`; add-peer form. |
|
|
| POST | `/admin/dev-deploy/peers/add` | Pair a peer (generates or accepts a 64-hex shared secret). |
|
|
| POST | `/admin/dev-deploy/peers/rotate` | Rotate a peer's shared secret. |
|
|
| POST | `/admin/dev-deploy/peers/delete` | Delete a peer. |
|
|
| GET | `/admin/dev-deploy/plan` | Preview the ops that would be promoted to a chosen peer. |
|
|
| POST | `/admin/dev-deploy/promote` | Promote outbound ops to a peer via signed `ingest`. |
|
|
| POST | `/admin/dev-deploy/pull` | Pull a peer's ops inbound via signed `journal` and apply them. |
|
|
| GET | `/admin/dev-deploy/tables` | List tracked tables and their `data_mode`. |
|
|
| POST | `/admin/dev-deploy/tables/set` | Set a table's `data_mode`. |
|
|
| GET | `/admin/dev-deploy/conflicts` | List pending conflicts. |
|
|
| POST | `/admin/dev-deploy/conflicts/resolve` | Resolve a conflict with `theirs` or `mine`. |
|
|
| GET | `/admin/dev-deploy/conflicts/merge` | Per-field merge view for a conflicting `update_*` op. |
|
|
| POST | `/admin/dev-deploy/conflicts/merge/apply` | Apply a per-field merge resolution. |
|
|
|
|
### Machine API (HMAC peer auth, CSRF-exempt)
|
|
|
|
These routes are registered with `noCsrf: true`; `onLoad` also registers
|
|
`/dev-deploy/api/` in Saltcorn's `disable_csrf_routes`.
|
|
|
|
| Method | Path | Purpose |
|
|
| --- | --- | --- |
|
|
| GET | `/dev-deploy/api/journal?since=op_id` | Return this env's ops since an op id (max 1000, oldest first). |
|
|
| POST | `/dev-deploy/api/ingest` | Apply a batch of ops from a peer; advances the inbound anchor. |
|
|
| GET | `/dev-deploy/api/file/:uuid` | Stream a file entity's bytes by UUID. |
|
|
| GET | `/dev-deploy/api/health` | Return this env's `env_id`, `label`, and installed plugin list. |
|
|
|
|
Every machine-API request must carry these headers (see `lib/peerAuth.js`):
|
|
|
|
| Header | Meaning |
|
|
| --- | --- |
|
|
| `X-DD-Env-Id` | Caller's `env_id`, looked up in `_dd_peers`. |
|
|
| `X-DD-Timestamp` | Milliseconds since epoch; rejected if skew > 5 min. |
|
|
| `X-DD-Nonce` | Random per-request bytes (replay padding). |
|
|
| `X-DD-Signature` | Hex HMAC-SHA256 over the canonical string. |
|
|
|
|
The request body uses `Content-Type: application/vnd.dev-deploy+json` so
|
|
Saltcorn's `express.json()` does not consume the stream before the HMAC is
|
|
verified over the exact raw bytes. The canonical string binds the target host,
|
|
so a request signed for one tenant will not verify if replayed against another
|
|
tenant on the same server. See [docs/peering.md](docs/peering.md).
|
|
|
|
## Quick start
|
|
|
|
These steps assume the dual-instance dev layout where the project root holds
|
|
your state dirs and the upstream Saltcorn lives in a sibling subfolder, with
|
|
`dev-deploy/` next to them.
|
|
|
|
1. **Install the plugin into the dev instances.** From the project root:
|
|
|
|
```
|
|
./reinstallDevDeploy.sh
|
|
```
|
|
|
|
This installs `dev-deploy` into the MAIN (`.dev-state`), TEST
|
|
(`.dev-state-test`), and Postgres (`.dev-state-pg`) instances. Because the
|
|
plugins folder's `node_modules` symlinks are shared with the sibling
|
|
`saltcorn-idp` plugin, re-run `./reinstallIdp.sh` afterward to keep both
|
|
installs consistent.
|
|
|
|
2. **Run an instance.** From the project root, start MAIN on `:3000`:
|
|
|
|
```
|
|
./startServer.sh
|
|
```
|
|
|
|
For the second SQLite instance (TEST on `:3001`):
|
|
|
|
```
|
|
./startServerTest.sh
|
|
```
|
|
|
|
For the multi-tenant Postgres instance (on `:3002`):
|
|
|
|
```
|
|
./startServerPg.sh
|
|
```
|
|
|
|
On first load the plugin bootstraps: it creates the `_dd_*` tables, assigns
|
|
an `env_id`, and backfills entity UUIDs. Open
|
|
`http://localhost:3000/admin/dev-deploy/` (as an admin) to reach the
|
|
dashboard.
|
|
|
|
3. **Run the gates.** The plugin ships an end-to-end harness and four
|
|
targeted gates:
|
|
|
|
```
|
|
node test/e2e.js # full end-to-end run (npm test)
|
|
node test/mtGate.js # multi-tenant isolation (Postgres)
|
|
node test/mixedTopologyGate.js # standalone-to-tenant push peering + host-bound HMAC
|
|
node test/managedRowsGate.js # managed-row sync across tenants (Postgres)
|
|
node test/pullGate.js # reverse + tenant-to-tenant pull peering (Postgres)
|
|
```
|
|
|
|
The Postgres gates require the `:3002` instance to be running. See
|
|
[docs/testing.md](docs/testing.md).
|
|
|
|
## Documentation
|
|
|
|
| Doc | Covers |
|
|
| --- | --- |
|
|
| [docs/architecture.md](docs/architecture.md) | The ops journal, stable UUIDs, entity model, and apply pipeline. |
|
|
| [docs/peering.md](docs/peering.md) | Pairing, the HMAC transport, anchors, and the machine API. |
|
|
| [docs/multitenancy.md](docs/multitenancy.md) | Per-tenant environments, schema-qualified tables, host-bound auth. |
|
|
| [docs/managed-rows.md](docs/managed-rows.md) | `user` / `starter` / `managed` data modes and `_dd_row_uuid`. |
|
|
| [docs/operations.md](docs/operations.md) | Day-to-day plan / promote / pull, conflict resolution, revert. |
|
|
| [docs/testing.md](docs/testing.md) | The e2e harness and the multi-tenant / managed-row gates. |
|
|
|
|
## License
|
|
|
|
MIT. Author: Scott Duensing.
|