{"_id":"@arthuroabrantes/maestro","_rev":"3-82a089a5323c1bde0f905fbdae1ffd66","name":"@arthuroabrantes/maestro","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.0":{"name":"@arthuroabrantes/maestro","version":"0.1.0","_id":"@arthuroabrantes/maestro@0.1.0","maintainers":[{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"}],"homepage":"https://github.com/instrutoria/maestro#readme","bugs":{"url":"https://github.com/instrutoria/maestro/issues"},"bin":{"maestro":"index.ts"},"dist":{"shasum":"7cea7d60561af809efa5143724600a65b0a9065e","tarball":"https://registry.npmjs.org/@arthuroabrantes/maestro/-/maestro-0.1.0.tgz","fileCount":65,"integrity":"sha512-vq0Dbn/wLvCFKmirUUze62yMikqhK5q1d3w/oSXMR+rf5dAhW97yXj+ntWh1wqMHl+XMruD9h6434X7Tkkx6Bw==","signatures":[{"sig":"MEUCIQCWbtlJu2ozBRqwtlg7P+6Z/l1HcV8VaoaVUSls2D5EHAIgNIFJSWXMf7eoeuQSm1SOiPVzN9jpJvsbfISXnmZjcIc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":231557},"type":"module","gitHead":"bc7d3aa3478b8f7c42df1a0726c59a8c98380118","scripts":{"test":"bun test","maestro":"bun index.ts","maestro:dry-run":"bun index.ts --dry-run"},"_npmUser":{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"},"repository":{"url":"git+https://github.com/instrutoria/maestro.git","type":"git"},"_npmVersion":"11.12.1","description":"Provision DigitalOcean infrastructure from a single maestro.yaml (Pulumi + Ansible)","directories":{},"_nodeVersion":"26.0.0","dependencies":{"fp-ts":"^2.16.11","io-ts":"^2.2.22","@pulumi/tls":"^5.2.3","@pulumi/pulumi":"^3.245.0","@pulumi/command":"^1.1.3","@pulumi/cloudflare":"6.11.0","@pulumi/digitalocean":"^4.55.0"},"_hasShrinkwrap":false,"devDependencies":{"@types/bun":"latest","typescript":"5"},"trustedDependencies":["protobufjs"],"_npmOperationalInternal":{"tmp":"tmp/maestro_0.1.0_1781291133315_0.05685862336360836","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@arthuroabrantes/maestro","version":"0.1.1","_id":"@arthuroabrantes/maestro@0.1.1","maintainers":[{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"}],"homepage":"https://github.com/instrutoria/maestro#readme","bugs":{"url":"https://github.com/instrutoria/maestro/issues"},"bin":{"maestro":"index.ts"},"dist":{"shasum":"4c7ccddd869ff857be468b8088fe455753faf955","tarball":"https://registry.npmjs.org/@arthuroabrantes/maestro/-/maestro-0.1.1.tgz","fileCount":65,"integrity":"sha512-HnFlsmbKSQRDqVW46AERS0poT2wV1+bfG2Fd439814tGH57uKIFZrv6+RY9CysT6byuRUVluqsHrkBihtyTHdQ==","signatures":[{"sig":"MEQCIG9PJXKIbfvOI9RPpoE6p0J1NQ8bEBtg50KppXOsmdB5AiBszGA+9KyUwwfKQaQLSTe5XhnRTV8dk2kTwk/XzK1bjg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":233104},"type":"module","gitHead":"9a4031f34bf70bb53ffcbf9771f693e771bf3fd2","scripts":{"test":"bun test","maestro":"bun index.ts","maestro:dry-run":"bun index.ts --dry-run"},"_npmUser":{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"},"repository":{"url":"git+https://github.com/instrutoria/maestro.git","type":"git"},"_npmVersion":"11.12.1","description":"Provision DigitalOcean infrastructure from a single maestro.yaml (Pulumi + Ansible)","directories":{},"_nodeVersion":"26.0.0","dependencies":{"fp-ts":"^2.16.11","io-ts":"^2.2.22","@pulumi/tls":"^5.2.3","@pulumi/pulumi":"^3.245.0","@pulumi/command":"^1.1.3","@pulumi/cloudflare":"6.11.0","@pulumi/digitalocean":"^4.55.0"},"_hasShrinkwrap":false,"devDependencies":{"@types/bun":"latest","typescript":"5"},"trustedDependencies":["protobufjs"],"_npmOperationalInternal":{"tmp":"tmp/maestro_0.1.1_1781292187394_0.8472606702359005","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@arthuroabrantes/maestro","version":"0.1.2","description":"Provision DigitalOcean infrastructure from a single maestro.yaml (Pulumi + Ansible)","type":"module","bin":{"maestro":"index.ts"},"repository":{"type":"git","url":"git+https://github.com/instrutoria/maestro.git"},"scripts":{"maestro":"bun index.ts","maestro:dry-run":"bun index.ts --dry-run","test":"bun test","release":"npm version patch && git push && git push --tags && npm publish --access public"},"devDependencies":{"@types/bun":"latest","typescript":"5"},"dependencies":{"@pulumi/cloudflare":"6.11.0","@pulumi/command":"^1.1.3","@pulumi/digitalocean":"^4.55.0","@pulumi/pulumi":"^3.245.0","@pulumi/tls":"^5.2.3","fp-ts":"^2.16.11","io-ts":"^2.2.22"},"trustedDependencies":["protobufjs"],"gitHead":"087e9700cabf88e8ff5ae106152c2a4eafa6139f","_id":"@arthuroabrantes/maestro@0.1.2","bugs":{"url":"https://github.com/instrutoria/maestro/issues"},"homepage":"https://github.com/instrutoria/maestro#readme","_nodeVersion":"26.0.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-nWDyF64ZPMnVwfdRlcs92bxpPNB4URQteMmI93m12umbK4fpwYoPIbil3qfHDW0P12ELapxP1OaLoiRnOTVKig==","shasum":"e5c6ab7ea4d5758bd1d5b58bd77d8914c33c469e","tarball":"https://registry.npmjs.org/@arthuroabrantes/maestro/-/maestro-0.1.2.tgz","fileCount":65,"unpackedSize":241493,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIDkBezVzsDz7O6YlleZgWnAMVQRV+qu4oox4FYR1W/pUAiEA2evsQrx6/QEj9+AnwNLgtWSIDyEY1m65JuzM5XOPh1E="}]},"_npmUser":{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"},"directories":{},"maintainers":[{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/maestro_0.1.2_1781295732595_0.8783962902124001"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-12T19:05:33.138Z","modified":"2026-06-12T20:22:12.881Z","0.1.0":"2026-06-12T19:05:33.454Z","0.1.1":"2026-06-12T19:23:07.549Z","0.1.2":"2026-06-12T20:22:12.782Z"},"bugs":{"url":"https://github.com/instrutoria/maestro/issues"},"homepage":"https://github.com/instrutoria/maestro#readme","repository":{"type":"git","url":"git+https://github.com/instrutoria/maestro.git"},"description":"Provision DigitalOcean infrastructure from a single maestro.yaml (Pulumi + Ansible)","maintainers":[{"name":"arthuroabrantes","email":"abrantesarthur1997@gmail.com"}],"readme":"# Maestro\n\nMaestro deploys a web application to production from a single YAML file. Point it at a `maestro.yaml` describing your domain, servers, and app containers, and it provisions the cloud infrastructure (DNS, droplets, tunnels, managed Postgres) and configures the servers (nginx, Docker, your backend) — end to end, in one command.\n\nUnder the hood it combines [Pulumi](https://www.pulumi.com) for cloud resources and [Ansible](https://www.ansible.com) for server configuration, so you don't have to wire the two together yourself.\n\nOne `maestro.yaml` gets you:\n\n- **DNS and TLS** — Cloudflare DNS records, zone TLS settings, and Origin CA certificates for your domain.\n- **Servers** — DigitalOcean droplets per environment (`dev` / `staging` / `prod`), each environment fully isolated on its own subdomain.\n- **Private SSH access** — servers are reached through Cloudflare Zero Trust tunnels (`ssh0.example.com`), never by raw IP.\n- **Web tier** — nginx serving your static site or reverse-proxying a web container.\n- **Backend tier** — your app's Docker image deployed blue/green with health checks and optional pre-deploy database migrations, so deploys are zero-downtime.\n- **Database tier (optional)** — a DigitalOcean Managed Postgres cluster per environment, reachable only over a private VPC, with a least-privilege app user.\n\nMaestro is a versioned npm package with a CLI. Your application's repository owns its `maestro.yaml` (next to the app code the file describes) and runs maestro against it — `bunx @arthuroabrantes/maestro`, or pinned as a `devDependency`. Upgrading maestro is a version bump in your repo; nothing about your app lives in maestro's repository.\n\n---\n\n## Using Maestro\n\n### 1. Install the host requirements\n\nMaestro runs on [Bun](https://bun.sh) and orchestrates a few host tools (all validated at startup):\n\n| Tool          | Used for                                                                                                                                          |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `docker`      | Running the containerized Ansible execution environment and extracting image-sourced website assets.                                               |\n| `pulumi`      | The Pulumi CLI. Maestro drives it in-process via the Automation API; you never run it yourself. State lives in Pulumi Cloud (no `pulumi login` needed — `PULUMI_ACCESS_TOKEN` authenticates). |\n| `bws`         | Bitwarden Secrets Manager CLI — the source of all secrets.                                                                                         |\n| `cloudflared` | Reaching the servers through Cloudflare SSH tunnels.                                                                                               |\n| `pip`/Python  | Maestro auto-installs `ansible-navigator` (and `ansible-builder`) via `pip install --user` if they're missing from `PATH`.                          |\n\n### 2. Set up your accounts (one time)\n\n**Cloudflare.** The base `domain` in `maestro.yaml` must already exist as an **active zone** in your Cloudflare account: add the domain to Cloudflare and point your registrar's nameservers at the ones Cloudflare assigns. Maestro looks the zone up by name at runtime and fails with `Cloudflare zone for <domain> not found.` if it's missing — it never creates the zone or verifies ownership itself.\n\nThe zone must also be **free of conflicting DNS records**. Maestro creates its own records (apex `A`, `www` `A`, and `api`/`ssh*` `CNAME`s) and does not adopt pre-existing ones; if a record of a different type already occupies one of those names, Cloudflare rejects the create with `A CNAME record with that host already exists` (error `81054`) and the run fails partway through. Remove (or import into Pulumi) any conflicting records on the apex, `www`, `api`, and `ssh*` names first.\n\n**DigitalOcean.** Add the SSH **public** key you'll use to your DigitalOcean account. Maestro provisions droplets with it but does not register the key for you.\n\n**Bitwarden Secrets Manager.** Create the secrets below in a BWS project readable by your machine-account token. Maestro fetches them at startup and injects them into its own environment — they are never written to your repo.\n\n| Secret                      | Purpose                                                                                                                                            | Required scopes                                                                                                                                                                                                                                                                                                  |\n| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `VPS_SSH_KEY`               | SSH **private** key matching the public key registered in DigitalOcean.                                                                            | Not an API token; no scopes apply.                                                                                                                                                                                                                                                                                |\n| `GHCR_TOKEN`                | GitHub Container Registry token the servers use to pull your app images.                                                                           | GitHub personal access token (classic) with **`read:packages`**. Nothing else is needed for read-only pulls.                                                                                                                                                                                                      |\n| `GHCR_USERNAME`             | GitHub username that owns `GHCR_TOKEN`.                                                                                                            | Not an API token; no scopes apply.                                                                                                                                                                                                                                                                                |\n| `PULUMI_ACCESS_TOKEN`       | Pulumi Cloud access token (where infrastructure state lives).                                                                                      | Standard personal access token; needs access to the organization/stacks being deployed.                                                                                                                                                                                                                           |\n| `CLOUDFLARE_API_TOKEN`      | Cloudflare API token.                                                                                                                              | Zone → **Zone:Read**, **Zone Settings:Edit**, **DNS:Edit**; User → **SSL and Certificates:Edit** (Origin CA certs — a _user-level_ permission; without it cert creation fails with `401 / code 1016`); Account → **Cloudflare Tunnel:Edit**. Scoped to the account/zone being managed.                              |\n| `DIGITALOCEAN_ACCESS_TOKEN` | DigitalOcean API token.                                                                                                                            | `droplet:create/read/update/delete`; `ssh_key:read`; `tag:create/read/delete`. A full read+write token also works. With the database tier enabled, additionally `database:create/read/update/delete` and VPC read/write.                                                                                            |\n\nWith the database tier enabled (`pulumi.database.enabled: true`), two more secrets are required — stable identifiers you choose, read by both Pulumi (to create the user/database) and your backend:\n\n| Secret          | Purpose                                                              |\n| --------------- | -------------------------------------------------------------------- |\n| `POSTGRES_USER` | Dedicated least-privilege app database user name (created by Pulumi). |\n| `POSTGRES_DB`   | Application database name (created by Pulumi).                        |\n\n> `POSTGRES_HOST`, `POSTGRES_PORT`, and `POSTGRES_PASSWORD` are **not** Bitwarden secrets — they are derived from DigitalOcean, surfaced as Pulumi stack outputs, and injected into your backend automatically. Never commit them.\n\n### 3. Write your `maestro.yaml`\n\nIn your application's repository, create a `maestro.yaml`. A minimal full-stack example:\n\n```yaml\ndomain: example.com\n\npulumi:\n  enabled: true\n  command: up\n  projectName: example\n  cloudflareAccountId: \"<your-cloudflare-account-id>\"\n  sshPort: 22\n  stacks:\n    prod:\n      servers:\n        - roles: [backend, web]\n\nansible:\n  enabled: true\n  web:\n    static:\n      source: local\n      dir: ./website # relative paths resolve against this file's directory\n      dist: dist\n  backend:\n    image: ghcr.io/your-org/your-app\n    tag: latest\n    port: 3000\n\nsecrets:\n  provider: bws\n```\n\nSee [`example.maestro.yaml`](example.maestro.yaml) for a fully documented template with every option, and the [Configuration reference](#configuration-reference) below.\n\n### 4. Run it\n\n```bash\nexport BWS_ACCESS_TOKEN=\"your_bws_machine_account_token\"\n\nbunx @arthuroabrantes/maestro --dry-run   # validate the config and preview settings\nbunx @arthuroabrantes/maestro             # provision everything\n```\n\n`BWS_ACCESS_TOKEN` is the only environment variable you set by hand — a Bitwarden machine-account token with **read** access to the project holding the secrets above. Everything else flows from Bitwarden.\n\nAlternatively, pin maestro as a `devDependency` and run it via `bun run maestro`. A thin CI workflow that runs maestro on merge works the same way — the CLI is non-interactive.\n\n### CLI reference\n\n```\nmaestro [--config <path>] [--dry-run]\n```\n\n| Flag              | Purpose                                                                              |\n| ----------------- | -------------------------------------------------------------------------------------- |\n| `--config <path>` | Path to `maestro.yaml`. Default: `./maestro.yaml` in the current working directory.    |\n| `--dry-run`       | Validate the config and display the resolved settings without provisioning anything.   |\n| `--help`          | Show usage.                                                                             |\n\nRelative paths inside the config (e.g. `ansible.web.static.dir: ./website`) always resolve against the **config file's directory**, not the directory maestro is invoked from — so the same config works locally, in CI, and via `--config` from anywhere.\n\n---\n\n## Configuration reference\n\nAll configuration lives in one YAML file. The full structure:\n\n```yaml\ndomain: example.com # Base domain for DNS and nginx\n\npulumi:\n  enabled: true # Enable/disable cloud provisioning\n  command: up # up | refresh | cancel | output | destroy (destroy skips Ansible)\n  projectName: your-project-name # Pulumi project name\n  cloudflareAccountId: \"\" # Your Cloudflare account ID\n  sshPort: 22 # SSH port for tunnels\n  database: # Optional: Managed Postgres tier (global defaults)\n    enabled: false # Provision a DigitalOcean Managed Postgres cluster per stack\n    version: \"16\" # Postgres major version: \"15\", \"16\", or \"17\"\n    size: db-s-1vcpu-1gb # DigitalOcean DB node size\n    nodeCount: 1 # Node count (region always co-locates with the stack's droplets)\n  stacks: # One or more environments: dev, staging, prod\n    prod:\n      servers:\n        - roles: [backend, web] # Roles determine what gets provisioned/configured\n          # groups: [devops]    # Optional: override global ansible.groups\n          # size: s-1vcpu-1gb   # Optional: DigitalOcean droplet size\n          # region: nyc1        # Optional: DigitalOcean region\n      # database:               # Optional: per-stack sizing override (override wins)\n      #   size: db-s-2vcpu-4gb\n\nansible:\n  enabled: true # Enable/disable all server configuration\n  groups: [devops] # System groups (can be overridden per-server)\n  web: # Required if any server has the \"web\" role\n    static: # ...serve static files with nginx, or:\n      source: local # local | image\n      dir: ./website # Website source (relative to this config file)\n      build: bun run build # Optional build command\n      dist: dist # Subdirectory with built assets\n    # docker:               # ...or reverse-proxy a web container\n    #   image: ghcr.io/org/web\n    #   tag: latest\n    #   port: 3000\n  backend: # Required if any server has the \"backend\" role\n    image: ghcr.io/org/app # Container image\n    tag: latest # Image tag\n    port: 3000 # Backend port\n    env: # Non-sensitive environment variables for the container\n      SOME_VAR: value\n    secretEnv: # Sensitive vars: names of Bitwarden secrets, values injected at runtime\n      - STRIPE_SECRET_KEY # container var STRIPE_SECRET_KEY ← BWS secret of the same name\n      - BWS_ACCESS_TOKEN: APP_BWS_ACCESS_TOKEN # rename on inject: container var ← BWS secret\n    migrate: # Optional: DB migration before each deploy (omit = none)\n      command: [\"npm\", \"run\", \"migrate\"] # argv run inside the backend image\n    healthCheck: # Optional: blue/green readiness probe\n      path: /health # Polled for 200 before cutover (default: /health)\n\nsecrets:\n  provider: bws # Secrets provider (bws = Bitwarden Secrets Manager)\n  projectId: \"\" # Optional BWS project ID to scope the fetch\n  requiredVars: [] # Extra secrets to validate and forward to Ansible\n```\n\n### Server roles\n\nProvisioning is **role-based**: each Ansible playbook runs only on servers holding the corresponding role, and is skipped entirely when no server has it.\n\n| Role      | Playbook      | Purpose                               |\n| --------- | ------------- | ------------------------------------- |\n| `backend` | `backend.yml` | Docker + backend container deployment |\n| `web`     | `web.yml`     | nginx (static files or reverse proxy) |\n\n**Security hardening** (`security.yml`) always runs on all servers: UFW firewall rules (deny incoming by default, allow SSH, role-specific openings) and system group management (`ansible.groups`).\n\n### Environments (stacks)\n\nEach stack under `pulumi.stacks` is an isolated environment with its own Pulumi state, servers, and (optionally) database. Maestro provisions every defined stack sequentially, then aggregates all hosts for the Ansible phase. Servers are tagged with both their stack name and their roles, so playbooks can target by environment. See [`ansible/README.md`](ansible/README.md) for host-targeting details.\n\nNon-production stacks live on prefixed subdomains of the base `domain`, applied to every resource:\n\n| Resource     | `dev`                  | `staging`                  | `prod`             |\n| ------------ | ---------------------- | -------------------------- | ------------------ |\n| Web domain   | `dev.example.com`      | `staging.example.com`      | `example.com`      |\n| WWW domain   | `www.dev.example.com`  | `www.staging.example.com`  | `www.example.com`  |\n| API endpoint | `api.dev.example.com`  | `api.staging.example.com`  | `api.example.com`  |\n| SSH tunnel   | `ssh0.dev.example.com` | `ssh0.staging.example.com` | `ssh0.example.com` |\n\nDNS records are created under the base domain's Cloudflare zone; TLS certificates are issued per effective domain.\n\n### Database (Managed Postgres)\n\nWhen `pulumi.database.enabled` is `true`, Maestro provisions a **DigitalOcean Managed Postgres** cluster as a dedicated database tier. The guiding principle: _the backend is cattle; the database is the crown jewels_ — the database lives in its own managed failure domain, separate from the disposable backend droplet.\n\n- **Per-environment isolation.** Each enabled stack provisions its **own** cluster, app database, and app user. A `dev` deploy never touches the `prod` database.\n- **Private VPC endpoint + TLS.** Each stack gets a dedicated VPC joined by both the backend droplet and the cluster. The backend connects over the database's **private** endpoint — traffic never traverses the public internet — and still uses `sslmode=require` (plus `PGSSLMODE=require` for libpq clients).\n- **Least privilege.** Pulumi creates a dedicated database user (from `POSTGRES_USER`) and database (from `POSTGRES_DB`). The application never uses the cluster admin (`doadmin`).\n- **Firewall by tag.** A `DatabaseFirewall` trusts the backend droplets by their per-stack **tag**, not droplet ID, so it survives droplet rebuilds. There is no `0.0.0.0/0` rule.\n- **Lifecycle safeguards.** The cluster carries `retainOnDelete: true` **and** `protect: true`; the app database, user, and VPC carry `retainOnDelete`. A `pulumi destroy` of the disposable backend succeeds while leaving the cloud database and its data intact; intentional teardown requires deliberately unprotecting and removing the resource from state.\n- **Durability.** DigitalOcean's built-in daily backups plus point-in-time recovery (PITR) cover durability — PITR protects against operator/application mistakes (a bad migration, an accidental `DELETE`), not just infrastructure loss.\n\n> **One-time droplet replacement.** VPC membership is immutable on a DigitalOcean droplet, so enabling the database replaces the existing backend droplet once on first apply. The backend is cattle; Ansible re-converges the replacement.\n\n**Connection wiring.** `POSTGRES_USER`/`POSTGRES_DB` originate in Bitwarden (stable values you choose); `POSTGRES_HOST`, `POSTGRES_PORT`, and `POSTGRES_PASSWORD` are derived from DigitalOcean and read from typed Pulumi stack outputs (the password as a Pulumi secret). Maestro threads the per-stack values onto that stack's backend host(s), so multi-stack deploys never cross-wire one stack's backend to another stack's database. The backend container ends up with `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_DB`, `POSTGRES_PASSWORD`, and `POSTGRES_SSLMODE=require` in its environment.\n\n### Zero-downtime backend deploys & migrations\n\nBackend deploys are **zero-downtime** by default: nginx stays up as a stable reverse proxy and Maestro swaps containers blue/green underneath it.\n\nThe backend runs as one of two containers — `backend-blue` (on `ansible.backend.port`) and `backend-green` (on `port + 1`) — both bound to `127.0.0.1`. Each deploy starts the new image on the idle port while the live one keeps serving, waits for it to pass the health check, points nginx at the new port, and reloads (`nginx -s reload` drains in-flight requests). Only then is the old container stopped. If the new container never gets healthy, the old one keeps serving and the deploy fails — there is no half-deployed state.\n\n- **`ansible.backend.healthCheck.path`** (optional): the HTTP path polled for a `200` on the new container before cutover. Defaults to `/health`. The retry/timeout budget is fixed.\n- **`ansible.backend.migrate.command`** (optional): an argv array (e.g. `[\"npm\", \"run\", \"migrate\"]`, not a shell line) run once inside the backend image **before the new container starts**, against the live database, with the same environment as the app (including the per-stack `POSTGRES_*` values). A non-zero exit aborts the deploy before the app container is touched. Omit the block to skip migrations. The DB password and other secrets stay on a `no_log`-guarded path and are never printed.\n\n### Forwarding extra secrets\n\nMaestro never stores secret values in `maestro.yaml` — the file is meant to be version controlled. There are three ways to get configuration into the system, by sensitivity and destination:\n\n- **`ansible.backend.env`** — non-sensitive backend-container variables, as literal key/value pairs (e.g. `NODE_ENV: production`).\n- **`ansible.backend.secretEnv`** — **sensitive** backend-container variables. List the _names_ of secrets stored in Bitwarden; maestro fetches the values at runtime and injects each into the container under the same name. To inject under a different name, use a mapping entry — `CONTAINER_VAR: BWS_SECRET_NAME` (e.g. `BWS_ACCESS_TOKEN: APP_BWS_ACCESS_TOKEN` hands the app a token stored under a non-conflicting Bitwarden name). Source secrets are validated at startup (a missing secret fails the run before any cloud call), and the values ride the same `no_log`-guarded path as the database password — they are never written to your repo or printed. A container var may not also appear in `env`, and `PORT` is reserved (auto-injected from `ansible.backend.port`).\n- **`secrets.requiredVars`** — secrets needed by Ansible _playbooks_ (not the app container). They are validated at startup and forwarded into the Ansible execution environment, where playbooks read them via `lookup('env', 'VAR_NAME')`.\n\n> **Never forward `BWS_ACCESS_TOKEN` to your app.** Maestro's deploy token can read all deploy secrets (Cloudflare, DigitalOcean, Pulumi); a compromised backend container would inherit your whole infrastructure. Maestro rejects it as a `secretEnv` _source_. If your application itself needs Bitwarden access, create a separate machine-account token scoped to an app-secrets project, store it in Bitwarden under a different name (e.g. `APP_BWS_ACCESS_TOKEN`), and use the mapping form to hand it to the app under the name it expects: `BWS_ACCESS_TOKEN: APP_BWS_ACCESS_TOKEN`.\n\n---\n\n## How it works\n\n### The pipeline\n\n`index.ts` (the `maestro` bin) orchestrates one linear pipeline:\n\n1. **Parse CLI flags** and resolve the config path against the current working directory.\n2. **Validate `maestro.yaml`** against a typed schema (io-ts codecs plus semantic checks: role/web consistency, region mixing, database preconditions, path existence). `--dry-run` stops here and prints the resolved settings.\n3. **Fetch secrets** from Bitwarden Secrets Manager (`bws secret list`) and inject them into the process environment; required secrets are asserted up front so failures happen before any cloud call.\n4. **Write the SSH key** to a `0600` temp file (stable path — it's interpolated into Pulumi resources, so a per-run random path would diff them on every deploy).\n5. **Run Pulumi per stack** — in-process via the [Automation API](https://www.pulumi.com/docs/iac/automation-api/), no Pulumi Docker image and no shelling out to `pulumi up`. The TypeScript program under `pulumi/` provisions Cloudflare DNS records, zone TLS settings, Origin CA certificates, Zero Trust SSH tunnels, the DigitalOcean droplets, and (optionally) the VPC + Managed Postgres tier. State lives in Pulumi Cloud.\n6. **Aggregate hosts** from all stacks' typed outputs — hostnames, roles, stack tags, and per-stack database endpoints.\n7. **Wait for tunnel readiness** — polls SSH-over-cloudflared until every host accepts connections.\n8. **Run Ansible** against the hosts: `web.yml`, `backend.yml`, then `security.yml` last (it may tighten firewall rules that would block the earlier plays).\n\n### The Ansible execution environment\n\nPlaybooks don't run on your host's Ansible — they run inside a containerized **execution environment** (EE) driven by `ansible-navigator`. Maestro builds the image locally with `ansible-builder` from the definition that ships with the package (`ansible/execution_environment/`). The built image is tagged with a content hash of the definition, so subsequent runs reuse it and skip the build entirely until the definition changes (e.g. on a maestro upgrade).\n\nThe image is **app-agnostic** — everything app-specific reaches the container at runtime, never at image build time:\n\n- **Website assets** are staged into a temp directory (built from `ansible.web.static.dir`, or extracted from a container image) and volume-mounted read-only at `/opt/website`.\n- **The SSH key** temp file is volume-mounted read-only.\n- **Configuration and secrets** are forwarded as environment variables (`--penv`), including the host list (`SSH_HOSTS`), which a dynamic inventory script (`ansible/inventory/hosts.py`) turns into Ansible hosts grouped by role and stack.\n\nThis is what lets one EE image serve every consuming application.\n\nMaestro's installation directory is treated as read-only at runtime: website staging, the EE build context, navigator logs, and secret files all live in the system temp directory (with a `maestro_` prefix, `0600` secret files, and cleanup on exit plus a stale sweep at startup).\n\n### Package layout\n\n| Path           | What it is                                                                                          |\n| -------------- | ---------------------------------------------------------------------------------------------------- |\n| `index.ts`     | CLI entry point (`bin`), the pipeline above.                                                          |\n| `lib/`         | Orchestration: config loading/validation, secrets, Pulumi driver, Ansible driver, tunnel readiness.   |\n| `pulumi/`      | The Pulumi program (imported in-process): DNS, certificates, tunnels, droplets, VPC, managed Postgres. |\n| `ansible/`     | Execution environment definition, dynamic inventory, and playbooks/roles for nginx, Docker, UFW, blue/green deploys. |\n\n### Releasing\n\nReleasing a maestro version is a `version` bump in `package.json` plus `npm publish` — the EE image definition, playbooks, and the Pulumi program all travel inside the package, so there is no second artifact to keep in sync.\n\n## Developing maestro\n\nThis repository is itself just another consuming app whose `maestro.yaml` happens to sit next to the tool:\n\n```bash\nbun index.ts --dry-run   # or: bun run maestro:dry-run\nbun index.ts             # full run against the repo's own maestro.yaml\nbun test                 # unit tests\n```\n\n## Roadmap\n\n- **Independent logical backup stream to DigitalOcean Spaces**: in addition to DO's daily backups + PITR, a self-owned `pg_dump`-style backup into a Spaces bucket on a schedule, with lifecycle/retention rules and tested restore drills — defense-in-depth against account- or provider-level problems, and portable copies.\n- **Per-database GRANT tightening**: the app user is non-superuser and never `doadmin`, but scoping its privileges to only what it needs within its own database (beyond DigitalOcean's defaults) is a follow-up.\n- **Multi-cloud provider support**: DigitalOcean is currently the only provider; AWS/GCP/Azure may follow.\n- **Automated SSH key provisioning**: today the public key must be added to DigitalOcean manually before the first run.\n- **Multiple web/backend servers per environment** with load balancing; today each role maps to a single server per stack.\n- **Ansible via SDK**: drive Ansible through a library interface instead of shelling out to `ansible-navigator` (Pulumi already runs in-process).\n- **Security hardening follow-ups**: avoid materializing the SSH key on the host filesystem, and hide server IP addresses from Pulumi output.\n","readmeFilename":"README.md"}