diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3ffef..7952577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ Versions are git tags (`vX.Y.Z`); projects install one with `uv tool install git+ssh://git@github.com/hybridinteract/deployctl@vX.Y.Z`. +## 0.11.0 + +The control panel, rebuilt around continuous deployment. + +**Panel** +- **Five tabs, in the order a project needs them.** *Operate* (every day: what runs, deploy + or roll back a tag, the release history, apply a config change, maintenance, a terminal), + *Setup* (once per server: the bootstrap script, the missing configuration, the first + deploy), *CI/CD* (once per repository: `ci doctor` as a checklist with a fix button per + row, recent runs), *Configure*, *Logs*. The Tutorial and Terminals tabs are gone: their + content is in Setup, Operate and the docs. +- **A live top bar and a stepper.** The running tag, who shipped it and when, health, + whether GitHub's config matches this machine's, and whether merges deploy — read through + `deploy status --json` and `ci doctor --json`, cached for 15 s, refreshed after every job. + The stepper (Server → First deploy → CI/CD → Live) names the next step. The page opens + on Operate once CI/CD is set up, on Setup before. +- **Deploys go through GitHub.** Deploy a tag, roll back, roll back to any earlier + release, and apply a config change all run the deploy workflow (`ci deploy`), so every + deploy lands in one history. Deploying straight from this machine is folded under + *Emergency*. +- **Buttons can take a value — only one the action declares.** Today that is an image tag, + checked against the CLI's own pattern and passed as one whole argument; an undeclared, + repeated or malformed value starts nothing and says why in the output pane. +- Dark mode (follows the system), keyboard-navigable tabs, a toast when a job ends, the + open tab kept in the address. Configure shows what validation says, re-read on Save; the + image-tag field is gone (the tag is not configuration since 0.10.0); a *Deploy branch* + field is new. + +**CLI** +- `deploy history --json`, for the panel's release list. +- `ci doctor --json`: the automatic-deploys row carries `value: on|off`. + +**Fixed** +- `validate` failed every project without `IMAGE_TAG` in its config (so every project + after `migrate-config`): it compared the rendered compose file with `repo:` and an empty + tag. It now checks against the tag the files were rendered with, and an image reference + is never derived without a tag. + ## 0.10.0 Continuous deployment by default, and fleets that are never left split. diff --git a/README.md b/README.md index 9a50599..7f3a945 100644 --- a/README.md +++ b/README.md @@ -94,51 +94,28 @@ drives your SSH keys, so never expose the port. ## Then, in the browser -**0 · Tutorial** *(first deployment only)* — the whole path from a bare VPS, droplet or EC2 -instance to a running deployment, in order: what to install on the server, the deploy user -and its permissions, the cloud pieces a cluster needs, then configure and deploy. Every -command is rendered with your own values, and the steps the panel can perform have a Run -button on them. Skip to **1** if your servers are already bootstrapped. +The panel follows the project's journey — **Server → First deploy → CI/CD → Live** — and +the stepper under the top bar always names the next step. -**1 · Configure** — fill in the sections and press **Save**. The form adapts to the -environment's shape: a single-server environment shows TLS and containerized database -settings, a cluster shows the host list with a "primary" selector. Required fields are -marked; help text sits under each input. Saving writes the same `config/*.env` files the -CLI reads — there is no hidden state. +**Setup** *(once per server)* — the root script that prepares a fresh server, rendered with +this environment's values; the configuration still missing; then the first deploy as a +numbered rail: pick the tag CI published, Regenerate → Validate → Doctor → Init → SSL → +Status. Everything before Init changes nothing on a server. -**2 · Set the image tag** — in *Application image*, press **fetch tags from the registry** -and click the newest, then **Save**. (Nothing published yet? Run `deployctl ci init` for -a GitHub Actions workflow, or `deployctl image push` to build from this machine.) +**CI/CD** *(once per repository)* — `deployctl ci doctor` as a checklist, each row with the +button that fixes it: connect GitHub, generate the workflows, create the CI key, pin the +host keys, sync the config, turn automatic deploys on. -**3 · Deploy** — the tab is laid out as the procedure itself: a numbered rail you work left -to right, where each step assumes the previous one passed. +**Operate** *(every day)* — shipping is a merge. What remains is here: what each host runs +and who shipped it, deploying or rolling back a tag through GitHub, the release history +with **Roll back to this**, putting a config change live, backups and a shell. Deploying +straight from this machine is folded under **Emergency**. -``` -First deployment - 1 Fill in config → 2 Regenerate → 3 Validate → 4 Doctor → 5 Init → 6 SSL: obtain → 7 Status - (you edit) (local) (local) (reads) (deploys) (single only) - -Roll out a new version ← the everyday one - 1 Set image tag → 2 Regenerate → 3 Validate → 4 Doctor → 5 Update → 6 Status - (you edit) (local) (local) (reads) (rolling) -``` - -Steps 2–4 change nothing on any server; the deploying step is the first that does. Below -the rails sit the groups that genuinely have no order — **Check & observe**, **Recover**, -and **Take it down** — drawn as plain grids so the difference between "do these in turn" -and "run whichever you need" is visible at a glance. - -Output streams live into the pane on the right (drag its edge to resize), and every card -shows the exact `deployctl` command it runs, whether it touches a host, and what it leaves -changed — so anything you see, you can reproduce in a terminal. - -**4 · TLS** *(single-server only)* — after the first `Init`, run **SSL: obtain**. Until -then the site is serving a self-signed placeholder certificate. Cluster deployments -terminate TLS at the load balancer instead, so the action is hidden there. +**Configure** — every setting as a form, validated as you save. **Logs** — follow a host. -That is a complete first deployment. **Day to day it is two clicks:** pick a new tag in -*Application image*, then **Update**. **Rollback** returns to the previously deployed tag, -and **Backup now** dumps the database. +Every card shows the exact `deployctl` command it runs, where it acts and what it leaves +changed, and output streams live into the pane on the right — anything you see, you can +reproduce in a terminal. Full details — the action whitelist, how secrets are handled, what the panel deliberately cannot do: **[docs/50-WEBUI.md](docs/50-WEBUI.md)**. @@ -287,7 +264,7 @@ troubleshooting table. The rest is depth to come back for: 4. **[30-OPERATIONS.md](docs/30-OPERATIONS.md)** — day two: rolling updates (what to edit, in what order), rollback, backups, `/docs` auth, troubleshooting. **[35-CONTINUOUS-DEPLOYMENT.md](docs/35-CONTINUOUS-DEPLOYMENT.md)** — deploy on merge from GitHub Actions: keys, the config secret, rollback. 5. **[40-ADOPTING-A-NEW-PROJECT.md](docs/40-ADOPTING-A-NEW-PROJECT.md)** — setting up a new repository, or moving one off a copied-in deployctl. -6. **[50-WEBUI.md](docs/50-WEBUI.md)** — the control panel, its Deploy tab, and its safety model. +6. **[50-WEBUI.md](docs/50-WEBUI.md)** — the control panel, its tabs, and its safety model. 7. **[60-COMMAND-REFERENCE.md](docs/60-COMMAND-REFERENCE.md)** — every command and flag, checked against the CLI by a test. ## Working on deployctl diff --git a/docs/05-QUICKSTART.md b/docs/05-QUICKSTART.md index ce5be82..9d11af4 100644 --- a/docs/05-QUICKSTART.md +++ b/docs/05-QUICKSTART.md @@ -9,8 +9,8 @@ optional and nothing here is ambiguous — run the blocks in order, top to botto > > 1. **A server** you can `ssh` into, with **Docker** installed and a **deploy user** that > can run `docker` without `sudo`. → Don't have one? Do -> [Part A and Part B of the Tutorial tab](#step-5--open-the-control-panel) first; it -> takes about ten minutes and covers provisioning and bootstrapping the host. +> the panel's **Setup → Prepare the server** (or `deployctl server bootstrap-script`) first; +> it takes about ten minutes on a fresh Ubuntu server. > 2. **A container image in a registry.** → Don't have one? Step 4 creates the CI workflow > that builds it. @@ -83,50 +83,42 @@ Ctrl+C stops it. If the port is taken: `--stop` ends the old one, `--restart` re # In the browser -## Configure +The panel opens on **Setup** and the stepper under the top bar — Server → First deploy → +CI/CD → Live — always names the next step. -Fill in the sections and press **Save**. The form adapts to the environment's shape: -single-server shows TLS and containerized-database settings, a cluster shows the host list -with a "primary" selector. Required fields are marked `*`; help text sits under each input. +## Setup -Saving writes the same `config/*.env` files the CLI reads — there is no hidden state. - -**Set the image tag:** in *Application image*, press **fetch tags from the registry**, -click the newest, then **Save**. - -## Deploy - -Open the **Deploy** tab and follow the **First deployment** rail left to right. Each card -shows the exact command it runs and what it changes: +1. **Prepare the server** — the root script for this environment, and the one line that + runs it on each new server. Skip it for a server you already bootstrapped. +2. **Finish the configuration** — lists what still blocks a deploy; **Open Configure**, + fill in the sections and press **Save**. The form adapts to the environment's shape; + Save writes the same `config/*.env` files the CLI reads. +3. **First deploy** — type the tag CI published (or pick it from **Recent tags from the + registry**), then work the rail left to right: ``` -1 Fill in config → 2 Regenerate → 3 Validate → 4 Doctor → 5 Init → 6 SSL: obtain → 7 Status - (you edit) (local) (local) (reads) (deploys) (single only) +1 Regenerate → 2 Validate → 3 Doctor → 4 Init → 5 SSL: obtain → 6 Status + (local) (local) (reads) (deploys) (single only) ``` -Steps 2–4 change nothing on a server; step 5 is the first one that does. **Do not skip +Steps 1–3 change nothing on a server; Init is the first one that does. **Do not skip Doctor** — it is what catches an unreachable host or an unpullable image *before* anything -is torn down. +starts. **SSL: obtain** appears only for a single-server Let's Encrypt environment. -Step 6 (**SSL: obtain**) appears only for a single-server Let's Encrypt environment. Until -you run it the site serves a self-signed placeholder certificate. Cluster deployments -terminate TLS at the load balancer, so the step is not shown there. +## CI/CD -That is a complete first deployment. +Work down the checklist — each row has the button that fixes it: Connect GitHub, Generate +workflows (then commit and push them), Create the CI key, Pin host keys, Sync config. Then +**Redeploy what's running** once, to prove it, and turn automatic deploys on. Details: +[35-CONTINUOUS-DEPLOYMENT.md](35-CONTINUOUS-DEPLOYMENT.md). ## Afterwards, day to day -Two clicks. Follow the **Roll out a new version** rail — it is the first thing on the -Deploy tab and marked *everyday*: - -``` -1 Set image tag → 2 Regenerate → 3 Validate → 4 Doctor → 5 Update → 6 Status - (you edit) (local) (local) (reads) (rolling) -``` - -On a routine release, **`IMAGE_TAG` is the only value that changes.** Full detail on what -to edit and why the order is what it is: -[30-OPERATIONS.md § Rolling out an update](30-OPERATIONS.md#rolling-out-an-update). +**Merge to the deploy branch** — that is the release. The panel opens on **Operate**: what +runs and who shipped it, **Deploy a version** / **Roll back** through GitHub, the release +history with **Roll back to this**, and **Apply a config change** (Save → Sync config → +Deploy the change). Deploying from this machine is still there, folded under +**Emergency**, for when GitHub is the thing that is broken. --- @@ -174,6 +166,4 @@ run, without touching a host. | Doctor fails on permissions | `deployctl deploy doctor --fix` repairs what does not need root; anything else is printed as an exact command to run on the host. | | Health gate times out after a deploy | `deployctl deploy logs --host `. The gate prints the last HTTP status and what it usually means. | -Full troubleshooting: [30-OPERATIONS.md § When it breaks](30-OPERATIONS.md), or the -**Tutorial** tab's "When it breaks" section, which is the same material with your values -substituted in. +Full troubleshooting: [30-OPERATIONS.md § Troubleshooting](30-OPERATIONS.md#troubleshooting). diff --git a/docs/30-OPERATIONS.md b/docs/30-OPERATIONS.md index 4c6b786..ac7c492 100644 --- a/docs/30-OPERATIONS.md +++ b/docs/30-OPERATIONS.md @@ -7,8 +7,9 @@ when something is broken. ## Rolling out an update -This is the everyday path. In the panel it is the **Roll out a new version** rail at the -top of the Deploy tab, drawn in this order; from the CLI it is the same six steps. +Once CI/CD is set up, a release is a merge — see +[35-CONTINUOUS-DEPLOYMENT.md](35-CONTINUOUS-DEPLOYMENT.md). This section is the same +release by hand, from a laptop: the panel's **Operate → Emergency**, or these commands. ### What you edit diff --git a/docs/50-WEBUI.md b/docs/50-WEBUI.md index 8027ebd..db4983c 100644 --- a/docs/50-WEBUI.md +++ b/docs/50-WEBUI.md @@ -1,12 +1,13 @@ # 50 — The control panel -A local web UI over the same CLI. Useful when you want the configuration laid out in front -of you, a tag picker instead of copy-paste, and streamed output without a terminal. +A local web UI over the same CLI: a new project goes from a bare server to deploying on +every merge through its **Setup** and **CI/CD** tabs, and daily work — seeing what runs, +deploying or rolling back a tag, putting a config change live — happens in **Operate**. ## Start it ```bash -uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0 # once per machine +uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.11.0 # once per machine deployctl webui # → http://127.0.0.1:8765 ``` @@ -87,7 +88,10 @@ which can send it requests. So: outright. - **A fixed action whitelist.** The browser can only trigger the actions in `webui/panel/actions.py`; anything else is a 404. Unknown hosts and actions that do not - apply to the environment's shape are refused. + apply to the environment's shape are refused. The only value a request may carry is one + the action declares — today, an image tag — and it must match the same pattern the CLI + enforces; it is passed as one whole argument, never through a shell. A value that is + undeclared, repeated or malformed starts nothing. - **Secrets never reach the browser.** Password fields render empty with a "saved" badge. Submitting one blank keeps the stored value; only a non-empty value replaces it. *Preview resolved config* masks credentials by key name **and** by value, so the @@ -103,102 +107,98 @@ which can send it requests. So: --- -## The tabs - -**Configure** — every setting for the selected environment, grouped, with help text. The -first section, *Deployment shape*, holds `MODE` — but note it only sets **defaults**: -`TLS_MODE`, `POSTGRES_MODE` and `REDIS_MODE` win wherever they are set explicitly, so -switching mode and saving does not silently rewrite them. Run **Validate** afterwards; it -reports exactly what still disagrees. Switching an environment that is already deployed is -a migration rather than a toggle — see -[30-OPERATIONS.md](30-OPERATIONS.md#changing-an-environments-shape). -Fields come from `webui/fields/*.toml` plus your `project/fields.toml`, filtered by the -environment's mode, so a single-server environment shows the TLS section and a cluster -environment shows the hosts widget. Save writes straight to the config files. -*Preview resolved config* opens what the CLI actually sees, secrets masked. - -**Deploy** — arranged by *how the commands are actually used*, not alphabetically: - -- **Roll out a new version** — the everyday path, drawn as a numbered rail you work left to - right: `1 Set the image tag → 2 Regenerate → 3 Validate → 4 Doctor → 5 Update → - 6 Status`. Each step assumes the one before it passed. -- **First deployment** — the same shape, ending in `Init` (and `SSL: obtain` on a - single-server Let's Encrypt environment) instead of `Update`. -- **Check & observe** — a plain group, because these have no order. Nothing here changes - anything, so all of it is safe mid-incident. -- **Recover** — Rollback, Restart, Migrate, Backup now. -- **Take it down** — Stop, on its own, because it is the only action with no automatic way - back. - -The two rails are ordered; the three groups below are not, and they are drawn differently -so that difference is visible without reading. Steps with a **dashed amber border are edits -you make yourself** — clicking one jumps to that section of the Configure tab rather than -running anything. Every other card shows the exact `deployctl` command it runs, whether it -touches a host at all, and what it leaves changed. Mutating actions confirm first — the -dialog names the environment, its hosts and the saved image tag, so a click on the wrong -environment or an unsaved tag is caught there — and output streams into the pane on the -right. +## The layout + +``` +[production ▾] demo · single · 1 host(s) ● live 8e3e648 · GitHub Actions · 2h ago ✓ healthy ✓ config synced ✓ auto-deploy on + ✓ Server ─ ✓ First deploy ─ ③ CI/CD ─ ④ Live NEXT Finish CI/CD: Pinned host keys. [CI/CD →] + Operate │ Setup │ CI/CD │ Configure │ Logs │ output +``` + +**The top bar is live.** What the hosts run, who shipped it (an Actions run links to it), +whether every service is healthy, whether GitHub's copy of the config matches this +machine's, and whether merges deploy. It comes from `deployctl deploy status --json` and +`deployctl ci doctor --json`, run in the background — the panel never opens ssh or calls +GitHub itself. Each fact is kept for 15 seconds, and read again after every job and on ↻. + +**The stepper** says how far the environment is: Server → First deploy → CI/CD → Live, +and names the one next step with a button to the tab it is done in. The page opens on +**Operate** once CI/CD is set up on this machine (workflows and `CI_SCOPE`), and on +**Setup** before that. It never switches tab by itself; the address keeps the open tab +(`#cicd`), so a reload stays put. + +### Operate — every day, once live + +- **Running now** — each host, its tag, who deployed it and when, and every service's + state, health and restart count. The same rules as a deploy's service watch decide what + counts as a problem. +- **Deploy a version** — a tag (type it, or pick from *Recent tags from the registry*), + then **Deploy**, or **Roll back** to the release before the running one. Both run the + deploy workflow on GitHub (`deployctl ci deploy`) and follow it in the output pane, so + every deploy lands in one history. +- **Releases** — the history on the primary, newest first, with who shipped each. Every + earlier release has **Roll back to this**. +- **Apply a config change** — ① change it in Configure and Save, ② **Sync config**, ③ + **Deploy the change** (the running tag, with the new config). +- **Maintenance** — Status, Doctor, Restart, Backups, Back up now, SSL check. +- **Open a terminal** — your own terminal app, in the deploy directory or ssh'd into a host + with your own agent and keys. No shell runs inside the page. +- **Emergency** (folded) — update, roll back, migrate or stop *from this machine*, straight + over ssh: for when GitHub or CI is what is broken. Same engine, lock, health gate and + revert; outside CI's history of runs. + +### Setup — once per server + +1. **Prepare the server** — the root script for this environment + (`deployctl server bootstrap-script`), how to run it on each host, and the check + afterwards. For a *new* server: it upgrades packages and may reboot. +2. **Finish the configuration** — what still blocks a deploy, with a jump to Configure. +3. **First deploy** — pick the tag CI published, then Regenerate → Validate → Doctor → + Init → SSL: obtain → Status, left to right. + +### CI/CD — once per repository + +The checklist is `deployctl ci doctor`: one row per piece, with its status, why, and the +button that fixes it — Connect GitHub, Generate workflows, Create the CI key, Pin host keys, +Sync config, automatic deploys on/off. Rows only a person can fix (`gh auth login`, the +install token) show the command instead. Below: the three things only you can do on +GitHub, **Redeploy what's running** to prove the setup end to end, and the workflow's recent +runs. + +### Configure and Logs + +**Configure** — every setting for the selected environment, grouped, with help text, and +what validation says about it at the top, re-read after every Save. The first section, +*Deployment shape*, holds `MODE` — but it only sets **defaults**: `TLS_MODE`, +`POSTGRES_MODE` and `REDIS_MODE` win wherever they are set explicitly. Switching an +environment that is already deployed is a migration rather than a toggle — see +[30-OPERATIONS.md](30-OPERATIONS.md#changing-an-environments-shape). Fields come from +`webui/fields/*.toml` plus your `project/fields.toml`, filtered by mode. Save writes +straight to the config files and nothing else — **Apply it →** takes you to *Apply a config +change*. The image tag is not here: CI deploys the tag it built, and Operate deploys the +one you pick. + +**Logs** — one button per host; streams `docker compose logs -f`. + +### How the buttons behave + +Every card shows the exact `deployctl` command it runs, where it acts (this machine, every +host, GitHub, or GitHub Actions → every host) and what it leaves changed. Anything that +changes what runs asks first, naming the environment, its hosts and the tag. Output +streams into the pane on the right, and a toast says when a job ends. **The pane is a view, not the command.** Each action runs as a *job* that belongs to the panel process, not to the browser tab: clicking another card, reloading, switching environment or closing the tab only stops *watching*. The bar under the pane's header lists running and recent jobs; click one to replay its output and follow it live. `⏏ detach` stops following; `✕ cancel` (shown while a job runs) stops the command itself, part-way, -after a confirmation. Actions that change a host or the rendered artifacts run one at a -time per environment: starting a second is refused with the name of the one running. Read- -only actions — Status, Doctor, Validate — run alongside it. (Before this, the command lived -inside the stream: pressing Status during an Update killed the Update.) +after a confirmation. Actions that change a host or the rendered files run one at a time +per environment: starting a second is refused with the name of the one running. Actions that do not apply are absent rather than disabled: `ssl` only on a Let's Encrypt environment, `migrate` only when the project has a migration command. Step numbers close up -behind anything hidden, so a cluster's flow reads 1-2-3-4-5-6 with no gap where the TLS step -would have been. - -The whitelist and the arrangement live in `webui/panel/actions.py`; the arrangement carries -no authority, so a flow can never render a button `/run` would refuse. - -**Logs** — one button per host; streams `docker compose logs -f`. - -**Terminals** — opens your real OS terminal (Terminal.app, or a Linux emulator), either in -`deployctl/` or ssh'd into a host with your own agent and keys. No shell runs inside the -page. - -**Tutorial** — the whole path from a bare server to a running deployment, in order. It is -not a copy of `docs/`: every command is rendered with *this* environment's real values — -deploy user, `REMOTE_DIR`, domain, host addresses, image repository — so the blocks are -pasteable without editing, which is the difference between a step that works and one that -gets typed wrong at 2am. Where a step is something the panel can already do, it renders the -same whitelisted action button the Deploy tab uses, so the walkthrough performs the deploy -rather than describing it. - -The toggle at the top switches between the single-server and cluster paths; it starts on -the environment's configured `MODE` and hides the steps that do not apply, so nobody -follows a load-balancer instruction on a one-machine deployment. Everything on the page is -static except those buttons — it reads fine with no configuration filled in yet, which is -when it is most useful. - -The environment picker in the top bar switches everything; the chips beside it show mode, -TLS, domain, image and host count, plus a count of configuration errors if there are any. - ---- - -## The recommended flow - -If this is the first deployment, open **Tutorial** and follow it top to bottom — it covers -the server bootstrap that has to happen before any of the below works. Afterwards, the -Deploy tab *is* the procedure: open it and work the top rail left to right. - -1. **Configure** → *Application image* → fetch tags → click the newest → **Save**. -2. **Deploy** → **Regenerate** → **Validate** → **Doctor**, in that order. None of the - three changes a running deployment; each catches what the next assumes. -3. **Deploy** → **Update**, and watch the output pane. -4. **Status**, to confirm every host reports the tag you intended. - -Regenerate is safe to run at any time: secrets are minted once and reused, so it never -invalidates sessions. - -Why that order, and what to edit when a release changes more than the tag: -[30-OPERATIONS.md § Rolling out an update](30-OPERATIONS.md#rolling-out-an-update). +behind anything hidden. The whitelist and the arrangement live in `webui/panel/actions.py`; +the arrangement carries no authority, so no tab can render a button `/run` would refuse. --- diff --git a/docs/60-COMMAND-REFERENCE.md b/docs/60-COMMAND-REFERENCE.md index 6b7f5c9..62af5e5 100644 --- a/docs/60-COMMAND-REFERENCE.md +++ b/docs/60-COMMAND-REFERENCE.md @@ -307,10 +307,11 @@ the tag. ### `deployctl deploy shell HOST --env E` **Touches: hosts (read).** Interactive shell in `REMOTE_DIR`, using your own ssh agent. -### `deployctl deploy history --env E` +### `deployctl deploy history --env E [--json]` **Touches: hosts (read).** The environment's releases, from the history kept on the primary: when, which tag, what kind (release, rollback, first bring-up, one-host roll) and -who — the record `rollback` consults when no `--to` is given. +who — the record `rollback` consults when no `--to` is given. `--json` prints the same +entries, oldest first, for the control panel. ### `deployctl deploy unlock --env E` **Touches: hosts (write).** Every host-changing command (init, update, rollback, migrate, diff --git a/docs/README.md b/docs/README.md index 78fc800..b3e8609 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,12 +13,11 @@ for. | **[30-OPERATIONS.md](30-OPERATIONS.md)** | Day two: rolling updates (what to edit, in what order), rollback, backups, `/docs` auth, extra services, troubleshooting. | | **[35-CONTINUOUS-DEPLOYMENT.md](35-CONTINUOUS-DEPLOYMENT.md)** | Deploying from GitHub Actions on merge: the CI ssh key, pinned host keys, the config secret, switching it on, rolling back. | | **[40-ADOPTING-A-NEW-PROJECT.md](40-ADOPTING-A-NEW-PROJECT.md)** | Dropping the tool into a different repository; migrating from an existing deployment. | -| **[50-WEBUI.md](50-WEBUI.md)** | The control panel: the tabs, the Deploy tab's layout, and the safety model. | +| **[50-WEBUI.md](50-WEBUI.md)** | The control panel: Operate, Setup, CI/CD, Configure and Logs, and the safety model. | | **[60-COMMAND-REFERENCE.md](60-COMMAND-REFERENCE.md)** | Every command, what it changes, every flag, exit codes. Kept honest by a test. | -Prefer to be walked through it? `deployctl webui` → the **Tutorial** tab is the same -ground as 10 and 20, in order, with every command pre-filled from your configuration and a -Run button on the steps the panel can perform itself. +Prefer to be walked through it? `deployctl webui` opens on **Setup** for a new project, +and its stepper names the next step until the environment deploys on every merge. ## The two procedures, in one screen diff --git a/src/deployctl/__init__.py b/src/deployctl/__init__.py index dfec050..080341c 100644 --- a/src/deployctl/__init__.py +++ b/src/deployctl/__init__.py @@ -1,3 +1,3 @@ """deployctl — deploy containerized apps to one server or a fleet over SSH.""" -__version__ = "0.10.0" +__version__ = "0.11.0" diff --git a/src/deployctl/cli/commands/ci.py b/src/deployctl/cli/commands/ci.py index 9d5dbdb..e77ba37 100644 --- a/src/deployctl/cli/commands/ci.py +++ b/src/deployctl/cli/commands/ci.py @@ -568,8 +568,9 @@ def unpack( # ---- ci doctor ----------------------------------------------------------------------- -def _check(items: list[dict], key: str, status: str, title: str, detail: str = "", fix: str = "") -> None: - items.append({"id": key, "status": status, "title": title, "detail": detail, "fix": fix}) +def _check(items: list[dict], key: str, status: str, title: str, detail: str = "", fix: str = "", + **extra: str) -> None: + items.append({"id": key, "status": status, "title": title, "detail": detail, "fix": fix, **extra}) def doctor_items(cfg: Config, repo_level: bool = False) -> list[dict]: @@ -664,7 +665,8 @@ def doctor_items(cfg: Config, repo_level: bool = False) -> list[dict]: auto = repo_values.get("AUTO_DEPLOY", "") _check(items, "auto-deploy", "ok", "Automatic deploys", - f"on — every push to {cfg.raw['DEPLOY_BRANCH']} deploys" if auto == "true" else "off — deploy with `ci deploy`") + f"on — every push to {cfg.raw['DEPLOY_BRANCH']} deploys" if auto == "true" else "off — deploy with `ci deploy`", + value="on" if auto == "true" else "off") return items diff --git a/src/deployctl/cli/commands/deploy.py b/src/deployctl/cli/commands/deploy.py index a5b8750..666c109 100644 --- a/src/deployctl/cli/commands/deploy.py +++ b/src/deployctl/cli/commands/deploy.py @@ -333,10 +333,16 @@ def shell( @deploy_app.command("history") -def history(env: str = env_option()) -> None: +def history( + env: str = env_option(), + as_json: bool = typer.Option(False, "--json", help="Machine-readable, oldest first: when, tag, kind, who."), +) -> None: """The environment's releases, from the primary — the same record CI and laptops write.""" env_name = resolve_env(env) entries = parse_history(_engine_output(load_config(env_name), "history"), env_name) + if as_json: + print(json.dumps({"env": env_name, "entries": entries}, indent=2)) + return if not entries: ui.warn("no releases recorded on the primary yet") raise typer.Exit(2) diff --git a/src/deployctl/cli/commands/validate.py b/src/deployctl/cli/commands/validate.py index c5263e6..b6927b9 100644 --- a/src/deployctl/cli/commands/validate.py +++ b/src/deployctl/cli/commands/validate.py @@ -4,7 +4,7 @@ import typer -from .. import checks, ui +from .. import checks, tags, ui from ..config import Problem from ..context import env_option, load_config @@ -33,6 +33,14 @@ def validate( cfg = load_config(env, require_valid=False) ui.header(f"Validate — {cfg.env}") ui.info(f"configuration sources: {' → '.join(cfg.sources) or 'defaults only'}") + # The rendered files are checked against the tag they were rendered with — + # which `setup` remembers — never against a tag from somewhere else. + try: + tag, source = tags.resolve(cfg, None, from_hosts=False) + ui.info(f"image tag {tag} ({source})") + except tags.NoTag: + ui.warn(f"no image tag rendered on this machine yet — the image checks are skipped " + f"(render with: deployctl setup --env {cfg.env} --tag )") print() errors, warnings = _report("Configuration", cfg.validate()) diff --git a/src/deployctl/cli/config.py b/src/deployctl/cli/config.py index 5349238..713f32c 100644 --- a/src/deployctl/cli/config.py +++ b/src/deployctl/cli/config.py @@ -701,7 +701,9 @@ def _derive(env: str, raw: dict[str, str]) -> dict[str, Any]: candidates.insert(0, "http://localhost:3000") cors_origins = ",".join(x for x in candidates if x) - image_ref = f"{raw['IMAGE_REPO']}:{raw['IMAGE_TAG']}" if raw["IMAGE_REPO"] else "" + # Empty until a tag is resolved (cli/tags.py): "repo:" is not an image, and a + # value that looks like one is how it ends up checked against, or shipped. + image_ref = f"{raw['IMAGE_REPO']}:{raw['IMAGE_TAG']}" if raw["IMAGE_REPO"] and raw["IMAGE_TAG"] else "" worker_image_ref = f"{image_ref}-worker" if image_ref and raw["WORKER_BUILD_TARGET"] else image_ref return { diff --git a/src/deployctl/webui/fields/core.toml b/src/deployctl/webui/fields/core.toml index 0f22394..a06a7f8 100644 --- a/src/deployctl/webui/fields/core.toml +++ b/src/deployctl/webui/fields/core.toml @@ -81,7 +81,7 @@ help = "The app is served at https://.." [[section]] id = "image" title = "Application image" -description = "Built by CI (or `deployctl image push`) and pulled by every host. Pin an immutable git-sha tag — never 'latest'." +description = "Built by CI and pulled by every host. Which tag runs is not configured here: CI deploys the tag it built, and Operate deploys the one you pick." [[section.field]] key = "IMAGE_REPO" @@ -90,14 +90,6 @@ target = "common" required = true placeholder = "ghcr.io/your-org/your-app" -[[section.field]] -key = "IMAGE_TAG" -label = "Image tag" -target = "env" -required = true -placeholder = "fb31c25" -help = "The git short SHA to deploy. Use the tag picker to fetch recent ones from the registry." - [[section]] id = "runtime" title = "Runtime" @@ -145,3 +137,15 @@ target = "env" type = "select" options = ["false", "true"] help = "/docs is additionally protected by the htpasswd file on each host." + +[[section]] +id = "cicd" +title = "Continuous deployment" +description = "Which branch deploys. The rest of CI/CD — keys, secrets, workflows — is set up from the CI/CD tab." + +[[section.field]] +key = "DEPLOY_BRANCH" +label = "Deploy branch" +target = "common" +placeholder = "main" +help = "A merge here builds the image and, with automatic deploys on, ships it. Generate workflows again after changing it." diff --git a/src/deployctl/webui/panel/actions.py b/src/deployctl/webui/panel/actions.py index 0aa7169..8317389 100644 --- a/src/deployctl/webui/panel/actions.py +++ b/src/deployctl/webui/panel/actions.py @@ -1,31 +1,64 @@ """ -The whitelist of actions the browser may trigger, and the order they go in. - -Two things live here, and the split matters: +The whitelist of actions the browser may trigger, and where each one sits. ``ACTIONS`` the security boundary. Each entry maps an id to the exact ``deployctl`` - argv it runs, and ``/run`` refuses anything not in this table. Adding a - capability to the panel means adding a row here — there is no other way - in, and nothing is assembled from user input. -``FLOWS`` / the teaching layer. The same actions again, arranged in the order an -``GRIDS`` operator actually performs them, with the configuration edits that - belong between them. This carries no authority: an id that appears in a - flow but not in ACTIONS simply does not render. - -The arrangement is the point. A flat list of buttons labelled Regenerate, Validate, -Doctor and Update tells you what exists but not that they are a sequence, that the -tag is edited before the first of them, or that Update is the only one of the four -that touches a host. Ordering them left to right, numbered, with the edit step -included, makes the release procedure the shape of the screen — so it can be -followed rather than remembered. + argv it runs, and ``/run`` refuses anything not in this table. The only + values filled in are the environment and the action's declared + ``params`` — each checked against its pattern and passed as one whole + argument, never through a shell. Adding a capability to the panel + means adding a row here; nothing is assembled from user input. +``FLOWS`` / where the actions appear. A flow is an ordered procedure, drawn as +``GRIDS`` numbered steps; a grid is a group with no order. Each belongs to one + tab. They carry no authority: an id that is not in ACTIONS, or does + not apply to this environment, simply does not render. +``ci_fix`` which action fixes each row of ``ci doctor``'s checklist. + +The arrangement is part of the interface. "Sync config" and "Deploy the change" +as two unrelated buttons do not say that one must come before the other, or that +the edit in Configure comes before both; drawn left to right and numbered, the +procedure is the shape of the screen and can be followed rather than remembered. """ from __future__ import annotations import dataclasses +import re +from collections.abc import Mapping +from deployctl.cli import tags from deployctl.cli.config import Config +#: Where an action acts, as the card says it. A click's blast radius should be +#: legible before the click. +LOCAL = "this machine" +HOSTS = "every host" +GITHUB = "GitHub" +HOSTS_AND_GITHUB = "every host + GitHub" +THROUGH_GITHUB = "GitHub Actions → every host" + + +@dataclasses.dataclass(frozen=True) +class Param: + """A value an action takes from the page — checked here, whatever the page did.""" + + name: str + pattern: re.Pattern + label: str + placeholder: str = "" + + +#: The one value the panel passes to a command. Same alphabet as the CLI's own +#: check (cli/tags.py), because the tag ends up in shell commands on the hosts. +TAG = Param("tag", tags.TAG_RE, "Image tag", "e.g. 8e3e648") + + +class ParamError(ValueError): + """A request carried a value the action does not declare, or one that fails its pattern.""" + + +#: Query parameters every request may carry that are not action values. +_ROUTING = frozenset({"env", "t"}) + @dataclasses.dataclass(frozen=True) class Action: @@ -33,13 +66,14 @@ class Action: id: str label: str - argv: tuple[str, ...] # deployctl arguments; {env} is substituted + argv: tuple[str, ...] # deployctl arguments; "{env}" and "{}" are whole arguments desc: str #: What this leaves changed. Shown on the card so the blast radius of a click #: is legible before it is clicked, not after. - effect: str = "" + effect: str + where: str = LOCAL danger: bool = False # the UI confirms before running it - touches_hosts: bool = False # contacts a server (vs. purely local work) + params: tuple[Param, ...] = () tls_modes: tuple[str, ...] = ("letsencrypt", "loadbalancer", "none") needs_migrate: bool = False # hidden when MIGRATE_CMD is empty #: Rewrites generated// without touching a host. Still exclusive: a @@ -51,89 +85,174 @@ def exclusive(self) -> bool: """Runs alone per environment (see panel/jobs.py); read-only work does not.""" return self.danger or self.local_write + @property + def param_names(self) -> str: + """For the page: which inputs the button reads (space-separated).""" + return " ".join(p.name for p in self.params) + + def values(self, query: Mapping[str, str]) -> dict[str, str]: + """The declared params from a request, checked. Raises :class:`ParamError`.""" + declared = {p.name: p for p in self.params} + extra = sorted(set(query) - _ROUTING - set(declared)) + if extra: + raise ParamError(f"'{self.id}' takes no {', '.join(extra)}") + out = {} + for name, param in declared.items(): + value = str(query.get(name, "")).strip() + if not param.pattern.fullmatch(value): + raise ParamError(f"{param.label.lower()}: {value!r} is not valid" if value + else f"{param.label.lower()} is required") + out[name] = value + return out + + def command(self, env: str, values: Mapping[str, str]) -> list[str]: + """The argv to run — each substituted value is one argument, whole.""" + out = [] + for part in self.argv: + if part == "{env}": + out.append(env) + elif part.startswith("{") and part.endswith("}"): + out.append(values[part[1:-1]]) + else: + out.append(part) + return out + + def shown(self, env: str) -> str: + """The command as a card prints it, a param as .""" + return "deployctl " + " ".join(self.command(env, {p.name: f"<{p.name}>" for p in self.params})) + ACTIONS: dict[str, Action] = { a.id: a for a in ( - # -- local, read-only or idempotent ------------------------------------ - Action("regenerate", "Regenerate", ("setup", "--env", "{env}", "--force"), - "Re-render generated/ from the saved configuration.", - effect="Rewrites local artifacts. Secrets are minted once and reused, so this never logs users out.", - local_write=True), + # -- this machine ------------------------------------------------------- + Action("regenerate", "Regenerate", ("setup", "--env", "{env}", "--force", "--tag", "{tag}"), + "Render generated/ from the saved configuration, for this tag.", + "Rewrites local files only. Secrets are minted once and reused, so this never logs users out.", + params=(TAG,), local_write=True), Action("validate", "Validate", ("validate", "--env", "{env}"), - "Lint the configuration and the rendered artifacts.", - effect="Reads only. Exit 0 clean · 2 warnings · 1 errors to fix."), - Action("dry-run", "Preview update", ("deploy", "update", "--env", "{env}", "--dry-run"), - "Print every ssh, rsync and compose command a rolling update would run.", - effect="Touches nothing — not even ssh. The safest way to see what Update will do."), - # -- read-only, but over ssh ------------------------------------------- + "Lint the configuration and the rendered files.", + "Reads only. Exit 0 clean · 2 warnings · 1 errors to fix."), + Action("dry-run", "Preview", ("deploy", "update", "--env", "{env}", "--tag", "{tag}", "--dry-run"), + "Print every ssh, rsync and compose command an update to this tag would run.", + "Touches nothing — not even ssh.", params=(TAG,)), + Action("ci-init", "Generate workflows", ("ci", "init", "--env", "{env}"), + "Write build-image.yml and deploy.yml into .github/workflows, and ci.yml if there is none.", + "Writes files in this repository. Commit and push them — GitHub runs what is on the branch."), + Action("ci-init-force", "Regenerate workflows", ("ci", "init", "--env", "{env}", "--force"), + "Replace the two managed workflows with this version's. ci.yml is yours and never touched.", + "Overwrites build-image.yml and deploy.yml. Review with git diff, then commit and push."), + # -- every host, reading -------------------------------------------------- Action("doctor", "Doctor", ("deploy", "doctor", "--env", "{env}"), - "Per host: ssh, docker without sudo, writable remote dir, image pullable, architecture.", - effect="Reads every host. Changes nothing.", touches_hosts=True), + "Per host: ssh, docker without sudo, writable directory, the running image pullable.", + "Reads every host. Changes nothing.", where=HOSTS), + Action("doctor-tag", "Doctor", ("deploy", "doctor", "--env", "{env}", "--tag", "{tag}"), + "Per host: ssh, docker without sudo, writable directory, and that this tag can be pulled.", + "Reads every host. Changes nothing.", where=HOSTS, params=(TAG,)), Action("status", "Status", ("deploy", "status", "--env", "{env}"), - "Service status and the currently deployed tag on every host.", - effect="Reads every host. Changes nothing.", touches_hosts=True), + "Every service on every host, and the tag each host runs.", + "Reads every host. Changes nothing.", where=HOSTS), Action("backup-list", "Backups", ("backup", "list", "--env", "{env}"), - "List database dumps on the host and locally.", - effect="Reads only.", touches_hosts=True), + "List the database dumps on the host and on this machine.", + "Reads only.", where=HOSTS), Action("ssl-check", "SSL: check", ("ssl", "check", "--env", "{env}"), "Show the certificate's issuer and expiry.", - effect="Reads only.", touches_hosts=True, tls_modes=("letsencrypt",)), - # -- mutating ---------------------------------------------------------- - Action("init", "Init", ("deploy", "init", "--env", "{env}"), + "Reads only.", where=HOSTS, tls_modes=("letsencrypt",)), + # -- every host, changing ------------------------------------------------- + Action("init", "Init", ("deploy", "init", "--env", "{env}", "--tag", "{tag}"), "First bring-up: push, pull, migrate once, then start primary → rest.", - effect="Starts the stack on every host. Run once per environment.", - danger=True, touches_hosts=True), - Action("update", "Update", ("deploy", "update", "--env", "{env}"), - "Rolling release, one host at a time, health-gated.", - effect="Replaces running containers — each host answers 502 for a few seconds while its api " - "restarts. Stops at the first host that fails its health gate, leaving the rest on the " - "previous release.", - danger=True, touches_hosts=True), + "Starts the stack on every host. Once per environment.", + where=HOSTS, danger=True, params=(TAG,)), + Action("ssl-setup", "SSL: obtain", ("ssl", "setup", "--env", "{env}"), + "Get the real Let's Encrypt certificate, replacing the bootstrap one.", + "Writes certificates on the host and reloads nginx.", + where=HOSTS, danger=True, tls_modes=("letsencrypt",)), + Action("update", "Update from this machine", ("deploy", "update", "--env", "{env}", "--tag", "{tag}"), + "Rolling release straight over ssh — no GitHub. Same lock, health gate and revert as CI.", + "Replaces running containers host by host. A host that fails is reverted, and so is every " + "host already moved. Refuses a tag older than the running one.", + where=HOSTS, danger=True, params=(TAG,)), + Action("rollback", "Roll back from this machine", ("deploy", "rollback", "--env", "{env}"), + "Re-deploy the release before the running one, straight over ssh.", + "Moves the image back, from the history on the primary. Runs no migrations and reverts none.", + where=HOSTS, danger=True), Action("migrate", "Migrate", ("deploy", "migrate", "--env", "{env}"), - "Run the migration command once, on the primary.", - effect="Changes the database schema. Update already does this — use it alone only to " - "re-run a migration that failed.", - danger=True, touches_hosts=True, needs_migrate=True), - Action("rollback", "Rollback", ("deploy", "rollback", "--env", "{env}"), - "Re-deploy the previously recorded image tag, health-gated.", - effect="Moves the image back to the release before the running one, from the history on the " - "primary. Runs no migrations and reverts none.", - danger=True, touches_hosts=True), + "Run the migration command once, on the primary, with the running image.", + "Changes the database schema. Every deploy already does this — use it alone only to " + "re-run a migration that failed.", + where=HOSTS, danger=True, needs_migrate=True), Action("restart", "Restart", ("deploy", "restart", "--env", "{env}"), "Restart services on every host, health-gated.", - effect="Brief per-host interruption. Same image, same config.", - danger=True, touches_hosts=True), + "Brief per-host interruption. Same image, same config.", + where=HOSTS, danger=True), Action("stop", "Stop", ("deploy", "stop", "--env", "{env}"), - "compose down on every host.", - effect="The site goes DOWN and stays down until Init or Update.", - danger=True, touches_hosts=True), - Action("ssl-setup", "SSL: obtain", ("ssl", "setup", "--env", "{env}"), - "Get the real Let's Encrypt certificate, replacing the bootstrap one.", - effect="Writes certificates on the host and reloads nginx.", - danger=True, touches_hosts=True, tls_modes=("letsencrypt",)), - Action("backup-run", "Backup now", ("backup", "run", "--env", "{env}"), - "pg_dump on the primary, prune old dumps, fetch a local copy.", - effect="Writes a dump on the host and a copy in ~/.deployctl/backups/ on this machine.", - danger=True, touches_hosts=True), + "docker compose down on every host.", + "The site goes DOWN and stays down until a deploy.", + where=HOSTS, danger=True), + Action("backup-run", "Back up now", ("backup", "run", "--env", "{env}"), + "pg_dump on the primary, prune old dumps, fetch a copy.", + "Writes a dump on the host and a copy in ~/.deployctl/backups/ on this machine.", + where=HOSTS, danger=True), + # -- GitHub --------------------------------------------------------------- + Action("ci-connect", "Connect GitHub", ("ci", "connect", "--env", "{env}"), + "Find the repository and its plan; decide where secrets live.", + "Writes CI_SCOPE (and nothing else) into config/common.env.", where=GITHUB), + Action("ci-setup-key", "Create the CI key", ("ci", "setup-key", "--env", "{env}"), + "A CI-only ssh key: installed on every host with your access, proven, private half into GitHub.", + "Adds a restricted key to the deploy user on every host, and the DEPLOY_SSH_KEY secret. " + "Only its fingerprint is shown; GitHub holds the only copy.", + where=HOSTS_AND_GITHUB, danger=True), + Action("ci-rotate-key", "Rotate the CI key", ("ci", "setup-key", "--env", "{env}", "--rotate"), + "Replace the CI key on every host and in GitHub.", + "The old key stops working everywhere, at once.", where=HOSTS_AND_GITHUB, danger=True), + Action("ci-pin-hosts", "Pin host keys", ("ci", "pin-hosts", "--env", "{env}"), + "Give GitHub the host keys this machine already trusts.", + "Sets the DEPLOY_KNOWN_HOSTS variable. CI refuses any other key.", where=GITHUB), + Action("ci-sync", "Sync config", ("ci", "sync-config", "--env", "{env}"), + "Upload this machine's config/ to GitHub, for the deploy workflow.", + "Sets the DEPLOYCTL_CONFIG secret and its digest. Deploys nothing.", where=GITHUB), + Action("ci-auto-on", "Turn on", ("ci", "auto-deploy", "on", "--env", "{env}"), + "Every push to the deploy branch that passes its checks deploys itself.", + "Sets AUTO_DEPLOY=true on the repository.", where=GITHUB), + Action("ci-auto-off", "Turn off", ("ci", "auto-deploy", "off", "--env", "{env}"), + "Merges build an image but no longer deploy it.", + "Sets AUTO_DEPLOY=false on the repository.", where=GITHUB), + Action("ci-deploy", "Deploy", ("ci", "deploy", "--env", "{env}", "--tag", "{tag}"), + "Run the deploy workflow for this tag, and follow it here.", + "Rolls every host to the tag, health-gated. A failed release is reverted everywhere.", + where=THROUGH_GITHUB, danger=True, params=(TAG,)), + Action("ci-redeploy", "Redeploy what's running", ("ci", "deploy", "--env", "{env}"), + "Run the deploy workflow for the tag the hosts already run.", + "Nothing changes but the proof: CI's key, config and workflow all work.", + where=THROUGH_GITHUB, danger=True), + Action("ci-apply", "Deploy the change", ("ci", "deploy", "--env", "{env}", "--allow-config-change"), + "Redeploy the running tag with GitHub's copy of the config.", + "Restarts every host with the new values, health-gated.", + where=THROUGH_GITHUB, danger=True), + Action("ci-rollback", "Roll back", ("ci", "deploy", "--env", "{env}", "--rollback"), + "Re-deploy the release before the running one, through GitHub.", + "Moves the image back. Runs no migrations and reverts none.", + where=THROUGH_GITHUB, danger=True), + Action("ci-rollback-to", "Roll back to this", ("ci", "deploy", "--env", "{env}", "--rollback", "--tag", "{tag}"), + "Re-deploy this earlier release, through GitHub.", + "Moves the image back. Runs no migrations and reverts none.", + where=THROUGH_GITHUB, danger=True, params=(TAG,)), ) } @dataclasses.dataclass(frozen=True) class Edit: - """A step performed in the Configure tab rather than by a command. + """A step done in the Configure tab rather than by a command. Flows include these because leaving them out is what makes a documented - procedure wrong: "Regenerate → Validate → Doctor → Update" silently assumes - somebody already changed the tag, which is the one step a release cannot skip. + procedure wrong: "Sync → Deploy" silently assumes somebody already changed a + value, which is the step the procedure exists for. """ label: str desc: str - #: Config keys this step edits — rendered so it is clear what "edit" means here. - keys: tuple[str, ...] = () - #: Section id in the Configure tab to open and scroll to. + #: Section id in the Configure tab to open and scroll to ("" = the top). section: str = "" @@ -142,10 +261,10 @@ class Flow: """An ordered procedure, rendered left to right as numbered steps.""" id: str + tab: str title: str desc: str steps: tuple[str | Edit, ...] # action ids, interleaved with Edit steps - lead: bool = False # the everyday path — rendered first and open by default @dataclasses.dataclass(frozen=True) @@ -153,80 +272,72 @@ class Grid: """Related actions with no inherent order, rendered as a plain group.""" id: str + tab: str title: str desc: str action_ids: tuple[str, ...] tone: str = "safe" # "safe" | "danger" -#: The two procedures. Everything else on the tab is a grid, because everything -#: else genuinely has no order — you run Status or Logs whenever you want to. FLOWS: tuple[Flow, ...] = ( Flow( - "release", - "Roll out a new version", - "The everyday path. Work left to right — each step assumes the one before it passed.", - ( - Edit( - "Set the image tag", - "In Application image, fetch tags and click the newest, then Save. On a routine " - "release this is the ONLY value that changes.", - keys=("IMAGE_TAG",), - section="image", - ), - "regenerate", - "validate", - "doctor", - "update", - "status", - ), - lead=True, + "first-deploy", "setup", "First deploy", + "Once per environment, on servers the bootstrap script has prepared. Pick the tag CI " + "published, then work left to right — each step assumes the one before it passed.", + ("regenerate", "validate", "doctor-tag", "init", "ssl-setup", "status"), ), Flow( - "first", - "First deployment", - "Once per environment, on hosts that already have Docker and the deploy user. " - "The Tutorial tab covers getting a bare server to that point.", + "apply-config", "operate", "Apply a config change", + "A new key, a worker count, a password. The deploy refuses a value that differs from the " + "servers' unless it is shipped on purpose — this is on purpose.", ( - Edit( - "Fill in the configuration", - "Work down the Configure tab and Save. Hosts, domain, registry, image, database.", - keys=("PROJECT_NAME", "BASE_DOMAIN", "HOSTS", "IMAGE_REPO", "IMAGE_TAG"), - section="project", - ), - "regenerate", - "validate", - "doctor", - "init", - "ssl-setup", - "status", + Edit("Change it in Configure", "Edit the value and press Save. Nothing leaves this machine yet."), + "ci-sync", + "ci-apply", ), ), ) GRIDS: tuple[Grid, ...] = ( - Grid( - "inspect", - "Check & observe", - "Safe to run at any time, including mid-incident. None of these change anything.", - ("status", "validate", "doctor", "dry-run", "backup-list", "ssl-check"), - ), - Grid( - "recover", - "Recover", - "When a release went wrong. Rollback is almost always the right first move.", - ("rollback", "restart", "migrate", "backup-run"), - tone="danger", - ), - Grid( - "danger", - "Take it down", - "Deliberate outage. Nothing here comes back on its own.", - ("stop",), - tone="danger", - ), + Grid("release", "operate", "Deploy a version", + "Through GitHub, with the same workflow a merge uses — one history, one lock.", + ("ci-deploy", "ci-rollback")), + Grid("maintenance", "operate", "Maintenance", + "Checks change nothing. Restart and Back up now ask first.", + ("status", "doctor", "restart", "backup-list", "backup-run", "ssl-check")), + Grid("emergency", "operate", "Emergency: act from this machine", + "For when GitHub is down or CI is broken. Straight over ssh with your own key — the same " + "engine, lock, health gate and revert, but outside CI's history of runs.", + ("dry-run", "update", "rollback", "migrate", "stop"), tone="danger"), + Grid("prove", "cicd", "Prove it", + "A deploy of what is already running, through GitHub. Run it once after setup.", + ("ci-redeploy",)), ) +#: Rendered per row rather than in a group: each release in the history gets one. +ROW_ACTIONS = ("ci-rollback-to",) + +#: ``ci doctor`` row id → the action that fixes it (rows with no entry are fixed +#: by hand — ``gh auth login``, a token — and show their command instead). +_CI_FIXES = {"scope": "ci-connect", "host-keys": "ci-pin-hosts", "config": "ci-sync", "workflow:ci": "ci-init"} +CI_FIX_ACTIONS = frozenset(_CI_FIXES.values()) | { + "ci-init", "ci-init-force", "ci-setup-key", "ci-rotate-key", "ci-auto-on", "ci-auto-off", +} + + +def ci_fix(item: Mapping) -> str | None: + """The action for one ``ci doctor --json`` item, or None.""" + key, status = item.get("id", ""), item.get("status", "") + if key == "auto-deploy": + return "ci-auto-off" if item.get("value") == "on" else "ci-auto-on" + if key == "ssh-key": + return "ci-rotate-key" if status == "ok" else "ci-setup-key" + if status == "ok": + return None + if key in ("workflow:build-image.yml", "workflow:deploy.yml"): + return "ci-init-force" if status == "warn" else "ci-init" + return _CI_FIXES.get(key) + def available(cfg: Config) -> list[Action]: """The actions that apply to this environment's shape, in declaration order. @@ -246,16 +357,15 @@ def available(cfg: Config) -> list[Action]: def board(cfg: Config) -> dict: - """The Deploy tab, resolved for one environment. + """Every flow and grid, resolved for one environment and keyed by id. - Steps whose action does not apply to this shape are dropped and the remaining - ones renumbered, so a single-server flow reads 1-2-3-4-5-6-7 with TLS included - and a cluster flow reads 1-2-3-4-5-6 without it — never with a gap where a - hidden step used to be. + Steps whose action does not apply to this shape are dropped and the rest + renumbered, so a flow never shows a gap where a hidden step used to be. A + group left with nothing is absent, and the template skips it. """ usable = {a.id for a in available(cfg)} - flows = [] + flows = {} for flow in FLOWS: steps = [] for entry in flow.steps: @@ -264,12 +374,22 @@ def board(cfg: Config) -> dict: elif entry in usable: steps.append({"kind": "action", "action": ACTIONS[entry], "n": len(steps) + 1}) if steps: - flows.append({"flow": flow, "steps": steps}) + actions = [s["action"] for s in steps if s["kind"] == "action"] + flows[flow.id] = {"flow": flow, "steps": steps, "params": _params_of(actions)} - grids = [] + grids = {} for grid in GRIDS: items = [ACTIONS[i] for i in grid.action_ids if i in usable] if items: - grids.append({"grid": grid, "actions": items}) + grids[grid.id] = {"grid": grid, "actions": items, "params": _params_of(items)} return {"flows": flows, "grids": grids} + + +def _params_of(actions: list[Action]) -> list[Param]: + """The inputs a group renders once, for every button in it that reads them.""" + seen: dict[str, Param] = {} + for action in actions: + for param in action.params: + seen.setdefault(param.name, param) + return list(seen.values()) diff --git a/src/deployctl/webui/panel/live.py b/src/deployctl/webui/panel/live.py new file mode 100644 index 0000000..5ec5854 --- /dev/null +++ b/src/deployctl/webui/panel/live.py @@ -0,0 +1,259 @@ +""" +Live facts about an environment — what the hosts run, what was released, whether +CI/CD is complete — and, from them, how far the project is on its way to live. + +Every fact comes from the CLI's own ``--json`` output (``deploy status``, +``deploy history``, ``ci doctor``, ``ci runs``), run exactly as an operator would +run it. The panel never opens ssh or calls GitHub itself: what it shows is what +the CLI prints, with the operator's own keys and ``gh`` login. + +A read takes seconds, and the page asks for the same fact from several places at +once. So a fact is kept for ``TTL`` seconds, and only one read of a fact runs at a +time: a request that arrives during a read waits for it and shares the result. +""" + +from __future__ import annotations + +import dataclasses +import datetime +import threading +import time +from typing import Any, Callable + +from deployctl.cli import paths +from deployctl.cli.config import Config + +from . import runner + +#: How long a fact is reused. Short: a deploy from another machine should show up +#: on the next refresh, not in minutes. +TTL = 15.0 + +#: The CLI command behind each fact. "{env}" is a whole argument. +SOURCES: dict[str, tuple[str, ...]] = { + "server": ("deploy", "status", "--env", "{env}", "--json"), + "history": ("deploy", "history", "--env", "{env}", "--json"), + "ci": ("ci", "doctor", "--env", "{env}", "--json"), + "runs": ("ci", "runs", "--env", "{env}", "--json", "--limit", "8"), +} + + +@dataclasses.dataclass(frozen=True) +class Fact: + data: Any # the parsed JSON, or None when the read failed + error: str # why it failed, in the CLI's words + at: float # when the read finished + + @property + def ok(self) -> bool: + return self.data is not None + + +class Cache: + def __init__(self, read: Callable = runner.run_json, clock: Callable[[], float] = time.time) -> None: + self._facts: dict[tuple[str, str], Fact] = {} + self._locks: dict[tuple[str, str], threading.Lock] = {} + self._guard = threading.Lock() + self._read = read + self._clock = clock + + def get(self, env: str, source: str, *, fresh: bool = False) -> Fact: + """A fact, read now or reused. + + ``fresh`` asks for a read that finished after this request was made — such + as one another request just ran, so the burst of refreshes the page sends + when a job ends costs one read, not one per panel. + """ + asked = self._clock() + key = (env, source) + with self._guard: + lock = self._locks.setdefault(key, threading.Lock()) + with lock: + fact = self._facts.get(key) + if fact and (fact.at >= asked if fresh else asked - fact.at < TTL): + return fact + argv = [env if part == "{env}" else part for part in SOURCES[source]] + data, error = self._read(argv) + fact = Fact(data, error, self._clock()) + self._facts[key] = fact + return fact + + def peek(self, env: str, source: str) -> Fact | None: + """What is cached, without reading — for places that must not wait on ssh.""" + return self._facts.get((env, source)) + + +CACHE = Cache() + + +# ---- where the project is ------------------------------------------------------------ + +#: The four stages, in order. The stepper under the top bar draws these. +STAGES = (("server", "Server"), ("first", "First deploy"), ("cicd", "CI/CD"), ("live", "Live")) + + +def local_facts(cfg: Config) -> dict: + """What this machine's files say — cheap, so the page can decide without waiting.""" + errors = [p for p in cfg.validate() if p.level == "error"] + return { + "configured": bool(cfg.hosts) and not errors, + "workflows": (paths.REPO_ROOT / ".github" / "workflows" / "deploy.yml").is_file(), + "ci_scope": bool(cfg.raw.get("CI_SCOPE")), + "branch": cfg.raw.get("DEPLOY_BRANCH") or "main", + } + + +def landing(local: dict) -> str: + """The tab to open on, from local facts alone: Operate once CI/CD is set up, Setup before. + + Deliberately not from live facts: those take seconds, and a page that switches + tab under the operator's cursor when they arrive is worse than one that opens a + tab away from ideal. The "Next step" banner says where to go once they are in. + """ + if local["configured"] and local["workflows"] and local["ci_scope"]: + return "operate" + return "setup" + + +def journey(local: dict, server: Fact | None, ci: Fact | None) -> dict: + """Each stage as done / todo / fail / unknown, and the one thing to do next.""" + stage: dict[str, tuple[str, str]] = {} + + hosts = server.data.get("hosts", []) if server and server.ok else [] + if not local["configured"]: + stage["server"] = ("todo", "the configuration is not complete") + elif not (server and server.ok): + stage["server"] = ("unknown", server.error if server else "not checked yet") + else: + down = [h["host"] for h in hosts if not h.get("reachable")] + stage["server"] = ("fail", f"cannot reach {', '.join(down)}") if down else ("done", f"{len(hosts)} reachable") + + if stage["server"][0] == "done": + running = [h for h in hosts if h.get("tag")] + stage["first"] = ("done", server.data.get("tag") or "running") if running else ("todo", "nothing deployed yet") + else: + stage["first"] = ("unknown", "") + + items = ci.data.get("items", []) if ci and ci.ok else None + if items is None: + stage["cicd"] = ("unknown", ci.error if ci else "not checked yet") + else: + missing = [i for i in items if i["status"] in ("todo", "fail")] + stage["cicd"] = ("todo", missing[0]["title"]) if missing else ("done", "complete") + + done = all(stage[s][0] == "done" for s in ("server", "first", "cicd")) + stage["live"] = ("done", "") if done else ("todo", "") + + return { + "stages": [{"id": key, "label": label, "status": stage[key][0], "detail": stage[key][1]} + for key, label in STAGES], + "next": _next(stage, local), + } + + +def _next(stage: dict[str, tuple[str, str]], local: dict) -> dict: + """The single next step: what to do, and which tab it is done in.""" + status, detail = stage["server"] + if status == "todo": + return {"text": "Fill in the configuration, then prepare the server.", "tab": "setup"} + if status == "fail": + return {"text": f"Can't reach the server ({detail.removeprefix('cannot reach ')}). " + "A new one? Run the bootstrap script on it.", "tab": "setup"} + if stage["first"][0] == "todo": + return {"text": "Nothing is running yet — do the first deploy.", "tab": "setup"} + if stage["cicd"][0] == "todo": + return {"text": f"Finish CI/CD: {stage['cicd'][1]}.", "tab": "cicd"} + if all(stage[s][0] == "done" for s in ("server", "first", "cicd")): + # Nothing to go and do: no button, just the everyday rule. + return {"text": f"Live. Ship by merging to {local['branch']}.", "tab": ""} + return {"text": "Checking the servers and GitHub…", "tab": ""} + + +def service_problems(server: dict) -> list[str]: + """What is wrong on the hosts right now, one line each; empty when all is well. + + The same rules as the deploy's service watch (``services_problems`` in + scripts/common/remote.sh), minus "restarted since the release", which needs a + baseline only a deploy has: a one-shot service that exited 0 is fine, anything + else not running is not, and neither is a failing healthcheck. + """ + out = [] + for host in server.get("hosts", []): + if not host.get("reachable"): + out.append(f"{host['host']}: unreachable") + continue + # A service can have an exited one-off beside its live container; the + # running one is the one that counts. + by_name: dict[str, dict] = {} + for svc in host.get("services", []): + if svc["name"] not in by_name or svc["state"] == "running": + by_name[svc["name"]] = svc + for svc in by_name.values(): + if svc["state"] == "exited" and svc.get("exit_code") == "0" and svc.get("restart_policy") == "no": + continue + if svc["state"] != "running": + out.append(f"{host['host']}: {svc['name']} {svc['state']}") + elif svc.get("health") == "unhealthy": + out.append(f"{host['host']}: {svc['name']} unhealthy") + return out + + +def history_rows(entries: list[dict], running: str | None, limit: int = 10) -> list[dict]: + """The primary's release history, newest first, as the Operate tab lists it. + + "Roll back to this" is offered once per earlier release — on its newest entry, + and only for a real release (a deploy or the first bring-up), never for the + tag already running or for a rollback, which is not a new release. + """ + rows, offered, marked = [], set(), False + for entry in reversed(entries): + tag, kind = entry.get("tag", ""), entry.get("kind", "") + can_roll_back = kind in ("deploy", "init") and tag != running and tag not in offered + if can_roll_back: + offered.add(tag) + # Only the newest entry for the running tag: older ones are history. + is_running = tag == running and not marked + marked = marked or is_running + rows.append({ + **entry, + "ago": ago(entry.get("at", "")), + "who": who(entry.get("by", "")), + "running": is_running, + "roll_back": can_roll_back, + }) + if len(rows) == limit: + break + return rows + + +def ci_item(ci: Fact | None, key: str) -> dict | None: + """One row of ``ci doctor``'s checklist, by id.""" + if not (ci and ci.ok): + return None + return next((item for item in ci.data.get("items", []) if item["id"] == key), None) + + +# ---- presentation helpers, shared by the fragments ----------------------------------- + + +def ago(stamp: str, now: datetime.datetime | None = None) -> str: + """ "2h ago" for an ISO-8601 UTC stamp such as the hosts record; "" if unreadable.""" + try: + when = datetime.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + except (AttributeError, ValueError): + return "" + now = now or datetime.datetime.now(datetime.timezone.utc) + seconds = int((now - when).total_seconds()) + if seconds < 90: + return "just now" + for size, unit in ((86400, "d"), (3600, "h"), (60, "m")): + if seconds >= size: + return f"{seconds // size}{unit} ago" + return "just now" + + +def who(by: str) -> dict: + """Who deployed, for display: an Actions run links to it; ``user@machine`` is shown as is.""" + if by.startswith("https://") and "/actions/runs/" in by: + return {"label": "GitHub Actions", "url": by} + return {"label": by or "unknown", "url": ""} diff --git a/src/deployctl/webui/panel/routes.py b/src/deployctl/webui/panel/routes.py index e073d0b..d7b56ba 100644 --- a/src/deployctl/webui/panel/routes.py +++ b/src/deployctl/webui/panel/routes.py @@ -1,8 +1,9 @@ """ The HTTP surface — every route the browser can reach. -Routes stay thin: parse, call into state/actions/runner, render. Everything is -whitelisted — ``/run`` only accepts ids from ``ACTIONS``, environment names are +Routes stay thin: parse, call into state/actions/live/runner, render. Everything +is whitelisted — ``/run`` only accepts ids from ``ACTIONS`` and only the values an +action declares, ``/live`` only the parts in ``LIVE_PARTS``, environment names are checked against the configured set, and host-scoped routes reject any address not in the environment's host list. """ @@ -11,6 +12,7 @@ import html import shlex +from concurrent.futures import ThreadPoolExecutor from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse @@ -18,8 +20,8 @@ from deployctl.cli import paths, registry -from . import jobs, security, state -from .actions import ACTIONS, available, board +from . import jobs, live, security, state +from .actions import ACTIONS, ROW_ACTIONS, ParamError, available, board, ci_fix from .runner import run_capture, sse_run from .terminal import open_native_terminal, term_result @@ -28,6 +30,20 @@ _SSE = "text/event-stream" +#: The tabs, in order. The first one a page opens on is decided by live.landing(). +TABS = (("operate", "Operate"), ("setup", "Setup"), ("cicd", "CI/CD"), ("configure", "Configure"), ("logs", "Logs")) + +#: Live fragments: part → the facts it needs (see live.SOURCES). A fragment is +#: rendered from templates/_live_.html. +LIVE_PARTS: dict[str, tuple[str, ...]] = { + "bar": ("server", "ci"), + "journey": ("server", "ci"), + "production": ("server",), + "history": ("server", "history"), + "checklist": ("ci",), + "runs": ("runs",), +} + def _env_or_404(requested: str | None) -> str: """Resolve the environment, refusing an unknown name rather than substituting one.""" @@ -45,25 +61,20 @@ def index(request: Request, env: str | None = None): """The single-page panel for one environment.""" env = _env_or_404(env) cfg = state.load(env) - actions = available(cfg) - hosts = cfg.hosts + local = live.local_facts(cfg) return templates.TemplateResponse(request, "index.html", { "env": env, "envs": state.environments(), - "mode": cfg.mode, - "tls": cfg.tls_mode, - "sections": state.view_sections(env), + "tabs": TABS, + # Opened from this machine's files alone, so the page never waits on ssh. + "landing": live.landing(local), + "local": local, "summary": state.summary(env), - # The Deploy tab: procedures first, then the unordered groups. + "sections": state.view_sections(env), "board": board(cfg), - "hosts": hosts, + "bootstrap": state.bootstrap(env), + "hosts": cfg.hosts, "primary": cfg.primary_host, - "term_targets": [{"kind": "local", "host": "", "label": "Local"}] - + [{"kind": "ssh", "host": h, "label": h} for h in hosts], - "tut": state.tutorial(env), - # The tutorial renders a Run button only where the action exists for this - # environment's shape, so it never offers a button that /run would refuse. - "action_ids": {a.id for a in actions}, # Jobs keep running across reloads, so a fresh page has to offer them. "jobs": jobs.REGISTRY.recent(), # Every acting request carries this; see panel/security.py. @@ -71,6 +82,9 @@ def index(request: Request, env: str | None = None): }) +# ---- configuration ------------------------------------------------------------------- + + @router.post("/config", response_class=HTMLResponse) async def config_post(request: Request, env: str): """Persist the form into the config files (the same ones the CLI reads).""" @@ -91,24 +105,29 @@ async def config_post(request: Request, env: str): try: applied = state.save_form(env, values) - total = sum(applied.values()) - where = ", ".join(f"{k} ({n})" for k, n in applied.items()) or "nothing" - note = html.escape(f"Saved {total} value(s) to {where}.") - note += " Next: Regenerate → Validate → Doctor → Update." - ok = True except Exception as exc: # noqa: BLE001 — show the failure in the save chip # Escaped: the message can quote a value that came from the form. - note, ok = html.escape(f"Save failed: {exc}"), False - - # A class, not a style attribute: the panel's CSP is `style-src 'self'` - # and blocks inline styles, so the colour would never be applied. - tone = "ok" if ok else "err" - # What a confirmation dialog names from now on (panel.js reads these back): - # a Save can change the tag or the hosts without reloading the page. - summary = state.summary(env) - image = html.escape(summary["image"], quote=True) - hosts = html.escape(" ".join(summary["hosts"]), quote=True) - return HTMLResponse(f'{note}') + return HTMLResponse(f'{html.escape(f"Save failed: {exc}")}') + + total = sum(applied.values()) + where = ", ".join(f"{k} ({n})" for k, n in applied.items()) or "nothing" + note = html.escape(f"Saved {total} value(s) to {where}. Nothing has left this machine yet.") + # A class, not a style attribute: the panel's CSP is `style-src 'self'`. + # data-hosts: what a confirmation dialog names from now on (panel.js reads it + # back), since a Save can change the hosts without reloading the page. + hosts = html.escape(" ".join(state.summary(env)["hosts"]), quote=True) + return HTMLResponse( + f'{note} ' + '' + ) + + +@router.get("/config/problems", response_class=HTMLResponse) +def config_problems(request: Request, env: str): + """What validation says about the saved configuration — re-fetched after each Save.""" + env = _env_or_404(env) + return templates.TemplateResponse(request, "_problems.html", {"summary": state.summary(env)}) @router.get("/config/preview", response_class=PlainTextResponse) @@ -119,19 +138,42 @@ def config_preview(env: str): return out +# ---- actions and jobs ---------------------------------------------------------------- + + +async def _refused(message: str): + """SSE that explains, in the output pane, why nothing was started.""" + yield f"data: refused: {message}\n\n" + yield "event: done\ndata: refused\n\n" + + @router.get("/run/{action_id}") -def run_action(action_id: str, env: str): - """Start one whitelisted action as a job and stream it; 404 for anything else.""" +def run_action(request: Request, action_id: str, env: str): + """Start one whitelisted action as a job and stream it. + + 404 for an id outside the whitelist. A value the action does not declare, a + value given twice, or one that fails its pattern is refused before anything + starts — explained in the output pane, where the operator is looking. + """ env = _env_or_404(env) action = ACTIONS.get(action_id) if action is None: raise HTTPException(status_code=404, detail="unknown action") - cfg = state.load(env) - if action not in available(cfg): + if action not in available(state.load(env)): raise HTTPException(status_code=400, detail=f"action '{action_id}' does not apply to this environment") - argv = [part.format(env=env) for part in action.argv] + + items = request.query_params.multi_items() + if len({key for key, _ in items}) != len(items): + return StreamingResponse(_refused("a value was given twice"), media_type=_SSE) + try: + values = action.values(dict(items)) + except ParamError as exc: + return StreamingResponse(_refused(str(exc)), media_type=_SSE) + + argv = action.command(env, values) + label = " ".join([action.label, *values.values()]) try: - job = jobs.REGISTRY.start(env=env, label=action.label, argv=argv, exclusive=action.exclusive) + job = jobs.REGISTRY.start(env=env, label=label, argv=argv, exclusive=action.exclusive) except jobs.Busy as busy: return StreamingResponse(jobs.refused(busy.job), media_type=_SSE) return StreamingResponse(jobs.stream(job), media_type=_SSE) @@ -170,21 +212,40 @@ def logs(host: str, env: str): return StreamingResponse(sse_run(["deploy", "logs", "--env", env, "--host", host]), media_type=_SSE) -@router.get("/status", response_class=HTMLResponse) -def status(request: Request, env: str): - """Quick cross-host status for the top-bar chip.""" +# ---- live facts ---------------------------------------------------------------------- + + +@router.get("/live/{part}", response_class=HTMLResponse) +def live_part(request: Request, part: str, env: str, fresh: int = 0): + """One live fragment: the top bar, the stepper, the production card, the history, + the CI/CD checklist or the recent runs. ``fresh=1`` after a job ends.""" env = _env_or_404(env) - rc, out = run_capture(["deploy", "status", "--env", env]) - return templates.TemplateResponse(request, "_status.html", { - "out": out, - "healthy": rc == 0, - "summary": state.summary(env), - }) + sources = LIVE_PARTS.get(part) + if sources is None: + raise HTTPException(status_code=404, detail="unknown part") + # The facts a part needs are independent reads (ssh, GitHub): run them side by side. + with ThreadPoolExecutor(max_workers=len(sources)) as pool: + read = pool.map(lambda source: live.CACHE.get(env, source, fresh=bool(fresh)), sources) + facts = dict(zip(sources, read)) + + cfg = state.load(env) + context = {"env": env, "live": live, **facts} + if part == "journey": + context["journey"] = live.journey(live.local_facts(cfg), facts["server"], facts["ci"]) + if part in ("checklist", "history"): + # Buttons render only for actions that apply here — the same list /run checks. + context["usable"] = {a.id: a for a in available(cfg)} + context["ci_fix"] = ci_fix + context["row_action"] = ACTIONS[ROW_ACTIONS[0]] + if part == "history" and facts["history"].ok: + running = facts["server"].data.get("tag") if facts["server"].ok else None + context["rows"] = live.history_rows(facts["history"].data.get("entries", []), running) + return templates.TemplateResponse(request, f"_live_{part}.html", context) @router.get("/image-tags", response_class=HTMLResponse) def image_tags(env: str): - """Recent registry tags as clickable cards that fill the IMAGE_TAG input.""" + """Recent registry tags as cards; a click fills the tag input beside the picker.""" env = _env_or_404(env) cfg = state.load(env) repo = cfg.raw.get("IMAGE_REPO", "") @@ -198,15 +259,19 @@ def image_tags(env: str): if not tags: return HTMLResponse('No git-sha tags found for this image.') - current = cfg.raw.get("IMAGE_TAG", "") + # Marked from what is already known — never an ssh round trip just to draw a badge. + server = live.CACHE.peek(env, "server") + running = server.data.get("tag") if server and server.ok else "" parts = ["
"] for index, tag in enumerate(tags): classes = "tag-card" - if tag.name == current: - classes += " tag-active" + badges = "" if index == 0: classes += " tag-newest" - badge = ' newest' if index == 0 else "" + badges += ' newest' + if tag.name == running: + classes += " tag-running" + badges += ' running' # tag.name is already constrained to [0-9a-f]{7,12} by registry._is_sha_tag, # but everything the registry returns is escaped anyway — the filter is # there to pick useful tags, not to sanitize, and it may be relaxed later. @@ -217,13 +282,16 @@ def image_tags(env: str): # looks clickable and does nothing. parts.append( f'' ) parts.append("
") return HTMLResponse("".join(parts)) +# ---- this machine's terminal --------------------------------------------------------- + + @router.post("/open-terminal/local", response_class=HTMLResponse) def open_terminal_local(): ok, message = open_native_terminal(f"cd {shlex.quote(str(paths.ROOT))}", "local shell") diff --git a/src/deployctl/webui/panel/runner.py b/src/deployctl/webui/panel/runner.py index 42d26a0..466ef07 100644 --- a/src/deployctl/webui/panel/runner.py +++ b/src/deployctl/webui/panel/runner.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import json import os import re import signal @@ -124,6 +125,35 @@ def _kill_if_still_there(group: int, grace: float = 1.0) -> None: signal_group(group, signal.SIGKILL) +def run_json(argv: list[str], timeout: int = 60) -> tuple[object | None, str]: + """``(parsed stdout, "")`` for a ``--json`` command, or ``(None, why not)``. Never raises. + + stdout only: the JSON commands print their document there and nothing else, + while warnings go to stderr — mixing the two, as run_capture does, would make + every warning a parse error. The exit code is not the verdict: ``ci doctor`` + exits 1 when something is missing and still prints the full checklist. + """ + try: + proc = subprocess.run( + [*_DEPLOYCTL, *argv], + cwd=str(paths.workdir()), + env=_child_env(), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return None, f"timed out after {timeout}s" + except Exception as exc: # noqa: BLE001 — rendered in the UI, never a 500 + return None, f"{type(exc).__name__}: {exc}" + try: + return json.loads(proc.stdout), "" + except ValueError: + # What the command said instead — its last lines are where the reason is. + said = strip_ansi(proc.stderr or proc.stdout).strip().splitlines() + return None, " ".join(said[-3:]) or f"exit {proc.returncode} with no output" + + def run_capture(argv: list[str], timeout: int = 120) -> tuple[int, str]: """Blocking run for small requests (the status chip). Never raises.""" try: diff --git a/src/deployctl/webui/panel/state.py b/src/deployctl/webui/panel/state.py index 445d998..787a3e4 100644 --- a/src/deployctl/webui/panel/state.py +++ b/src/deployctl/webui/panel/state.py @@ -161,52 +161,27 @@ def view_sections(env: str) -> list[dict]: return sections -def tutorial(env: str) -> dict: - """Values the walkthrough substitutes into its commands. - - The point of the Tutorial tab is that its commands are the ones *this* - environment actually needs — a bootstrap block with the real deploy user and - REMOTE_DIR in it is copy-pasteable; one with ```` in it is a step - that gets typed wrong at 2am. Where a value is not configured yet, a visible - angle-bracket placeholder is substituted, so a half-filled config produces an - obviously-incomplete command rather than a plausible wrong one. - """ - cfg = load(env) - hosts = cfg.hosts +def bootstrap(env: str) -> dict: + """The Setup tab's first card: the root script for this environment, and where to run it. - def placeholder(value: str, name: str) -> str: - return value or f"<{name}>" + The same text ``deployctl server bootstrap-script`` prints, so the page and the + terminal can never disagree about what a new server gets. + """ + from deployctl.cli.commands.server import render_bootstrap + cfg = load(env) return { - "env": env, - "mode": cfg.mode, - "cluster": cfg.mode == "cluster", - "single": cfg.mode != "cluster", + "script": render_bootstrap(cfg), + "hosts": cfg.hosts or [""], + "ssh_user": cfg.raw.get("SSH_USER") or "deploy", + "domain": cfg.derived["API_DOMAIN"], "tls_le": cfg.derived["TLS_LE"], "tls_lb": cfg.derived["TLS_LB"], - "tls_none": cfg.derived["TLS_NONE"], - "with_postgres": cfg.derived["WITH_POSTGRES"], - "with_redis": cfg.derived["WITH_REDIS"], - "with_beat": cfg.derived["WITH_BEAT"], - "has_migrate": bool(cfg.raw.get("MIGRATE_CMD")), - "project": placeholder(cfg.raw.get("PROJECT_NAME", ""), "project"), - "domain": placeholder(cfg.derived["API_DOMAIN"], "api.example.com"), - "base_domain": placeholder(cfg.raw.get("BASE_DOMAIN", ""), "example.com"), - "ssh_user": cfg.raw.get("SSH_USER") or "deploy", - "remote_dir": placeholder(cfg.remote_dir, "opt/myapp"), - "hosts": hosts, - "primary": cfg.primary_host or "", - "first_host": hosts[0] if hosts else "", - "secondaries": [h for h in hosts if h != cfg.primary_host], - "image_repo": placeholder(cfg.raw.get("IMAGE_REPO", ""), "ghcr.io/your-org/myapp"), - "registry_host": cfg.derived["REGISTRY_HOST"] or "ghcr.io", - "registry_user": placeholder(cfg.raw.get("REGISTRY_USER", ""), "your-github-user"), - "acme_email": placeholder(cfg.raw.get("ACME_EMAIL", ""), "ops@example.com"), } def summary(env: str) -> dict: - """The top-bar chips.""" + """The environment as configured: the page's fixed facts, and what validation says.""" cfg = load(env) problems = cfg.validate() return { @@ -215,9 +190,9 @@ def summary(env: str) -> dict: "tls": cfg.tls_mode, "project": cfg.raw.get("PROJECT_NAME") or "—", "domain": cfg.derived["API_DOMAIN"] or "—", - "image": (cfg.derived["IMAGE_REF"].rsplit("/", 1)[-1] if cfg.derived["IMAGE_REF"] else "—"), "hosts": cfg.hosts, "primary": cfg.primary_host or "—", + "problems": [dataclasses.asdict(p) for p in problems], "errors": sum(1 for p in problems if p.level == "error"), "warnings": sum(1 for p in problems if p.level == "warn"), } diff --git a/src/deployctl/webui/panel/terminal.py b/src/deployctl/webui/panel/terminal.py index 4be0177..730a630 100644 --- a/src/deployctl/webui/panel/terminal.py +++ b/src/deployctl/webui/panel/terminal.py @@ -1,9 +1,10 @@ """ Open the OS terminal on THIS machine — no in-browser shell. -The panel runs locally, so the Terminals tab just launches the real terminal app: -"Local" lands in deployctl/, per-host buttons run ``ssh user@host`` with your own -agent and keys. Nothing to keep alive, and no shell ever runs inside the page. +The panel runs locally, so "Open a terminal" (Operate tab) just launches the real +terminal app: "This project" lands in the deploy directory, per-host buttons run +``ssh user@host`` with your own agent and keys. Nothing to keep alive, and no shell +ever runs inside the page. """ from __future__ import annotations diff --git a/src/deployctl/webui/static/panel.css b/src/deployctl/webui/static/panel.css index 4111eca..c9eb9e1 100644 --- a/src/deployctl/webui/static/panel.css +++ b/src/deployctl/webui/static/panel.css @@ -1,128 +1,327 @@ +/* deployctl control panel. + * + * Every colour is a token below, defined twice: light, and dark for + * prefers-color-scheme. Components use tokens only — a hard-coded colour is + * one that breaks in the other scheme. + * + * No style="" anywhere: the CSP (style-src 'self') drops it silently. Anything + * that needs styling gets a class; tests/test_panel_wiring.py pins that. + */ + :root { - --bg:#f4f5f7; --panel:#fff; --border:#e2e5ea; --border2:#d3d8e0; - --text:#1c2330; --muted:#6b7480; --accent:#3a5bd9; --accent-d:#2f49ad; - --danger:#c0392b; --ok:#1f9254; --warn:#b26a00; - --term-bg:#0b1020; --term-fg:#cfe3ff; --term-dim:#7f93b8; - --r:7px; --gap:.55rem; + --bg:#f4f5f7; --panel:#ffffff; --inset:#f8f9fb; --input:#ffffff; + --border:#e2e5ea; --border2:#d3d8e0; + --text:#1c2330; --muted:#636c78; + --accent:#3a5bd9; --accent-d:#2f49ad; --accent-soft:#e8edfb; --on-accent:#ffffff; --focus:#9fb2f2; + --ok:#1a7f4b; --ok-soft:#e6f4ec; + --warn:#9a5b00; --warn-soft:#fdf3e1; + --danger:#b83227; --danger-soft:#fbeceb; --danger-line:#e6c4bf; + --shadow:0 12px 40px rgba(20,28,45,.22); --backdrop:rgba(20,28,45,.38); + --term-bg:#0b1020; --term-fg:#cfe3ff; --term-dim:#8497bb; + --r:7px; --gap:.6rem; /* Output pane width. Set by dragging the gutter and remembered per browser; --term-h is only the fallback height when the window is too narrow to split side by side. */ --term-w:38%; --term-h:40%; + color-scheme:light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg:#0e1218; --panel:#161b24; --inset:#1b212c; --input:#10141b; + --border:#262d3a; --border2:#353e4f; + --text:#e2e7ef; --muted:#9aa3b3; + --accent:#8aa4ff; --accent-d:#a9bcff; --accent-soft:#1d2744; --on-accent:#0b1020; --focus:#3d4f86; + --ok:#56c98f; --ok-soft:#15291e; + --warn:#f0b35a; --warn-soft:#33270f; + --danger:#ff8a7e; --danger-soft:#361b19; --danger-line:#5c2c28; + --shadow:0 12px 40px rgba(0,0,0,.55); --backdrop:rgba(0,0,0,.6); + --term-bg:#080b12; + color-scheme:dark; + } } + * { box-sizing:border-box; } html,body { height:100%; } body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-size:13px; color:var(--text); background:var(--bg); display:flex; flex-direction:column; height:100vh; overflow:hidden; } +body.resizing { user-select:none; cursor:col-resize; } a { color:var(--accent); } code,.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } +code { background:var(--inset); border:1px solid var(--border); border-radius:4px; padding:0 .25rem; font-size:.92em; } +h2,h3 { margin:0; } +:focus-visible { outline:2px solid var(--focus); outline-offset:1px; } +.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; + clip:rect(0,0,0,0); white-space:nowrap; border:0; } +.dim { color:var(--muted); } +.small { font-size:.76rem; } +.hint { color:var(--muted); font-size:.74rem; } +.hint.err, .err { color:var(--danger); } +.ok { color:var(--ok); } +.hint.block { margin:.45rem 0 .6rem; } +.hint.spaced { margin-top:.7rem; } +.loading { opacity:.6; transition:opacity .2s; } + +/* ===== Top bar and stepper ============================================== */ -.topbar { flex:0 0 auto; display:flex; align-items:center; gap:1rem; padding:.5rem .85rem; - background:var(--panel); border-bottom:1px solid var(--border); } +.topbar { flex:0 0 auto; display:flex; align-items:center; gap:.9rem; flex-wrap:wrap; + padding:.5rem .85rem; background:var(--panel); border-bottom:1px solid var(--border); } .brand { font-weight:700; letter-spacing:-.02em; } -.brand .sub { color:var(--muted); font-weight:400; margin-left:.4rem; font-size:.82rem; } -.chips { display:flex; gap:1rem; flex-wrap:wrap; } -.chip { display:flex; flex-direction:column; line-height:1.25; } -.chip .k { font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); } -.chip .v { font-weight:600; } .envpick { padding:.3rem .5rem; border:1px solid var(--border2); border-radius:var(--r); - font-weight:600; background:var(--panel); } -.health { display:flex; align-items:center; gap:.4rem; color:var(--muted); } -.dot { width:9px; height:9px; border-radius:50%; background:var(--border2); } + font-weight:600; background:var(--input); color:var(--text); } +.project { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:34ch; } +.livebar { display:flex; align-items:center; gap:.45rem; flex-wrap:wrap; min-height:1.6rem; } +.stat { display:inline-flex; align-items:center; gap:.35rem; } +.stat .k { font-size:.66rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); } +.topbar-right { margin-left:auto; display:flex; align-items:center; gap:.5rem; } +.dot { width:9px; height:9px; border-radius:50%; background:var(--border2); display:inline-block; } .dot.ok { background:var(--ok); } .dot.bad { background:var(--danger); } -/* Side-by-side: settings on the left, streamed output on the right. Deploy - output is many short lines, so a tall narrow column suits it better than a - wide short one — and it stays readable while you scroll the form. */ +.journey { flex:0 0 auto; display:flex; align-items:center; gap:1.2rem; flex-wrap:wrap; + padding:.4rem .85rem; background:var(--inset); border-bottom:1px solid var(--border); min-height:2.3rem; } +.journey:empty { display:none; } +.stepper { display:flex; align-items:center; gap:0; margin:0; padding:0; list-style:none; } +.step { display:flex; align-items:center; gap:.35rem; color:var(--muted); font-weight:600; font-size:.78rem; } +.step:not(:last-child)::after { content:''; width:1.6rem; height:1px; background:var(--border2); margin:0 .5rem; } +.step-n { width:1.25rem; height:1.25rem; border-radius:50%; display:inline-flex; align-items:center; + justify-content:center; font-size:.68rem; border:1px solid var(--border2); background:var(--panel); } +.step.done { color:var(--text); } +.step.done .step-n { background:var(--ok); border-color:var(--ok); color:var(--on-accent); } +.step.todo .step-n { border-color:var(--accent); color:var(--accent); } +.step.fail .step-n { background:var(--danger); border-color:var(--danger); color:var(--on-accent); } +.next { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; } +.next-k { font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--accent); } + +/* ===== Pills: a status word with a colour AND a mark, so it reads without colour */ + +.pill { display:inline-flex; align-items:center; gap:.3rem; padding:.08rem .5rem; border-radius:99px; + font-size:.72rem; font-weight:600; white-space:nowrap; border:1px solid transparent; + background:var(--inset); color:var(--muted); border-color:var(--border); } +.pill-mark { font-size:.7rem; } +.pill.ok, .pill.done { background:var(--ok-soft); color:var(--ok); border-color:transparent; } +.pill.warn { background:var(--warn-soft); color:var(--warn); border-color:transparent; } +.pill.fail { background:var(--danger-soft); color:var(--danger); border-color:transparent; } +.pill.todo { background:var(--accent-soft); color:var(--accent); border-color:transparent; } + +/* ===== Layout: tabs on the left, streamed output on the right ============ + Deploy output is many short lines, so a tall narrow column suits it better + than a wide short one — and it stays readable while you scroll a tab. */ + .shell { flex:1 1 auto; display:flex; flex-direction:row; min-height:0; } .left { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; min-width:0; } .tabs { flex:0 0 auto; display:flex; gap:.15rem; padding:.45rem .85rem 0; - background:var(--panel); border-bottom:1px solid var(--border); } -.tab { padding:.45rem .9rem; border:1px solid transparent; border-bottom:none; cursor:pointer; - border-radius:var(--r) var(--r) 0 0; color:var(--muted); font-weight:600; } + background:var(--panel); border-bottom:1px solid var(--border); overflow-x:auto; } +.tab { padding:.45rem .95rem; border:1px solid transparent; border-bottom:none; cursor:pointer; + border-radius:var(--r) var(--r) 0 0; color:var(--muted); font:inherit; font-weight:600; + background:none; white-space:nowrap; } +.tab:hover { color:var(--text); } .tab.active { background:var(--bg); border-color:var(--border); color:var(--text); } -.panes { flex:1 1 auto; overflow:auto; padding: .8rem .85rem 1.2rem; min-height:0; } -.pane { display:none; max-width:1080px; } +.panes { flex:1 1 auto; overflow:auto; padding:.9rem .85rem 1.4rem; min-height:0; } +.pane { display:none; max-width:1100px; } .pane.active { display:block; } -.card { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); - margin-bottom:var(--gap); } -.card > summary { list-style:none; cursor:pointer; padding:.6rem .8rem; display:flex; gap:.6rem; - align-items:baseline; } +/* ===== Groups, cards, flows ============================================== */ + +.group { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); + margin-bottom:.8rem; padding:.7rem .8rem .8rem; scroll-margin-top:.6rem; } +.group-head { margin-bottom:.6rem; } +.group-head h2 { font-size:.95rem; display:flex; align-items:center; gap:.45rem; } +.group-head p { color:var(--muted); font-size:.78rem; line-height:1.5; margin:.25rem 0 0; max-width:78ch; } +.group.tone-danger .group-head h2 { color:var(--danger); } +.card-body { line-height:1.55; } +.lede { margin:.2rem 0 .35rem; } + +.stepno { flex:0 0 auto; width:1.3rem; height:1.3rem; display:inline-flex; align-items:center; + justify-content:center; background:var(--inset); border:1px solid var(--border2); + border-radius:50%; font-size:.68rem; font-weight:700; color:var(--muted); } + +.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:var(--gap); } +.acard { text-align:left; background:var(--inset); border:1px solid var(--border2); color:var(--text); + border-radius:var(--r); padding:.55rem .65rem; cursor:pointer; display:flex; font:inherit; + flex-direction:column; gap:.22rem; width:100%; height:100%; } +.acard:hover { border-color:var(--accent); } +.acard-top { display:flex; align-items:center; gap:.4rem; } +.alabel { font-weight:700; } +.where { font-size:.64rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; } +.acmd { font-size:.7rem; color:var(--accent); word-break:break-all; } +.adesc { font-size:.74rem; color:var(--muted); line-height:1.45; } +.aeffect { font-size:.7rem; color:var(--muted); line-height:1.45; + border-top:1px dashed var(--border2); padding-top:.28rem; margin-top:.1rem; } +.acard.danger { border-color:var(--danger-line); } +.acard.danger .alabel { color:var(--danger); } +.acard.danger:hover { border-color:var(--danger); } +.acard.running, .btn.running { border-color:var(--warn); box-shadow:inset 0 0 0 1px var(--warn); } +/* The edit step is not a command — dashed, so it never reads as a button that + runs something, and tinted, so the one manual step in a flow is findable. */ +.acard.edit { background:var(--warn-soft); border-style:dashed; border-color:var(--warn); } +.acard.edit .stepno { border-color:var(--warn); color:var(--warn); } + +/* A flow is a sequence, so it is drawn as one — numbered cards left to right + with arrows between them, scrolling rather than wrapping, because a wrapped + flow stops reading as an order. */ +.rail { display:flex; align-items:stretch; gap:1.5rem; overflow-x:auto; list-style:none; + margin:0; padding:.1rem .1rem .4rem; } +.rail > li { position:relative; flex:0 0 215px; } +.rail > li:not(:last-child)::after { content:'→'; position:absolute; right:-1.12rem; top:50%; + transform:translateY(-50%); color:var(--border2); + font-size:1.05rem; line-height:1; pointer-events:none; } + +/* The value a group's buttons read: one input, above them. */ +.params { display:flex; align-items:flex-end; gap:.5rem; flex-wrap:wrap; margin-bottom:.55rem; } +.param { display:flex; flex-direction:column; gap:.2rem; } +.param-label { font-size:.72rem; font-weight:600; color:var(--muted); } +.param input { width:14rem; } +.tag-slot:empty { display:none; } +.tag-slot { margin:-.2rem 0 .6rem; } + +.danger-zone { border:1px solid var(--danger-line); border-radius:var(--r); background:var(--panel); + margin-bottom:.8rem; } +.danger-zone > summary { list-style:none; cursor:pointer; padding:.65rem .8rem; font-weight:700; + color:var(--danger); display:flex; gap:.5rem; align-items:center; } +.danger-zone > summary::-webkit-details-marker { display:none; } +.danger-zone > .group { border:0; border-top:1px solid var(--danger-line); border-radius:0 0 var(--r) var(--r); + margin:0; } +.arrow { color:var(--muted); font-size:.7rem; transition:transform .12s; display:inline-block; } +details[open] > summary .arrow { transform:rotate(90deg); } + +.fold { margin:.6rem 0; } +.fold > summary { cursor:pointer; list-style:none; color:var(--accent); font-weight:600; font-size:.78rem; } +.fold > summary::-webkit-details-marker { display:none; } + +.callout { border-left:3px solid var(--border2); border-radius:0 var(--r) var(--r) 0; background:var(--inset); + padding:.5rem .7rem; margin:.5rem 0; line-height:1.55; } +.callout.warn { border-left-color:var(--warn); background:var(--warn-soft); } +.callout.danger { border-left-color:var(--danger); background:var(--danger-soft); } +.notes { margin:.3rem 0 0; padding-left:1.1rem; line-height:1.6; } +.notes li { margin:.15rem 0; } + +/* Briefly outlines where a jump landed, so it is obvious after the scroll. */ +.flash { box-shadow:0 0 0 2px var(--warn); } + +/* ===== Code blocks with copy ============================================== */ + +.codeblock { position:relative; margin:.35rem 0 .55rem; } +.codeblock pre { background:var(--term-bg); color:var(--term-fg); border-radius:var(--r); margin:0; + padding:.55rem 4rem .55rem .7rem; font-size:.76rem; line-height:1.55; + overflow:auto; max-height:26rem; white-space:pre; } +.copy-btn { position:absolute; top:.35rem; right:.35rem; padding:.15rem .45rem; + background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18); + color:var(--term-dim); border-radius:var(--r); font-size:.68rem; cursor:pointer; } +.copy-btn:hover { color:var(--term-fg); border-color:var(--term-dim); } +.copy-btn.copied { color:#8ce0ad; border-color:#8ce0ad; } + +/* ===== Live: hosts, releases, checklist =================================== */ + +/* One host takes the full width; a fleet wraps. The services table scrolls inside + its card rather than spilling out of it on a narrow pane. */ +.hosts { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr)); gap:var(--gap); } +.hostcard { background:var(--inset); border:1px solid var(--border2); border-radius:var(--r); + padding:.55rem .65rem; overflow-x:auto; min-width:0; } +.hostcard header { display:flex; align-items:center; gap:.45rem; flex-wrap:wrap; } +.hostcard p { margin:.25rem 0 .4rem; } +.tagchip { background:var(--accent-soft); color:var(--accent); border-radius:5px; padding:0 .35rem; font-weight:700; } + +table { border-collapse:collapse; width:100%; } +th { text-align:left; font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); + font-weight:700; padding:.25rem .45rem; border-bottom:1px solid var(--border); } +td { padding:.3rem .45rem; border-bottom:1px solid var(--border); vertical-align:middle; } +tr:last-child td { border-bottom:0; } +.svc td { font-size:.76rem; } +.warnnum { color:var(--warn); font-weight:700; } +.releases tr.is-running td { background:var(--ok-soft); } +.releases td:last-child { text-align:right; } + +.checklist { list-style:none; margin:0; padding:0; } +.check { display:flex; align-items:center; gap:.7rem; padding:.5rem .1rem; border-bottom:1px solid var(--border); } +.check:last-child { border-bottom:0; } +.check > .pill { flex:0 0 5.2rem; justify-content:center; } +.check-body { flex:1 1 auto; display:flex; flex-direction:column; gap:.1rem; min-width:0; } +.check-body .dim { font-size:.76rem; } +.fixcmd { align-self:flex-start; margin-top:.15rem; } + +.problems-box { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); + padding:.55rem .75rem; margin-bottom:.7rem; } +.problems-box.has-errors { border-color:var(--danger-line); } +.problems-head { display:flex; align-items:center; gap:.45rem; } +.problems { margin:.35rem 0 .1rem; padding-left:1.1rem; line-height:1.55; } +.problems li.err { color:var(--danger); } +.problems li.warn { color:var(--warn); } + +/* ===== Configure form ===================================================== */ + +.card { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); margin-bottom:var(--gap); } +.card > summary { list-style:none; cursor:pointer; padding:.6rem .8rem; display:flex; gap:.6rem; align-items:baseline; } .card > summary::-webkit-details-marker { display:none; } -.card .arrow { color:var(--muted); font-size:.7rem; transition:transform .12s; } -.card[open] .arrow { transform:rotate(90deg); } .card .tw { font-weight:700; } .card .sdesc { color:var(--muted); font-size:.78rem; } -.card-body { padding:.2rem .9rem .8rem; border-top:1px solid var(--border); } +.card > .card-body { padding:.5rem .9rem .8rem; border-top:1px solid var(--border); } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.6rem .9rem; } .field { display:flex; flex-direction:column; gap:.25rem; } .field.full { grid-column:1 / -1; } .field label { font-size:.75rem; font-weight:600; color:var(--muted); } -.field input,.field select { padding:.42rem .5rem; border:1px solid var(--border2); - border-radius:var(--r); font-size:.85rem; background:#fff; } -.field input:focus,.field select:focus { outline:2px solid #b9c6f5; border-color:var(--accent); } +input, select { padding:.42rem .5rem; border:1px solid var(--border2); border-radius:var(--r); + font:inherit; font-size:.85rem; background:var(--input); color:var(--text); } +input:focus, select:focus { outline:2px solid var(--focus); border-color:var(--accent); } +input.invalid { border-color:var(--danger); outline:2px solid var(--danger-soft); } +input.picked { border-color:var(--ok); outline:2px solid var(--ok-soft); } .req { color:var(--danger); } -.hint { color:var(--muted); font-size:.72rem; } -.hint.err { color:var(--danger); } -.saved-badge { background:#e7f4ec; color:var(--ok); border-radius:9px; padding:0 .45rem; +.saved-badge { background:var(--ok-soft); color:var(--ok); border-radius:9px; padding:0 .45rem; font-size:.68rem; margin-left:.35rem; } -.footer { display:flex; gap:.6rem; align-items:center; margin-top:.4rem; } +.footer { position:sticky; bottom:-1.4rem; display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; + padding:.6rem 0 .8rem; background:var(--bg); } +.host-row { display:flex; gap:.5rem; align-items:center; margin-bottom:.35rem; } +.host-row input[type=text] { flex:1; } +.pri { display:flex; gap:.3rem; align-items:center; color:var(--muted); font-size:.78rem; } /* "Where do I get this?" — the (i) beside a label, and the modal it opens. - Deliberately small and quiet: it sits on a handful of fields, and a loud - badge on every other label would read as a warning rather than an offer. */ + Deliberately small and quiet: a loud badge on every label would read as a + warning rather than an offer. */ .infobtn { width:14px; height:14px; padding:0; margin-left:.3rem; vertical-align:middle; border:1px solid var(--border2); border-radius:50%; background:var(--panel); color:var(--muted); font:600 9px/1 ui-monospace,Menlo,monospace; cursor:pointer; } .infobtn:hover { border-color:var(--accent); color:var(--accent); } -.infobtn:focus-visible { outline:2px solid #b9c6f5; } - -dialog.guide { max-width:30rem; padding:0; border:1px solid var(--border2); - border-radius:var(--r); background:var(--panel); color:var(--text); - box-shadow:0 12px 40px rgba(20,28,45,.22); } -dialog.guide::backdrop { background:rgba(20,28,45,.38); } +dialog.guide { max-width:30rem; padding:0; border:1px solid var(--border2); border-radius:var(--r); + background:var(--panel); color:var(--text); box-shadow:var(--shadow); } +dialog.guide::backdrop { background:var(--backdrop); } .guide-body { padding:.9rem 1.05rem 1.05rem; } .guide-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; } .guide-head h3 { margin:0 0 .5rem; font-size:.86rem; } -.guide-x { border:0; background:none; color:var(--muted); font-size:1.05rem; line-height:1; - padding:0 .1rem; cursor:pointer; } +.guide-x { border:0; background:none; color:var(--muted); font-size:1.05rem; line-height:1; padding:0 .1rem; cursor:pointer; } .guide-x:hover { color:var(--text); } .guide-steps { margin:0; padding-left:1.15rem; } .guide-steps li { margin:.3rem 0; font-size:.78rem; line-height:1.45; } -.guide-steps code { background:var(--bg); border:1px solid var(--border); - border-radius:3px; padding:0 .22rem; font-size:.92em; } -.guide-link { display:inline-block; margin-top:.7rem; font-size:.78rem; font-weight:600; - word-break:break-all; } +.guide-link { display:inline-block; margin-top:.7rem; font-size:.78rem; font-weight:600; word-break:break-all; } .guide-note { margin:.7rem 0 0; padding-top:.6rem; border-top:1px solid var(--border); color:var(--muted); font-size:.72rem; line-height:1.45; } -.btn { padding:.5rem .95rem; background:var(--accent); border:1px solid var(--accent-d); - color:#fff; font-weight:700; border-radius:var(--r); cursor:pointer; font-size:.85rem; } +/* ===== Buttons ============================================================ */ + +.btn { padding:.45rem .9rem; background:var(--accent); border:1px solid var(--accent-d); + color:var(--on-accent); font:inherit; font-weight:700; border-radius:var(--r); cursor:pointer; font-size:.84rem; } .btn:hover { background:var(--accent-d); } -.btn.ghost { background:#fff; color:var(--accent); border-color:var(--border2); } -.btn.sm { padding:.3rem .6rem; font-size:.76rem; } -.iconbtn { background:none; border:1px solid var(--border2); border-radius:var(--r); +.btn.ghost { background:var(--panel); color:var(--accent); border-color:var(--border2); } +.btn.ghost:hover { border-color:var(--accent); } +.btn.danger { background:var(--danger); border-color:var(--danger); color:var(--on-accent); } +.btn.ghost.danger { background:var(--panel); color:var(--danger); border-color:var(--danger-line); } +.btn.sm { padding:.25rem .6rem; font-size:.75rem; } +.iconbtn { background:none; border:1px solid var(--border2); border-radius:var(--r); font:inherit; cursor:pointer; padding:.25rem .55rem; color:var(--muted); } .iconbtn:hover { color:var(--text); } +.iconbtn.push-right { margin-left:auto; } -.group-title { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; - color:var(--muted); margin:.9rem 0 .4rem; } -.actions { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:var(--gap); } -.acard { text-align:left; background:var(--panel); border:1px solid var(--border); - border-radius:var(--r); padding:.6rem .7rem; cursor:pointer; display:flex; - flex-direction:column; gap:.2rem; } -.acard:hover { border-color:var(--accent); } -.acard.danger:hover { border-color:var(--danger); } -.acard .alabel { font-weight:700; } -.acard.danger .alabel { color:var(--danger); } -.acard .acmd { font-size:.72rem; color:var(--accent); } -.acard .adesc { font-size:.74rem; color:var(--muted); } -.acard.running { border-color:var(--warn); } +/* ===== Tag picker ========================================================= */ -.host-row { display:flex; gap:.5rem; align-items:center; margin-bottom:.35rem; } -.host-row input[type=text] { flex:1; } -.pri { display:flex; gap:.3rem; align-items:center; color:var(--muted); font-size:.78rem; } +.tag-picker { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.35rem; } +.tag-card { border:1px solid var(--border2); background:var(--panel); color:var(--text); border-radius:var(--r); + padding:.3rem .55rem; cursor:pointer; font:inherit; font-size:.76rem; } +.tag-card:hover { border-color:var(--accent); } +.tag-card.tag-active { border-color:var(--ok); background:var(--ok-soft); } +.tag-sha { font-family:ui-monospace,Menlo,monospace; font-weight:700; } +.tag-badge { background:var(--accent); color:var(--on-accent); border-radius:8px; padding:0 .4rem; font-size:.66rem; } +.tag-badge.running { background:var(--ok); } +.tag-age { color:var(--muted); } + +/* ===== Output pane ======================================================== */ /* Drag handle. The ::after extends the grab area a few pixels either side, so it is easy to hit without drawing a thick divider. */ @@ -133,231 +332,19 @@ dialog.guide::backdrop { background:rgba(20,28,45,.38); } .term { flex:0 0 var(--term-w); min-width:260px; max-width:85%; display:flex; flex-direction:column; min-height:0; background:var(--term-bg); color:var(--term-fg); } -/* Collapsing hides the divider too, so the form gets the whole width. */ +/* Collapsing hides the divider too, so the tabs get the whole width. */ .shell.no-term .term, .shell.no-term .gutter { display:none; } - .term-head { flex:0 0 auto; display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; padding:.35rem .7rem; color:var(--term-dim); font-size:.75rem; border-bottom:1px solid rgba(255,255,255,.08); } -#termTitle { font-weight:600; white-space:nowrap; overflow:hidden; - text-overflow:ellipsis; max-width:45%; } +#termTitle { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:45%; } .term-head .iconbtn { border-color:rgba(255,255,255,.18); color:var(--term-dim); } .term-head .iconbtn:hover { color:var(--term-fg); border-color:var(--term-dim); } +.term-head .iconbtn.danger { border-color:#c0392b; color:#f1998e; } .term-out { flex:1 1 auto; overflow:auto; margin:0; padding:.5rem .7rem .9rem; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.78rem; line-height:1.45; white-space:pre-wrap; word-break:break-word; } -/* Too narrow to split: stack the output under the form and drop the handle, - rather than squeezing both into unusable columns. */ -@media (max-width:900px) { - .shell { flex-direction:column; } - .gutter { display:none; } - .term { flex:0 0 var(--term-h); min-width:0; max-width:none; - border-top:2px solid var(--accent); } -} - -.tag-picker { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.35rem; } -.tag-card { border:1px solid var(--border2); background:#fff; border-radius:var(--r); - padding:.3rem .55rem; cursor:pointer; font-size:.76rem; } -.tag-card:hover { border-color:var(--accent); } -.tag-card.tag-active { border-color:var(--ok); background:#e7f4ec; } -.tag-sha { font-family:ui-monospace,Menlo,monospace; font-weight:700; } -.tag-badge { background:var(--accent); color:#fff; border-radius:8px; padding:0 .4rem; font-size:.66rem; } -.tag-age { color:var(--muted); } -/* Shown by panel.js while a fetch is in flight, then reset to the stylesheet's - display:none by clearing the inline style. */ -.spinner { display:none; } - -/* Briefly outlines the Configure section a flow's edit step jumped to, so the - landing point is obvious after the scroll. */ -.card.flash { box-shadow:0 0 0 2px var(--warn); } - -/* ===== Tutorial ======================================================== - A walkthrough rather than reference documentation: numbered steps, every - command pre-filled with this environment's real values, and a Run button - wherever the step is something the panel can already do. Styled from the - same tokens as the rest of the panel so it reads as one application. */ -.tut-nav { position:sticky; top:0; z-index:5; display:flex; gap:.3rem; flex-wrap:wrap; - padding:.45rem 0 .5rem; margin-bottom:.4rem; background:var(--bg); - border-bottom:1px solid var(--border); } -.tn { padding:.25rem .55rem; border:1px solid var(--border2); border-radius:99px; - background:var(--panel); color:var(--muted); font-size:.73rem; font-weight:600; - text-decoration:none; white-space:nowrap; } -.tn:hover { border-color:var(--accent); color:var(--accent); } -.tn.active { background:var(--accent); border-color:var(--accent-d); color:#fff; } - -/* The walkthrough covers both deployment shapes; the toggle hides the steps - that do not apply, so nobody follows a load-balancer step on one server. */ -.tut-shape-pick { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); - gap:var(--gap); margin:.6rem 0 .3rem; } -.tut-shape { text-align:left; background:var(--panel); border:1px solid var(--border); - border-radius:var(--r); padding:.6rem .7rem; cursor:pointer; line-height:1.5; } -.tut-shape:hover { border-color:var(--accent); } -.tut-shape b { display:block; margin-bottom:.15rem; } -.tut-shape span { color:var(--muted); font-size:.78rem; } -.tut-wrap[data-shape="single"] .tut-shape[data-pick="single"], -.tut-wrap[data-shape="cluster"] .tut-shape[data-pick="cluster"] { - border-color:var(--accent); box-shadow:inset 0 0 0 1px var(--accent); } -.tut-wrap[data-shape="single"] [data-only="cluster"], -.tut-wrap[data-shape="cluster"] [data-only="single"] { display:none; } - -/* scroll-margin keeps a jumped-to heading clear of the sticky nav above it */ -.tut-section { margin:0 0 1.6rem; scroll-margin-top:3rem; } -.tut-h2 { font-size:1.05rem; margin:.3rem 0 .5rem; letter-spacing:-.01em; } -.tut-h3 { font-size:.88rem; margin:1rem 0 .4rem; } -.tut-part { display:inline-block; background:var(--accent); color:#fff; border-radius:var(--r); - padding:.05rem .45rem; font-size:.68rem; letter-spacing:.04em; text-transform:uppercase; - margin-right:.45rem; vertical-align:middle; } -.tut-p { color:var(--text); line-height:1.6; margin:.35rem 0 .7rem; } -.tut-ul, .tut-ol { margin:.35rem 0 .7rem; padding-left:1.15rem; line-height:1.65; } -.tut-ul li, .tut-ol li { margin:.15rem 0; } - -.tut-step { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); - margin-bottom:var(--gap); overflow:hidden; } -.tut-step-head { display:flex; align-items:center; gap:.5rem; padding:.5rem .8rem; - border-bottom:1px solid var(--border); font-weight:700; } -.tsn { flex:0 0 auto; min-width:1.5rem; height:1.5rem; display:inline-flex; align-items:center; - justify-content:center; background:var(--bg); border:1px solid var(--border2); - border-radius:var(--r); font-size:.7rem; color:var(--muted); padding:0 .3rem; } -.tut-step-body { padding:.6rem .8rem .75rem; line-height:1.6; } -.tut-step-body > :first-child { margin-top:0; } -.tut-step-body > :last-child { margin-bottom:0; } - -/* Code block + copy button. The button is absolutely positioned so it never - shifts the code, and the block scrolls rather than wrapping — a wrapped - shell command is a command someone will paste wrong. */ -.tut-code { position:relative; background:var(--term-bg); color:var(--term-fg); - border-radius:var(--r); padding:.55rem 3.6rem .55rem .7rem; margin:.5rem 0; - font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.76rem; - line-height:1.55; overflow-x:auto; white-space:pre; } -.tut-code .cmt { color:var(--term-dim); } -.copy-btn { position:absolute; top:.35rem; right:.35rem; padding:.15rem .45rem; - background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18); - color:var(--term-dim); border-radius:var(--r); font-size:.68rem; cursor:pointer; } -.copy-btn:hover { color:var(--term-fg); border-color:var(--term-dim); } -.copy-btn.copied { color:#8ce0ad; border-color:#8ce0ad; } - -.tut-diagram { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); - padding:.7rem .8rem; margin:.5rem 0 .9rem; overflow-x:auto; white-space:pre; - font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - font-size:.72rem; line-height:1.35; color:var(--muted); } - -.tut-callout { border-left:3px solid var(--border2); border-radius:0 var(--r) var(--r) 0; - background:var(--panel); padding:.5rem .7rem; margin:.5rem 0; line-height:1.55; } -.tut-callout.info { border-left-color:var(--accent); } -.tut-callout.tip { border-left-color:var(--ok); } -.tut-callout.warn { border-left-color:var(--warn); } -.tut-callout.danger { border-left-color:var(--danger); } - -/* Runs the same whitelisted action as the matching Deploy-tab card. */ -.tut-run { display:inline-flex; align-items:center; gap:.35rem; margin:.4rem .4rem .1rem 0; - padding:.35rem .7rem; background:var(--panel); border:1px solid var(--accent); - color:var(--accent); font-weight:700; font-size:.78rem; border-radius:var(--r); - cursor:pointer; } -.tut-run:hover { background:var(--accent); color:#fff; } -.tut-run.danger { border-color:var(--danger); color:var(--danger); } -.tut-run.danger:hover { background:var(--danger); color:#fff; } -.tut-run.running { border-color:var(--warn); color:var(--warn); } -.tut-runnote { color:var(--muted); font-size:.72rem; margin-top:.2rem; } - -.te-row { background:var(--panel); border:1px solid var(--border); border-left:3px solid var(--danger); - border-radius:0 var(--r) var(--r) 0; padding:.5rem .7rem; margin-bottom:var(--gap); - line-height:1.55; } -.te-sym { font-weight:700; margin-bottom:.2rem; } -.te-cause, .te-fix { font-size:.8rem; color:var(--muted); } -.te-fix { color:var(--text); } -.tut-section code, .tut-callout code, .te-row code { - background:var(--bg); border:1px solid var(--border); border-radius:4px; - padding:0 .25rem; font-size:.92em; } - -/* ===== Deploy tab: procedures and groups ================================ - The release path is a sequence, so it is drawn as one — numbered cards left - to right with arrows between them, scrolling horizontally rather than - wrapping, because a wrapped flow stops reading as an order. Groups that have - no inherent order (Check & observe, Recover) stay as plain grids, so the - difference between "do these in turn" and "run whichever you need" is visible - without reading a word. */ - -.deploy-lede { color:var(--muted); line-height:1.6; margin:.2rem 0 .9rem; max-width:70ch; } -.deploy-lede b { color:var(--text); } - -.flow-group { background:var(--panel); border:1px solid var(--border); border-radius:var(--r); - margin-bottom:.8rem; } -.flow-group.lead { border-color:var(--accent); box-shadow:inset 0 0 0 1px rgba(58,91,217,.14); } -.flow-head { padding:.6rem .8rem .1rem; } -.flow-title { font-weight:700; display:flex; align-items:center; gap:.5rem; } -.flow-badge { background:var(--accent); color:#fff; border-radius:99px; padding:.05rem .5rem; - font-size:.66rem; letter-spacing:.04em; text-transform:uppercase; } -.flow-desc { color:var(--muted); font-size:.78rem; margin-top:.2rem; line-height:1.5; } - -.flow-rail { display:flex; align-items:stretch; gap:1.5rem; overflow-x:auto; - padding:.7rem .8rem .9rem; } -.fstep { position:relative; flex:0 0 210px; display:flex; flex-direction:column; gap:.25rem; - text-align:left; background:var(--bg); border:1px solid var(--border2); - border-radius:var(--r); padding:.5rem .6rem; cursor:pointer; } -.fstep:hover { border-color:var(--accent); } -/* The connector sits in the gap, so it never overlaps a card's content. */ -.fstep:not(:last-child)::after { content:'→'; position:absolute; right:-1.12rem; top:50%; - transform:translateY(-50%); color:var(--border2); - font-size:1.05rem; line-height:1; pointer-events:none; } -.fstep-top { display:flex; align-items:center; gap:.4rem; } -.fsn { flex:0 0 auto; width:1.3rem; height:1.3rem; display:inline-flex; align-items:center; - justify-content:center; background:var(--panel); border:1px solid var(--border2); - border-radius:50%; font-size:.68rem; font-weight:700; color:var(--muted); } -.fstep-label { font-weight:700; } -.fstep-cmd { font-size:.68rem; color:var(--accent); word-break:break-all; } -.fstep-desc { font-size:.72rem; color:var(--muted); line-height:1.45; } -.fstep-effect { font-size:.7rem; color:var(--muted); line-height:1.45; - border-top:1px dashed var(--border2); padding-top:.28rem; margin-top:.1rem; } - -/* The edit step is not a command — dashed, so it never reads as a button that - runs something, and tinted, so the one manual step in the flow is findable. */ -.fstep.edit { background:#fffdf5; border-style:dashed; border-color:var(--warn); } -.fstep.edit .fsn { border-color:var(--warn); color:var(--warn); } -.fstep.edit:hover { border-color:var(--warn); box-shadow:inset 0 0 0 1px var(--warn); } -.fstep-keys { display:flex; flex-wrap:wrap; gap:.2rem; margin-top:.1rem; } -.fkey { background:var(--panel); border:1px solid var(--border2); border-radius:4px; - padding:0 .3rem; font-family:ui-monospace,Menlo,monospace; font-size:.68rem; } - -/* Mutating steps carry the danger tone the grid cards already use. */ -.fstep.danger { border-color:#e6c4bf; } -.fstep.danger .fstep-label { color:var(--danger); } -.fstep.danger:hover { border-color:var(--danger); } -.fstep.running { border-color:var(--warn); box-shadow:inset 0 0 0 1px var(--warn); } -.fstep-ssh { font-size:.64rem; color:var(--muted); text-transform:uppercase; - letter-spacing:.04em; } - -.grid-group { margin:1.1rem 0 .3rem; } -.grid-group .gg-head { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; - margin-bottom:.35rem; } -.gg-title { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; } -.gg-desc { color:var(--muted); font-size:.76rem; } -.gg-danger .gg-title { color:var(--danger); } -.acard .aeffect { font-size:.7rem; color:var(--muted); line-height:1.45; - border-top:1px dashed var(--border); padding-top:.28rem; margin-top:.15rem; } - -@media (max-width:900px) { - /* Stack the flow instead of scrolling it sideways on a narrow window; the - arrow rotates so the sequence still reads in the direction of travel. */ - .flow-rail { flex-direction:column; gap:1.15rem; } - .fstep { flex:1 1 auto; } - .fstep:not(:last-child)::after { right:auto; left:1rem; top:auto; bottom:-1.02rem; - transform:rotate(90deg); } -} - -/* Utility classes replacing inline style attributes. - The panel's own CSP is `style-src 'self'`, which blocks a style="" attribute - outright — every one of these rendered with its styling silently dropped, - and the only evidence was four console errors nobody was reading. Anything - that needs styling gets a class; tests/test_panel_wiring.py pins that. */ -.ok { color:var(--ok); } -.err, .v.err { color:var(--danger); } -.topbar-right { margin-left:auto; display:flex; align-items:center; gap:.8rem; } -.hint.block { margin:.45rem 0 .6rem; } -.hint.spaced { margin-top:.7rem; } -.iconbtn.push-right { margin-left:auto; } -.tut-callout.spaced { margin-top:1rem; } - /* Jobs: a command keeps running whatever the page does, and this bar is how you get back to one. Running chips are amber, finished ones green or red. */ .jobbar { flex:0 0 auto; display:flex; flex-wrap:wrap; gap:.3rem; padding:.3rem .7rem; @@ -366,7 +353,29 @@ dialog.guide::backdrop { background:rgba(20,28,45,.38); } .jobchip { background:none; border:1px solid rgba(255,255,255,.18); border-radius:var(--r); color:var(--term-dim); font-size:.7rem; padding:.1rem .45rem; cursor:pointer; } .jobchip:hover { color:var(--term-fg); } -.jobchip.running { border-color:var(--warn); color:#f0c27a; } -.jobchip.ok { border-color:var(--ok); } -.jobchip.bad { border-color:var(--danger); } -.term-head .iconbtn.danger { border-color:var(--danger); color:#f1998e; } +.jobchip.running { border-color:#b26a00; color:#f0c27a; } +.jobchip.ok { border-color:#1f9254; } +.jobchip.bad { border-color:#c0392b; } + +/* ===== Toasts ============================================================= */ + +.toasts { position:fixed; right:1rem; bottom:1rem; display:flex; flex-direction:column; gap:.4rem; z-index:20; } +.toast { background:var(--panel); color:var(--text); border:1px solid var(--border2); border-left-width:4px; + border-radius:var(--r); padding:.55rem .8rem; box-shadow:var(--shadow); max-width:26rem; font-weight:600; } +.toast-ok { border-left-color:var(--ok); } +.toast-bad { border-left-color:var(--danger); } + +/* ===== Narrow windows ===================================================== */ + +/* Too narrow to split: stack the output under the tabs and drop the handle, + rather than squeezing both into unusable columns. */ +@media (max-width:900px) { + .shell { flex-direction:column; } + .gutter { display:none; } + .term { flex:0 0 var(--term-h); min-width:0; max-width:none; border-top:2px solid var(--accent); } + /* Stack a flow instead of scrolling it sideways; the arrow turns to point down. */ + .rail { flex-direction:column; gap:1.15rem; } + .rail > li { flex:1 1 auto; } + .rail > li:not(:last-child)::after { right:auto; left:1rem; top:auto; bottom:-1.02rem; transform:rotate(90deg); } + .project { max-width:none; } +} diff --git a/src/deployctl/webui/static/panel.js b/src/deployctl/webui/static/panel.js index 07ecfd8..e556745 100644 --- a/src/deployctl/webui/static/panel.js +++ b/src/deployctl/webui/static/panel.js @@ -1,7 +1,6 @@ /* deployctl control panel — all of the panel's behaviour. * - * No framework and no CDN. The panel replaced htmx with the ~40 lines under - * "fetch and swap" below for three reasons, in order of importance: + * No framework and no CDN, for three reasons, in order of importance: * * 1. A page that can start a production deploy must not load script from a * third-party host. A compromised CDN would have had the same reach as the @@ -14,7 +13,11 @@ * * There are no inline handlers anywhere: the Content-Security-Policy forbids * them, so behaviour is attached by delegation from data-act attributes. Adding - * a button means adding a data-act, not a diff --git a/src/deployctl/webui/templates/index.html b/src/deployctl/webui/templates/index.html index 69e9853..ac9b869 100644 --- a/src/deployctl/webui/templates/index.html +++ b/src/deployctl/webui/templates/index.html @@ -1,274 +1,47 @@ {% extends "base.html" %} +{#- + The page shell: the top bar, the stepper, the tabs and the output pane. Each tab + is its own file (_tab_*.html). Anything marked data-live="" is filled in + by panel.js from /live/ — facts read from the hosts and GitHub — so the + page itself renders from local files only and never waits on ssh. +-#} {% block body %} -
-
deployctlcontrol panel · localhost
- -
-
mode{{ mode }} · {{ tls }}
-
project{{ summary.project }}
-
domain{{ summary.domain }}
-
image{{ summary.image }}
-
hosts - {{ summary.hosts|length }} · primary {{ summary.primary }}
- {% if summary.errors %} -
config - {{ summary.errors }} error(s)
- {% endif %} +
+
deployctl
+ +
+ {{ summary.project }} · {{ summary.mode }} · {{ summary.hosts|length }} host(s) · {{ summary.domain }}
+
reading the hosts and GitHub…
- -
- status unknown - -
+ +
-
+ + +
-
-
Configure
-
Deploy
-
Logs
-
Terminals
-
Tutorial
+ {#- Real tabs: buttons in a tablist, arrow keys move between them (panel.js). -#} +
+ {% for id, label in tabs %} + + {% endfor %}
- - -
-
- {% for sec in sections %} -
- - ▶ - {{ sec.title }} - {{ sec.desc }} - -
- {% if sec.special == 'hosts' %} -
- {% for h in hosts %} -
- - primary - -
- {% endfor %} - {% if not hosts %} -
- - primary - -
- {% endif %} -
- -
- The primary runs the scheduler and database migrations. It must be one of the rows above. -
- {% endif %} - -
- {% for f in sec.fields %} -
- - {% if f.type == 'select' %} - - {% elif f.type == 'password' %} - - {% elif f.type == 'number' %} - - {% else %} - - {% endif %} - {% if f.help %}{{ f.help }}{% endif %} - {#- The modal itself. Rendered server-side next to its field rather - than assembled in JS: the CSP forbids inline script, and Jinja - escapes the text for us. Every button in here is type="button" - — this sits inside the config , and a default-type button - would submit it. -#} - {% if f.guide %} - -
-
-

{{ f.guide.title }}

- -
-
    - {% for step in f.guide.steps %}
  1. {{ step }}
  2. {% endfor %} -
- {% if f.guide.link %} - {{ f.guide.link_label }} ↗ - {% endif %} - {% if f.guide.note %}

{{ f.guide.note }}

{% endif %} -
-
- {% endif %} - {% if f.key == 'IMAGE_TAG' %} -
- - fetching… -
-
- {% endif %} -
- {% endfor %} -
-
-
- {% endfor %} - - -
-
- - -
-

- Every card shows the exact deployctl command it runs and what it leaves - changed. Cards with a dashed amber border are edits you make yourself in the - Configure tab — click one to jump straight to it. Output streams into the pane on the - right. -

- - {% for f in board.flows %} -
-
-
- {{ f.flow.title }} - {% if f.flow.lead %}everyday{% endif %} -
-
{{ f.flow.desc }}
-
-
- {% for s in f.steps %} - {% if s.kind == 'edit' %} -
-
- {{ s.n }} - {{ s.edit.label }} -
- you edit · configure tab - {{ s.edit.desc }} - {% if s.edit.keys %} -
- {% for k in s.edit.keys %}{{ k }}{% endfor %} -
- {% endif %} -
- {% else %} - - {% endif %} - {% endfor %} -
-
- {% endfor %} - - {% for g in board.grids %} -
-
- {{ g.grid.title }} - {{ g.grid.desc }} -
-
- {% for a in g.actions %} - - {% endfor %} -
-
- {% endfor %} -
- - -
-
Follow logs (streams into the output pane)
-
- {% for h in hosts %} - - {% endfor %} -
-
- - -
-
Open your OS terminal (uses your own ssh agent and keys)
-
- {% for t in term_targets %} - - {% endfor %} -
-

-
- - {% include "_tutorial.html" %} - + {% include "_tab_operate.html" %} + {% include "_tab_setup.html" %} + {% include "_tab_cicd.html" %} + {% include "_tab_configure.html" %} + {% include "_tab_logs.html" %}
@@ -280,14 +53,16 @@

{{ f.guide.title }}

output - - - +
{% include "_jobs.html" %}

   
+ +
{% endblock %} diff --git a/tests/test_ci_commands.py b/tests/test_ci_commands.py index a46018f..6165750 100644 --- a/tests/test_ci_commands.py +++ b/tests/test_ci_commands.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import os import pathlib import subprocess @@ -306,6 +307,11 @@ def test_doctor_is_green_when_everything_is_in_place(repo, monkeypatch): assert result.exit_code == 0, result.output assert "every push to main deploys" in result.output + # The panel reads the switch from `value`, never from the sentence. + doc = json.loads(_cli("doctor", "--env", "production", "--json").stdout) + auto = next(item for item in doc["items"] if item["id"] == "auto-deploy") + assert auto["value"] == "on" + # ---- ci deploy / runs / auto-deploy ---------------------------------------------------- diff --git a/tests/test_history.py b/tests/test_history.py index 2b7f929..ab1ab36 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -85,3 +85,28 @@ def test_two_tags_across_the_fleet_are_reported_as_split(): "HOST\tb\tsecondary\treachable\nSTATE\tb\tIMAGE_TAG=old0000\n") state = parse_state(text) assert state["split"] is True and state["tag"] is None + + +def test_history_json_is_what_the_panel_reads(write_config, monkeypatch): + """`deploy history --json`: this environment's entries, oldest first, nothing else on stdout.""" + import json + + from typer.testing import CliRunner + + from deployctl.cli.commands import deploy + from deployctl.cli.main import app + + write_config("production", "MODE=single\nPROJECT_NAME=demo\nBASE_DOMAIN=demo.test\n" + "IMAGE_REPO=ghcr.io/acme/demo\nHOSTS=203.0.113.10\nACME_EMAIL=ops@demo.test\n" + "POSTGRES_DB=demo\nPOSTGRES_USER=demo\n") + primary = ("2026-09-24T09:00:00Z\tproduction\t6dff488\tdeploy\tamal@mac\n" + "2026-09-24T09:30:00Z\tstaging\t1111111\tdeploy\tamal@mac\n" + "2026-09-25T10:00:00Z\tproduction\t8e3e648\tdeploy\thttps://github.com/acme/demo/actions/runs/1\n") + monkeypatch.setattr(deploy, "_engine_output", lambda cfg, command: primary) + + result = CliRunner().invoke(app, ["deploy", "history", "--env", "production", "--json"]) + assert result.exit_code == 0, result.output + doc = json.loads(result.stdout) + assert doc["env"] == "production" + assert [e["tag"] for e in doc["entries"]] == ["6dff488", "8e3e648"] + assert doc["entries"][1]["by"].endswith("/actions/runs/1") diff --git a/tests/test_panel_actions.py b/tests/test_panel_actions.py index 2801bc3..7b07c8c 100644 --- a/tests/test_panel_actions.py +++ b/tests/test_panel_actions.py @@ -1,28 +1,25 @@ -"""The Deploy tab's whitelist and its arrangement. +"""The panel's whitelist, the values it accepts, and where each action sits. -``ACTIONS`` is the security boundary — ``/run`` refuses anything absent from it — -and ``FLOWS``/``GRIDS`` are the teaching layer on top. The tests below pin the -property that keeps the two honest: a flow may only reference an action that -exists and applies, so the panel can never render a button the route would refuse. +``ACTIONS`` is the security boundary — ``/run`` refuses anything absent from it, +and fills in nothing but the environment and an action's declared params, each +checked against its pattern. ``FLOWS``/``GRIDS``/``ci_fix`` are the arrangement on +top. The tests below pin both: no value reaches a command unchecked or split +into two arguments, and the panel never renders a button the route would refuse. """ from __future__ import annotations -import pathlib -import sys - import pytest - from deployctl.cli import config as cli_config # noqa: E402 from deployctl.webui.panel import actions # noqa: E402 +from deployctl.webui.panel.routes import TABS # noqa: E402 _SINGLE = """\ MODE=single PROJECT_NAME=demo BASE_DOMAIN=example.com IMAGE_REPO=ghcr.io/acme/demo -IMAGE_TAG=fb31c25 HOSTS=10.0.0.1 ACME_EMAIL=ops@example.com TLS_MODE=letsencrypt @@ -35,7 +32,6 @@ PROJECT_NAME=demo BASE_DOMAIN=example.com IMAGE_REPO=ghcr.io/acme/demo -IMAGE_TAG=fb31c25 HOSTS=10.0.0.1 10.0.0.2 PRIMARY_HOST=10.0.0.1 TLS_MODE=loadbalancer @@ -61,6 +57,11 @@ def cluster(write_config): return cli_config.load("production") +def _rendered(board: dict) -> set[str]: + ids = {s["action"].id for f in board["flows"].values() for s in f["steps"] if s["kind"] == "action"} + return ids | {a.id for g in board["grids"].values() for a in g["actions"]} + + class TestWhitelistIntegrity: def test_every_flow_step_is_a_real_action(self): for flow in actions.FLOWS: @@ -73,74 +74,141 @@ def test_every_grid_entry_is_a_real_action(self): for entry in grid.action_ids: assert entry in actions.ACTIONS, f"{grid.id} references unknown action {entry!r}" - def test_every_action_is_reachable_from_the_tab(self): + def test_every_action_is_placed_somewhere(self): """An action nobody can see is a maintenance trap, not a safety feature.""" placed = {e for f in actions.FLOWS for e in f.steps if isinstance(e, str)} placed |= {e for g in actions.GRIDS for e in g.action_ids} + placed |= actions.CI_FIX_ACTIONS | set(actions.ROW_ACTIONS) assert placed == set(actions.ACTIONS), f"unplaced: {set(actions.ACTIONS) - placed}" - def test_argv_never_interpolates_anything_but_env(self): + def test_argv_fills_in_only_env_and_declared_params_as_whole_arguments(self): for action in actions.ACTIONS.values(): + declared = {f"{{{p.name}}}" for p in action.params} for part in action.argv: - assert "{" not in part or part == "{env}", f"{action.id}: {part!r}" + if "{" in part or "}" in part: + assert part == "{env}" or part in declared, f"{action.id}: {part!r}" - def test_host_touching_mutations_are_all_confirmed(self): + def test_every_declared_param_is_used(self): + for action in actions.ACTIONS.values(): + for param in action.params: + assert f"{{{param.name}}}" in action.argv, f"{action.id} declares {param.name} and never uses it" + + def test_confirmed_actions_all_reach_beyond_this_machine(self): for action in actions.ACTIONS.values(): if action.danger: - assert action.touches_hosts, f"{action.id} is danger but never leaves this machine" + assert action.where != actions.LOCAL, f"{action.id} is danger but never leaves this machine" + + def test_everything_that_deploys_is_confirmed(self): + for action in actions.ACTIONS.values(): + if action.where in (actions.HOSTS, actions.THROUGH_GITHUB) and action.argv[:2] in ( + ("deploy", "update"), ("deploy", "init"), ("deploy", "rollback"), ("ci", "deploy"), + ) and "--dry-run" not in action.argv: + assert action.danger, f"{action.id} changes what runs without asking" def test_every_action_states_its_effect(self): for action in actions.ACTIONS.values(): assert action.effect, f"{action.id} does not say what it leaves changed" + def test_flows_and_grids_sit_on_real_tabs(self): + tabs = {tab for tab, _ in TABS} + for group in (*actions.FLOWS, *actions.GRIDS): + assert group.tab in tabs, f"{group.id} is on tab {group.tab!r}" + + +class TestParams: + """The one value the page passes: checked, then passed as one whole argument.""" + + deploy = actions.ACTIONS["ci-deploy"] + + def test_a_valid_tag_becomes_exactly_one_argument(self): + values = self.deploy.values({"env": "production", "t": "token", "tag": "8e3e648"}) + argv = self.deploy.command("production", values) + assert argv == ["ci", "deploy", "--env", "production", "--tag", "8e3e648"] + + @pytest.mark.parametrize("bad", [ + "8e3e648;rm -rf /", "8e3e648 --force", "$(id)", "--help", "-t", "a" * 129, "tag\nnext", "", + ]) + def test_anything_but_a_plain_tag_is_refused(self, bad): + with pytest.raises(actions.ParamError): + self.deploy.values({"tag": bad}) + + def test_a_missing_value_is_refused(self): + with pytest.raises(actions.ParamError, match="required"): + self.deploy.values({"env": "production"}) + + def test_an_undeclared_value_is_refused(self): + with pytest.raises(actions.ParamError, match="host"): + self.deploy.values({"tag": "8e3e648", "host": "10.0.0.9"}) + + def test_an_action_without_params_takes_none(self): + with pytest.raises(actions.ParamError): + actions.ACTIONS["status"].values({"env": "production", "tag": "8e3e648"}) + + def test_routing_keys_are_not_values(self): + assert actions.ACTIONS["status"].values({"env": "production", "t": "x"}) == {} + + def test_the_card_shows_a_param_as_a_placeholder(self): + assert self.deploy.shown("production") == "deployctl ci deploy --env production --tag " -class TestBoardForSingleServer: - def test_release_flow_is_first_and_marked_everyday(self, single): - board = actions.board(single) - assert board["flows"][0]["flow"].id == "release" - assert board["flows"][0]["flow"].lead is True - def test_release_flow_starts_with_the_tag_edit(self, single): - steps = actions.board(single)["flows"][0]["steps"] - assert steps[0]["kind"] == "edit" - assert "IMAGE_TAG" in steps[0]["edit"].keys +class TestBoard: + def test_first_deploy_is_the_documented_order(self, single): + steps = actions.board(single)["flows"]["first-deploy"]["steps"] + assert [s["action"].id for s in steps] == [ + "regenerate", "validate", "doctor-tag", "init", "ssl-setup", "status", + ] - def test_release_order_is_the_documented_one(self, single): - steps = actions.board(single)["flows"][0]["steps"] - ids = [s["action"].id if s["kind"] == "action" else "EDIT" for s in steps] - assert ids == ["EDIT", "regenerate", "validate", "doctor", "update", "status"] + def test_first_deploy_asks_for_the_tag_once(self, single): + flow = actions.board(single)["flows"]["first-deploy"] + assert [p.name for p in flow["params"]] == ["tag"] - def test_ssl_is_present_for_letsencrypt(self, single): - first = next(f for f in actions.board(single)["flows"] if f["flow"].id == "first") - assert any(s["kind"] == "action" and s["action"].id == "ssl-setup" for s in first["steps"]) + def test_applying_config_starts_with_the_edit(self, single): + steps = actions.board(single)["flows"]["apply-config"]["steps"] + assert [s["kind"] if s["kind"] == "edit" else s["action"].id for s in steps] == ["edit", "ci-sync", "ci-apply"] + def test_maintenance_asks_for_nothing(self, single): + assert actions.board(single)["grids"]["maintenance"]["params"] == [] -class TestBoardForCluster: def test_ssl_steps_are_dropped_behind_a_load_balancer(self, cluster): - board = actions.board(cluster) - rendered = { - s["action"].id - for f in board["flows"] for s in f["steps"] if s["kind"] == "action" - } | {a.id for g in board["grids"] for a in g["actions"]} + rendered = _rendered(actions.board(cluster)) assert "ssl-setup" not in rendered assert "ssl-check" not in rendered def test_step_numbers_stay_contiguous_after_a_drop(self, cluster): """A gap where a hidden step used to be reads as a missing instruction.""" - for flow in actions.board(cluster)["flows"]: + for flow in actions.board(cluster)["flows"].values(): numbers = [s["n"] for s in flow["steps"]] assert numbers == list(range(1, len(numbers) + 1)), flow["flow"].id def test_nothing_rendered_is_outside_the_whitelist(self, cluster): usable = {a.id for a in actions.available(cluster)} - board = actions.board(cluster) - for flow in board["flows"]: - for step in flow["steps"]: - if step["kind"] == "action": - assert step["action"].id in usable - for grid in board["grids"]: - for action in grid["actions"]: - assert action.id in usable + assert _rendered(actions.board(cluster)) <= usable + + +class TestCiFix: + """Each row of `ci doctor` gets the button that fixes it — or none.""" + + @pytest.mark.parametrize("item, expected", [ + ({"id": "scope", "status": "todo"}, "ci-connect"), + ({"id": "workflow:deploy.yml", "status": "todo"}, "ci-init"), + ({"id": "workflow:deploy.yml", "status": "warn"}, "ci-init-force"), + ({"id": "workflow:ci", "status": "todo"}, "ci-init"), + ({"id": "ssh-key", "status": "todo"}, "ci-setup-key"), + ({"id": "ssh-key", "status": "ok"}, "ci-rotate-key"), + ({"id": "host-keys", "status": "todo"}, "ci-pin-hosts"), + ({"id": "config", "status": "warn"}, "ci-sync"), + ({"id": "config", "status": "ok"}, None), + ({"id": "auto-deploy", "status": "ok", "value": "off"}, "ci-auto-on"), + ({"id": "auto-deploy", "status": "ok", "value": "on"}, "ci-auto-off"), + # Only a person can fix these: no button, the row shows the command. + ({"id": "github", "status": "fail"}, None), + ({"id": "install-token", "status": "todo"}, None), + ]) + def test_row_to_action(self, item, expected): + assert actions.ci_fix(item) == expected + + def test_every_fix_is_a_real_action(self): + assert actions.CI_FIX_ACTIONS <= set(actions.ACTIONS) class TestMigrateVisibility: @@ -149,8 +217,7 @@ def test_migrate_is_hidden_without_a_migrate_command(self, write_config): cfg = cli_config.load("production") cfg.raw["MIGRATE_CMD"] = "" assert "migrate" not in {a.id for a in actions.available(cfg)} - rendered = {a.id for g in actions.board(cfg)["grids"] for a in g["actions"]} - assert "migrate" not in rendered + assert "migrate" not in _rendered(actions.board(cfg)) class TestSavingHosts: diff --git a/tests/test_panel_http.py b/tests/test_panel_http.py index 7ae2d32..8f6900b 100644 --- a/tests/test_panel_http.py +++ b/tests/test_panel_http.py @@ -1,23 +1,21 @@ -"""The panel over HTTP: what a confirmation dialog is able to name. +"""The panel over HTTP: the page, Save, /run's refusals, and the live fragments. -A host-changing click asked "Update — This changes a running deployment. -Continue?" with no environment in it, while the picker holds staging and -production side by side. The dialog now names the environment, its hosts and -the saved image tag; these tests pin that the page carries those values and that -a Save hands back the new ones. +Nothing here runs a real command or opens ssh: /run is exercised only up to the +point it would start a job (the registry is replaced), and the live facts come +from canned CLI output handed to the cache. """ from __future__ import annotations +import json import pathlib import re -import sys +import types import pytest from fastapi.testclient import TestClient - -from deployctl.webui.panel import create_app, security # noqa: E402 +from deployctl.webui.panel import create_app, jobs, live, security # noqa: E402 PANEL_JS = (pathlib.Path(__file__).resolve().parent.parent / "src" / "deployctl") / "webui" / "static" / "panel.js" @@ -26,13 +24,14 @@ PROJECT_NAME=demo BASE_DOMAIN=demo.test IMAGE_REPO=ghcr.io/acme/demo -IMAGE_TAG=fb31c25 HOSTS=203.0.113.10 ACME_EMAIL=ops@demo.test POSTGRES_DB=demo POSTGRES_USER=demo """ +T = security.TOKEN + @pytest.fixture def client(write_config): @@ -41,22 +40,182 @@ def client(write_config): return TestClient(create_app(), base_url="http://127.0.0.1") +# ---- the page --------------------------------------------------------------------- + + def test_the_page_carries_what_the_dialog_names(client): page = client.get("/?env=production").text assert 'data-env="production"' in page assert 'data-hosts="203.0.113.10"' in page - assert 'data-image="demo:fb31c25"' in page -def test_a_save_hands_back_the_new_tag(client): - saved = client.post(f"/config?env=production&t={security.TOKEN}", data={"IMAGE_TAG": "1234abc"}) +def test_every_tab_is_a_real_tab(client): + page = client.get("/?env=production").text + for tab in ("operate", "setup", "cicd", "configure", "logs"): + assert f'id="tab-{tab}"' in page and f'id="pane-{tab}"' in page + assert f'aria-controls="pane-{tab}"' in page + assert page.count('role="tab"') == 5 + assert page.count('aria-selected="true"') == 1, "exactly one tab is open" + + +def test_a_fresh_project_opens_on_setup(client): + """Configured, never deployed from here, no workflows: the next thing to do is in Setup.""" + page = client.get("/?env=production").text + assert re.search(r'id="tab-setup" class="tab active"', page) + + +def test_the_page_has_no_tutorial_or_terminals_tab(client): + page = client.get("/?env=production").text + assert "tab-tutorial" not in page and "tab-terminals" not in page + + +def test_the_bootstrap_card_names_this_environment(client): + page = client.get("/?env=production").text + assert "deployctl server bootstrap-script --env production | ssh root@203.0.113.10 'bash -s'" in page + + +def test_a_save_hands_back_the_hosts(client): + saved = client.post(f"/config?env=production&t={T}", data={"HOSTS": "203.0.113.11"}) assert saved.status_code == 200, saved.text - assert 'data-image="demo:1234abc"' in saved.text + assert 'data-hosts="203.0.113.11"' in saved.text -def test_the_dialog_names_environment_hosts_and_image(): +def test_the_dialog_names_environment_hosts_and_tag(): js = PANEL_JS.read_text() body = js[js.index("function confirmText"):js.index("function runAction")] - for needed in ("ENV", "d.hosts", "d.image"): + for needed in ("ENV", "d.hosts", "values.tag", "where"): assert needed in body, f"confirmText no longer names {needed}" - assert re.search(r"confirm\(confirmText\(label\)\)", js), "runAction must ask confirmText's question" + assert re.search(r"confirm\(confirmText\(el\.dataset\.label, el\.dataset\.where, got\.values\)\)", js), ( + "runAction must ask confirmText's question" + ) + + +# ---- /run ------------------------------------------------------------------------- + + +@pytest.fixture +def started(monkeypatch): + """Replace the job registry: record what would start, start nothing.""" + calls = [] + + def start(*, env, label, argv, exclusive): + calls.append({"env": env, "label": label, "argv": argv, "exclusive": exclusive}) + fake = types.SimpleNamespace(id="fake", env=env, label=label, status="running") + raise jobs.Busy(fake) + + monkeypatch.setattr(jobs.REGISTRY, "start", start) + return calls + + +def test_a_tag_reaches_the_command_as_one_argument(client, started): + client.get(f"/run/ci-deploy?env=production&tag=8e3e648&t={T}") + assert started == [{"env": "production", "label": "Deploy 8e3e648", "exclusive": True, + "argv": ["ci", "deploy", "--env", "production", "--tag", "8e3e648"]}] + + +@pytest.mark.parametrize("query, why", [ + ("tag=8e3e648;id", "not valid"), + ("tag=--force", "not valid"), + ("", "required"), + ("tag=8e3e648&host=10.0.0.9", "takes no host"), + ("tag=8e3e648&tag=1234567", "twice"), +]) +def test_a_bad_value_starts_nothing_and_says_why(client, started, query, why): + reply = client.get(f"/run/ci-deploy?env=production&{query}&t={T}") + assert reply.status_code == 200 and "refused" in reply.text and why in reply.text + assert started == [] + + +def test_an_unknown_action_is_404(client, started): + assert client.get(f"/run/rm-rf?env=production&t={T}").status_code == 404 + assert started == [] + + +# ---- /live ------------------------------------------------------------------------ + +SERVER = { + "env": "production", "tag": "8e3e648", "split": False, + "hosts": [{ + "host": "203.0.113.10", "role": "primary", "reachable": True, "tag": "8e3e648", + "deployed_at": "2026-09-25T10:00:00Z", + "deployed_by": "https://github.com/acme/demo/actions/runs/1", + "services": [ + {"name": "api", "state": "running", "health": "healthy", "restarts": 0, + "exit_code": "0", "restart_policy": "unless-stopped"}, + {"name": "worker", "state": "restarting", "health": "none", "restarts": 7, + "exit_code": "137", "restart_policy": "unless-stopped"}, + ], + }], +} +HISTORY = {"env": "production", "entries": [ + {"at": "2026-09-24T09:00:00Z", "tag": "6dff488", "kind": "deploy", "by": "amal@mac"}, + {"at": "2026-09-25T10:00:00Z", "tag": "8e3e648", "kind": "deploy", + "by": "https://github.com/acme/demo/actions/runs/1"}, +]} +CI = {"env": "production", "items": [ + {"id": "github", "status": "ok", "title": "GitHub", "detail": "acme/demo (private)", "fix": ""}, + {"id": "config", "status": "warn", "title": "Deploy config in GitHub", + "detail": "differs from this machine's config/", "fix": "deployctl ci sync-config --env production"}, + {"id": "install-token", "status": "todo", "title": "Installing deployctl in CI", + "detail": "private", "fix": "gh secret set DEPLOYCTL_INSTALL_TOKEN"}, + {"id": "auto-deploy", "status": "ok", "title": "Automatic deploys", "detail": "on", "fix": "", "value": "on"}, +]} +RUNS = [{"at": "2026-09-25T10:00:00Z", "trigger": "merge", "action": "update", "tag": "8e3e648", + "status": "success", "url": "https://github.com/acme/demo/actions/runs/1"}] + + +@pytest.fixture +def canned(monkeypatch): + """The live cache, reading canned CLI output instead of running the CLI.""" + answers = {("deploy", "status"): SERVER, ("deploy", "history"): HISTORY, ("ci", "doctor"): CI, ("ci", "runs"): RUNS} + reads = [] + + def read(argv): + reads.append(argv) + return json.loads(json.dumps(answers[tuple(argv[:2])])), "" + + monkeypatch.setattr(live, "CACHE", live.Cache(read=read)) + return reads + + +def test_the_bar_shows_what_runs_and_what_is_wrong(client, canned): + bar = client.get(f"/live/bar?env=production&t={T}").text + assert "8e3e648" in bar and "GitHub Actions" in bar + assert "1 problem(s)" in bar, "the restarting worker must show" + assert "config differs from GitHub" in bar + assert "auto-deploy on" in bar + + +def test_the_production_card_escapes_what_the_hosts_say(client, canned): + card = client.get(f"/live/production?env=production&t={T}").text + assert "<b>worker</b>" in card and "worker" not in card + + +def test_history_offers_a_rollback_to_the_earlier_release_only(client, canned): + rows = client.get(f"/live/history?env=production&t={T}").text + assert rows.count('data-url="/run/ci-rollback-to') == 1 + assert '' in rows + + +def test_the_checklist_gives_fixable_rows_a_button_and_the_rest_a_command(client, canned): + rows = client.get(f"/live/checklist?env=production&t={T}").text + assert 'data-url="/run/ci-sync?env=production"' in rows + assert 'data-url="/run/ci-auto-off?env=production"' in rows + assert "gh secret set DEPLOYCTL_INSTALL_TOKEN" in rows + + +def test_the_journey_points_at_the_first_open_ci_item(client, canned): + strip = client.get(f"/live/journey?env=production&t={T}").text + assert "Finish CI/CD: Installing deployctl in CI." in strip + assert 'data-tab="cicd"' in strip + + +def test_facts_are_read_once_for_every_part_that_needs_them(client, canned): + for part in ("bar", "journey", "production", "history"): + client.get(f"/live/{part}?env=production&t={T}") + status_reads = [argv for argv in canned if argv[:2] == ["deploy", "status"]] + assert len(status_reads) == 1 + + +def test_an_unknown_part_is_404(client, canned): + assert client.get(f"/live/secrets?env=production&t={T}").status_code == 404 diff --git a/tests/test_panel_live.py b/tests/test_panel_live.py new file mode 100644 index 0000000..520a0e0 --- /dev/null +++ b/tests/test_panel_live.py @@ -0,0 +1,214 @@ +"""Live facts: the cache's timing, where a project is on its way to live, and the +rules that turn `deploy status` / `deploy history` into what the page shows.""" + +from __future__ import annotations + +import datetime +import threading + +import pytest + +from deployctl.webui.panel import live + + +class Clock: + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +class TestCache: + def _cache(self): + clock, reads = Clock(), [] + + def read(argv): + reads.append(argv) + return {"n": len(reads)}, "" + + return live.Cache(read=read, clock=clock), clock, reads + + def test_a_fact_is_reused_within_the_ttl_and_read_again_after(self): + cache, clock, reads = self._cache() + cache.get("production", "server") + clock.now += live.TTL - 1 + cache.get("production", "server") + assert len(reads) == 1 + clock.now += 2 + cache.get("production", "server") + assert len(reads) == 2 + + def test_the_command_is_the_clis_own_with_the_env_as_one_argument(self): + cache, _, reads = self._cache() + cache.get("staging", "ci") + assert reads == [["ci", "doctor", "--env", "staging", "--json"]] + + def test_fresh_rereads_a_fact_older_than_the_request(self): + cache, clock, reads = self._cache() + cache.get("production", "server") + clock.now += 1 + assert cache.get("production", "server", fresh=True).data == {"n": 2} + + def test_fresh_requests_during_one_read_share_it(self): + """The burst of refreshes after a job: one ssh, not one per panel.""" + gate, reads = threading.Event(), [] + + def read(argv): + reads.append(argv) + gate.wait(2) + return {}, "" + + cache = live.Cache(read=read) + threads = [threading.Thread(target=cache.get, args=("production", "server"), kwargs={"fresh": True}) + for _ in range(4)] + for thread in threads: + thread.start() + gate.set() + for thread in threads: + thread.join() + assert 1 <= len(reads) <= 2, "at most the read already running plus one after it" + + def test_a_failed_read_keeps_the_reason(self): + cache = live.Cache(read=lambda argv: (None, "ssh: connect to host 10.0.0.1 port 22: timed out")) + fact = cache.get("production", "server") + assert not fact.ok and "timed out" in fact.error + + def test_peek_never_reads(self): + cache, _, reads = self._cache() + assert cache.peek("production", "server") is None + assert reads == [] + + +LOCAL = {"configured": True, "workflows": True, "ci_scope": True, "branch": "prod"} + + +def fact(data, error=""): + return live.Fact(data, error, 0.0) + + +def host(tag="8e3e648", reachable=True, services=()): + return {"host": "10.0.0.1", "role": "primary", "reachable": reachable, "tag": tag, + "deployed_at": "", "deployed_by": "", "services": list(services)} + + +def ci(*statuses): + return fact({"items": [{"id": f"i{n}", "status": s, "title": f"item {n}"} for n, s in enumerate(statuses)]}) + + +def stages(journey): + return {s["id"]: s["status"] for s in journey["stages"]} + + +class TestLanding: + @pytest.mark.parametrize("local, tab", [ + ({**LOCAL, "configured": False}, "setup"), + ({**LOCAL, "workflows": False}, "setup"), + ({**LOCAL, "ci_scope": False}, "setup"), + (LOCAL, "operate"), + ]) + def test_the_tab_a_page_opens_on(self, local, tab): + assert live.landing(local) == tab + + +class TestJourney: + def test_everything_done_is_live(self): + journey = live.journey(LOCAL, fact({"tag": "8e3e648", "hosts": [host()]}), ci("ok", "warn")) + assert stages(journey) == {"server": "done", "first": "done", "cicd": "done", "live": "done"} + assert journey["next"] == {"text": "Live. Ship by merging to prod.", "tab": ""} + + def test_an_unconfigured_project_starts_at_setup(self): + journey = live.journey({**LOCAL, "configured": False}, None, None) + assert stages(journey)["server"] == "todo" + assert journey["next"]["tab"] == "setup" + + def test_an_unreachable_host_is_a_failed_server(self): + journey = live.journey(LOCAL, fact({"tag": None, "hosts": [host(tag="", reachable=False)]}), ci("ok")) + assert stages(journey)["server"] == "fail" + assert "10.0.0.1" in journey["next"]["text"] and journey["next"]["tab"] == "setup" + + def test_reachable_but_empty_asks_for_the_first_deploy(self): + journey = live.journey(LOCAL, fact({"tag": None, "hosts": [host(tag="")]}), ci("todo")) + assert stages(journey)["first"] == "todo" + assert journey["next"] == {"text": "Nothing is running yet — do the first deploy.", "tab": "setup"} + + def test_a_missing_ci_item_is_named(self): + journey = live.journey(LOCAL, fact({"tag": "a", "hosts": [host()]}), ci("ok", "todo", "fail")) + assert journey["next"] == {"text": "Finish CI/CD: item 1.", "tab": "cicd"} + + def test_facts_that_could_not_be_read_are_unknown_not_failed(self): + journey = live.journey(LOCAL, fact(None, "timed out"), fact(None, "gh: not logged in")) + assert stages(journey) == {"server": "unknown", "first": "unknown", "cicd": "unknown", "live": "todo"} + assert journey["next"]["tab"] == "" + + +def svc(name, state="running", health="none", code="0", policy="unless-stopped"): + return {"name": name, "state": state, "health": health, "restarts": 0, "exit_code": code, "restart_policy": policy} + + +class TestServiceProblems: + """The deploy's own service-watch rules, applied to what runs now.""" + + def test_all_running_is_fine(self): + assert live.service_problems({"hosts": [host(services=[svc("api", health="healthy"), svc("worker")])]}) == [] + + def test_a_one_shot_that_exited_zero_is_fine(self): + assert live.service_problems({"hosts": [host(services=[svc("migrate", "exited", policy="no")])]}) == [] + + def test_restarting_and_unhealthy_are_problems(self): + found = live.service_problems({"hosts": [host(services=[ + svc("worker", "restarting", code="137"), svc("api", health="unhealthy")])]}) + assert found == ["10.0.0.1: worker restarting", "10.0.0.1: api unhealthy"] + + def test_an_exited_one_off_beside_the_live_container_does_not_count(self): + services = [svc("api", "exited", code="1", policy="no"), svc("api")] + assert live.service_problems({"hosts": [host(services=services)]}) == [] + + def test_an_unreachable_host_is_a_problem(self): + assert live.service_problems({"hosts": [host(reachable=False)]}) == ["10.0.0.1: unreachable"] + + +class TestHistoryRows: + ENTRIES = [ + {"at": "2026-09-20T10:00:00Z", "tag": "aaa1111", "kind": "init", "by": "amal@mac"}, + {"at": "2026-09-21T10:00:00Z", "tag": "bbb2222", "kind": "deploy", "by": "amal@mac"}, + {"at": "2026-09-22T10:00:00Z", "tag": "ccc3333", "kind": "deploy", "by": "amal@mac"}, + {"at": "2026-09-23T10:00:00Z", "tag": "bbb2222", "kind": "rollback", "by": "amal@mac"}, + ] + + def test_newest_first_with_the_running_tag_marked_once(self): + rows = live.history_rows(self.ENTRIES, "bbb2222") + assert [r["tag"] for r in rows] == ["bbb2222", "ccc3333", "bbb2222", "aaa1111"] + assert [r["running"] for r in rows] == [True, False, False, False] + + def test_roll_back_is_offered_once_per_earlier_release(self): + rows = live.history_rows(self.ENTRIES, "bbb2222") + assert [r["roll_back"] for r in rows] == [False, True, False, True], ( + "not the rollback entry, not the running tag, and each earlier release once" + ) + + def test_the_list_is_capped(self): + assert len(live.history_rows(self.ENTRIES * 5, None, limit=10)) == 10 + + +class TestPresentation: + NOW = datetime.datetime(2026, 9, 25, 12, 0, tzinfo=datetime.timezone.utc) + + @pytest.mark.parametrize("stamp, expected", [ + ("2026-09-25T11:59:30Z", "just now"), + ("2026-09-25T11:45:00Z", "15m ago"), + ("2026-09-25T10:00:00Z", "2h ago"), + ("2026-09-22T12:00:00Z", "3d ago"), + ("not a date", ""), + ("", ""), + ]) + def test_ago(self, stamp, expected): + assert live.ago(stamp, now=self.NOW) == expected + + def test_an_actions_run_links_to_it(self): + assert live.who("https://github.com/acme/demo/actions/runs/9") == { + "label": "GitHub Actions", "url": "https://github.com/acme/demo/actions/runs/9"} + + def test_anything_else_is_shown_as_is_and_never_linked(self): + assert live.who("amal@mac") == {"label": "amal@mac", "url": ""} + assert live.who("javascript:alert(1)") == {"label": "javascript:alert(1)", "url": ""} diff --git a/tests/test_panel_security.py b/tests/test_panel_security.py index 1e89971..790fae5 100644 --- a/tests/test_panel_security.py +++ b/tests/test_panel_security.py @@ -48,7 +48,7 @@ def test_page_and_assets_need_no_token(self): @pytest.mark.parametrize("path", [ "/run/update", "/run/stop", "/config", "/logs/10.0.0.1", - "/status", "/image-tags", "/config/preview", + "/live/bar", "/live/checklist", "/image-tags", "/config/preview", "/config/problems", "/open-terminal/local", "/open-terminal/ssh/10.0.0.1", "/jobs", "/jobs/20260923-101500-abc123/stream", "/jobs/20260923-101500-abc123/cancel", ]) diff --git a/tests/test_panel_wiring.py b/tests/test_panel_wiring.py index e9781de..ab9cf03 100644 --- a/tests/test_panel_wiring.py +++ b/tests/test_panel_wiring.py @@ -7,7 +7,7 @@ but no ``data-act`` renders perfectly, looks clickable, and silently does nothing. That is exactly what happened to the tag picker: the cards had ``data-tag`` but no -``data-act``, so clicking a tag did not fill IMAGE_TAG. Nothing errored — the +``data-act``, so clicking a tag did not fill the tag field. Nothing errored — the handler simply returned early. These tests close the loop in both directions: every ``data-act`` the server or @@ -78,7 +78,7 @@ def test_no_handler_is_orphaned(self): class TestTagPicker: - """The specific regression, pinned.""" + """The specific regression, pinned — and its successor: a card fills its own group's input.""" def test_tag_cards_carry_both_attributes(self): source = ROUTES_PY.read_text() @@ -89,15 +89,44 @@ def test_tag_cards_carry_both_attributes(self): ) assert "data-tag=" in card.group(0), "tag cards must carry the tag itself" - def test_panel_js_reads_the_tag_from_the_dataset(self): + def test_panel_js_hands_the_card_to_pick_tag(self): js = PANEL_JS.read_text() assert "case 'tag':" in js - assert "pickTag(el.dataset.tag)" in js + assert "pickTag(el)" in js - def test_pick_tag_targets_the_image_tag_input(self): + def test_pick_tag_fills_the_input_in_its_own_scope(self): + """Operate, Emergency and Setup each have a tag field; a card must not fill another's.""" js = PANEL_JS.read_text() pick = js[js.index("function pickTag"):] - assert "imageTagInput" in pick[:400], "pickTag no longer targets the IMAGE_TAG input" + assert "closest('[data-param-scope]')" in pick[:500] + assert "input[name=\"tag\"]" in pick[:500] + + def test_the_picker_lands_inside_a_param_scope(self): + """The picker's slot is rendered by param_inputs, which only groups with data-param-scope call.""" + macros = (TEMPLATES / "_macros.html").read_text() + for macro in ("flow", "grid"): + body = macros[macros.index(f"{{% macro {macro}("):] + body = body[:body.index("{%- endmacro %}")] + assert "data-param-scope" in body and "param_inputs(" in body, macro + + +class TestParamsReachTheServer: + """A button that takes a value must be able to find it, and send it.""" + + def test_run_buttons_name_their_params(self): + macros = (TEMPLATES / "_macros.html").read_text() + attrs = macros[macros.index("{% macro run_attrs"):] + assert 'data-params="{{ a.param_names }}"' in attrs[:600] + + def test_history_rows_give_their_button_a_tag(self): + row = (TEMPLATES / "_live_history.html").read_text() + assert "data-param-scope" in row + assert '' in row + + def test_values_are_url_encoded(self): + js = PANEL_JS.read_text() + run = js[js.index("function runAction"):] + assert "encodeURIComponent(value)" in run[:1200] class TestNoInlineStyles: @@ -136,7 +165,7 @@ def test_the_registry_token_has_a_guide(self): assert guide.link.startswith("https://") def test_the_template_opens_the_dialog_it_renders(self): - text = (TEMPLATES / "index.html").read_text() + text = (TEMPLATES / "_tab_configure.html").read_text() assert 'data-guide="guide-{{ f.key }}"' in text assert 'id="guide-{{ f.key }}"' in text, ( "the button's data-guide must match the dialog's id, or the modal never opens" @@ -144,7 +173,7 @@ def test_the_template_opens_the_dialog_it_renders(self): def test_guide_buttons_cannot_submit_the_config_form(self): """The dialog sits inside
; a default button submits it.""" - text = (TEMPLATES / "index.html").read_text() + text = (TEMPLATES / "_tab_configure.html").read_text() for act in ("guide", "guide-close"): button = re.search(rf']*data-act="{act}"[^>]*>', text) or re.search( rf']*data-act="{act}"[^>]*', text diff --git a/tests/test_tags.py b/tests/test_tags.py index f6e42a0..353b335 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -91,3 +91,25 @@ def test_the_cache_survives_a_forced_render(cfg): tags.remember("production", "abc1234") assert tags.cache_file("production").parent.name == "generated" assert tags.cached("production") == "abc1234" + + +def test_no_tag_means_no_image_reference(cfg): + """Never "repo:" — a half reference is how the wrong thing gets checked, or shipped.""" + assert cfg().derived["IMAGE_REF"] == "" + assert cfg().derived["WORKER_IMAGE_REF"] == "" + + +def test_validate_checks_the_files_against_the_tag_they_were_rendered_with(cfg): + """Regression: with IMAGE_TAG out of config, validate compared the compose file with + "ghcr.io/acme/demo:" and failed every project that had just rendered cleanly.""" + from typer.testing import CliRunner + + from deployctl.cli.main import app + + runner = CliRunner() + rendered = runner.invoke(app, ["setup", "--env", "production", "--tag", "8e3e648"]) + assert rendered.exit_code == 0, rendered.output + checked = runner.invoke(app, ["validate", "--env", "production", "--skip-docker"]) + assert "image tag 8e3e648" in checked.output + assert "does not reference" not in checked.output + assert checked.exit_code in (0, 2), checked.output