From 49d4932cdf8684ba306e100ee8aaf18b28cee93d Mon Sep 17 00:00:00 2001 From: David Wolfe Date: Tue, 6 Oct 2026 14:04:12 -0700 Subject: [PATCH 1/3] DUPLOAI-3742: run the extension installer beside the studio devkit runs the extension installer beside the studio, so a build in `extensions/[name]/dist/` installs on its own. - Add an `installer` service on `quay.io/duplocloud/helpdesk-installer`, pinned by `INSTALLER_TAG` in `.env.example`, which every user's `.env` adopts on the next `./run.sh` - Point the studio at it through `Extensions__InstallerUrl`, and hand it the JWT secret and the license token from `.env` and none of its LLM, AWS or admin credentials - Leave the license-server catalog off unless `LICENSE_SERVER_URL` is set - Send the bundled sample through `deploy-extension.sh`, since that build never reaches the installer. A pass with no selection key installs nothing - The Available tab hands Install to this installer only once the UI tag and the studio tag in `.env.example` are images that do that. The tags pinned there predate that handoff --- .env.example | 2 + README.md | 57 ++++- SUPPORT.md | 4 +- docker-compose.yml | 55 ++++- docs/architecture.md | 4 +- docs/configuration.md | 1 + .../build-your-first-extension.md | 5 + docs/getting-started/install.md | 5 +- docs/troubleshooting.md | 5 +- docs/upgrading.md | 2 +- hackday/Installing DevKit.md | 2 +- logs.sh | 2 +- run.sh | 10 +- scripts/_runtime.sh | 2 +- scripts/check-image-arches.sh | 1 + tests/test-installer-wiring.sh | 194 ++++++++++++++++++ 16 files changed, 334 insertions(+), 17 deletions(-) create mode 100755 tests/test-installer-wiring.sh diff --git a/.env.example b/.env.example index 6eb4b14..ffec6c9 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,14 @@ # • UI : the duplo-ui git SHA (e.g. 02ae64f593513d17a7713fdaeead7b74956085a4) # • Studio: deploy_tag = branch-- (e.g. branch-feature-plugin-acc46696) # • Agent : a main tag (no extension-specific changes) +# • Installer: a main build, main-[short-sha]. It installs your extension builds (see README) # ───────────────────────────────────────────────────────────────────────────── STUDIO_TAG=dev-1.0.6-45708c38 AGENT_TAG=dev-0.1.2-4b7fcf1 UI_TAG=e67ae329e9466304450e68a59e4f59d8bc97cb05 XTERM_TAG=dev-0.1.2-6cdf181 +INSTALLER_TAG=main-8d05eb4e # Qdrant vector database (Knowledge Base). The API key is fixed and not a secret — Qdrant is bound to # the compose network and localhost only. Change it if you like; compose and scripts/register-qdrant.sh diff --git a/README.md b/README.md index dd68971..bc1c88e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ hot-loads into the running platform with no restart. ## Why you'd want it -- **A whole platform, locally.** Six containers, one command. +- **A whole platform, locally.** Seven containers, one command. - **Your own resource types.** Not plugins bolted on the side — they get a REST API, a portal UI, and a provisioning workflow, exactly like the built-in types. - **Hot-load, no restart.** Build, deploy, and the new type is live in the UI seconds later. @@ -49,7 +49,7 @@ region against six security rules, charts the violations over time, and fixes th | | | | --- | --- | -| `docker-compose.yml` | The platform from published images — mongo, qdrant, studio, agent, ui, xterm | +| `docker-compose.yml` | The platform from published images: mongo, qdrant, studio, agent, ui, xterm, installer | | `.env` / `.env.example` | Image tags, auth, LLM credentials, and the build target | | `run.sh` `stop.sh` `logs.sh` | Lifecycle | | `scripts/` | Build, deploy, register, and upgrade | @@ -58,6 +58,59 @@ region against six security rules, charts the violations over time, and fixes th | `extensions/terraform/` | The real, shipping **Terraform extension** — source, fetched once by `run.sh`, disconnected from git and yours to modify | | `extensions//` | **Yours** (subject to our and any third party's rights in the underlying software and technology upon which they are built). The one thing an upgrade never touches | +## Installing an extension + +The `installer` service installs extensions into the running studio and keeps them current. + +**Your own build.** Build it into `extensions/[name]/dist/`: + +```bash +./scripts/build-extension.sh extensions/[name] +``` + +The installer installs each build in `extensions/[name]/dist/` within about 20 seconds of it changing, including a +rebuild at the same version. The studio may keep running the previous backend DLL after a same-version reload, as +`deploy-extension.sh` warns, so bump the version for backend changes. + +The installer reinstalls a same-version rebuild only over a build it installed itself. After a `deploy-extension.sh` +load, bump the version or run `./scripts/remove-extension.sh [id]` before relying on the installer for a rebuild. + +**From the catalog.** The Available tab lists the extensions your license entitles you to. Install there hands +the click to this installer only once the UI tag and the studio tag in `.env.example` are images that do that. +Those two tags predate that handoff. The installer reads that catalog only when `.env` sets these keys, so by +default it runs on your own builds alone: + +```bash +LICENSE_SERVER_URL=https://console.duplocloud.com +URL_ROUTE_URL=https://results.prod-apps.duplocloud.net/urls +CHANNELS_BASE_URL=https://channels.prod-apps.duplocloud.net +CHANNELS_BUCKET=duplo-helpdesk-channels +``` + +Re-run `./run.sh` after changing them. `EXTENSIONS_ENABLED=false` in `.env` stops the installer installing anything, +and `EXTENSIONS_EXCLUDE` takes a JSON array of extension ids it leaves alone. `docker-compose.yml` lists the other +`EXTENSIONS_` keys it passes through. + +**How it checks an install.** The installer judges an install by the studio's `/healthz` and the extension's row. It +sees a studio restart only as missed `/healthz` reads spanning at least 5 seconds followed by an answer. A quicker +restart goes unseen, and the row alone then judges the install. + +**A load that hangs the studio.** Under compose the installer backs out only through the studio's API, so it cannot +back out a build that hangs the studio when it starts. Stop the studio, disable the extension's row by hand, with +`[id]` its manifest id, and start the studio again: + +```bash +docker compose stop duplo-ai-studio +docker compose exec mongo mongosh -u authuser -p authpass --authenticationDatabase admin duplo-ai-helpdesk \ + --eval 'db.loaded_extensions.updateOne({ExtensionId: "[id]", IsCurrent: true}, {$set: {Status: NumberInt(1)}})' +docker compose start duplo-ai-studio +``` + +To install a fixed build, run `./scripts/remove-extension.sh [id]` and then rebuild. The rebuild has to differ in +content from the build that hung, since the installer may hold that exact build. An install whose load timed out also +waits until you restart the studio, with `docker compose restart duplo-ai-studio`. Under podman, use `podman compose` +for each `docker compose` above. + ## Documentation | Page | What it covers | diff --git a/SUPPORT.md b/SUPPORT.md index c698587..914fc2d 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -45,8 +45,8 @@ usually a report nobody can act on. Useful to attach, once redacted: `./logs.sh --no-follow` (or one service, e.g. `./logs.sh --no-follow duplo-ai-studio`), `docker compose ps`, `docker compose version`, your OS and -architecture, and the `STUDIO_TAG` / `UI_TAG` / `AGENT_TAG` / `XTERM_TAG` lines from `.env` — those -tags are not secret and they pin down exactly which build you are on. +architecture, and the `STUDIO_TAG` / `UI_TAG` / `AGENT_TAG` / `XTERM_TAG` / `INSTALLER_TAG` lines from `.env`. +Those tags are not secret and they pin down exactly which build you are on. ## Never paste diff --git a/docker-compose.yml b/docker-compose.yml index 893fd7f..d311431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,16 +43,21 @@ services: - qdrant_data:/qdrant/storage # One-shot: chown the shared named volumes to uid 1001 so the non-root studio + agent - # (both appuser 1001) can write them. Named volumes are created root-owned, and run.sh - # does `down -v` each start, so this must run every startup (see the services' depends_on). + # (both appuser 1001) can write them. Named volumes are created root-owned, on first start and again after + # `run.sh --reset` or `stop.sh --wipe` removes them, so this runs every startup (see the services' depends_on). # Both processes writing as 1001 keeps ticket/skill/extension dirs interoperable. + # The installer's state volume goes to 65532 instead, the distroless nonroot user its image runs as. init-perms: image: busybox:1.36 - command: ["sh", "-c", "chown -R 1001:1001 /data /extstudio /csessions && echo 'volumes chowned to 1001'"] + command: + - sh + - -c + - chown -R 1001:1001 /data /extstudio /csessions && chown -R 65532:65532 /installer && echo 'volumes chowned' volumes: - platform_data:/data - extension_studio_data:/extstudio - claude_sessions:/csessions + - installer_state:/installer duplo-ai-studio: image: quay.io/duplocloud/backend:${STUDIO_TAG} @@ -75,6 +80,7 @@ services: # Browser reaches xterm on the published host port, so this must be host-reachable. - AIStudio__XtermHost=http://localhost:${XTERM_PORT:-6060} - LocalFileStorage__RootPath=/data + - Extensions__InstallerUrl=http://installer:8080 volumes: # Shared /data file store (skills + loaded-extension skill files). Shared with the agent so it can # read the seeded skills. Survives restart. @@ -88,6 +94,48 @@ services: init-perms: condition: service_completed_successfully + # Installs and upgrades extensions in the studio: each build in extensions/[name]/dist/ by itself, and a catalog + # extension when the Available tab asks. Only the studio calls it, signing each call with the shared JWT secret, so + # no host port is published. + installer: + image: quay.io/duplocloud/helpdesk-installer:${INSTALLER_TAG} + restart: unless-stopped + environment: + - INSTALLER_PLATFORM=compose + - INSTALLER_BACKEND_URL=http://duplo-ai-studio:60021 + # The studio service's image, which the status record reports and holds are keyed on. + - INSTALLER_BACKEND_IMAGE=quay.io/duplocloud/backend:${STUDIO_TAG} + - INSTALLER_STATE_DIR=/var/lib/installer + - INSTALLER_LOCAL_DIR=/extensions + # One key at a time rather than env_file, so the installer holds none of .env's LLM, AWS or admin credentials. + - Authentication__JwtSharedSecret=${Authentication__JwtSharedSecret:-} + - Licensing__Token=${Licensing__Token:-} + # The selection. With no selection key set, every pass skips as no-release-config and installs nothing, so the + # installer reads an empty value as unset. + - EXTENSIONS_ENABLED=${EXTENSIONS_ENABLED:-true} + - EXTENSIONS_SOURCE=${EXTENSIONS_SOURCE:-customer} + - EXTENSIONS_EXCLUDE=${EXTENSIONS_EXCLUDE:-} + - EXTENSIONS_RESET_AT=${EXTENSIONS_RESET_AT:-} + - EXTENSIONS_UPDATE_INSTALLED=${EXTENSIONS_UPDATE_INSTALLED:-} + - EXTENSIONS_FROZEN=${EXTENSIONS_FROZEN:-} + - EXTENSIONS_DRY_RUN=${EXTENSIONS_DRY_RUN:-} + - EXTENSIONS_OPT_OUT=${EXTENSIONS_OPT_OUT:-} + - EXTENSIONS_LAYERS=${EXTENSIONS_LAYERS:-} + - EXTENSIONS_CONFIG_INVALID=${EXTENSIONS_CONFIG_INVALID:-} + - EXTENSIONS_ENROLLMENT_ID=${EXTENSIONS_ENROLLMENT_ID:-} + # The license server's catalog, off while LICENSE_SERVER_URL is empty, so the installer runs on local builds only. + - LICENSE_SERVER_URL=${LICENSE_SERVER_URL:-} + - URL_ROUTE_URL=${URL_ROUTE_URL:-} + - CHANNELS_BASE_URL=${CHANNELS_BASE_URL:-} + - CHANNELS_BUCKET=${CHANNELS_BUCKET:-} + volumes: + # The status record. A named volume, so it survives a recreate and goes with the database on `down -v`. + - installer_state:/var/lib/installer + - ./extensions:/extensions:ro + depends_on: + init-perms: + condition: service_completed_successfully + claude-code-agent: image: quay.io/duplocloud/duplo-agent:${AGENT_TAG} restart: unless-stopped @@ -260,6 +308,7 @@ volumes: extension_studio_data: claude_sessions: qdrant_data: + installer_state: # Keyed by uid on purpose: these are written as the calling user (see the service's `user:`), and one # uid's runtime-created cache subdirectories are not writable by another, so sharing a single pair # would break the second uid to build. build/Dockerfile.builder has the full reasoning, including why diff --git a/docs/architecture.md b/docs/architecture.md index e846f0f..753d38b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,8 @@ Nothing here builds from source. Every service pulls a published image pinned by | `mongo` | `mongo:7.0.14` | 27018 | All platform state, including the `loaded_extensions` records. Matches the version pinned in the helpdesk-helm chart. | | `qdrant` | `qdrant/qdrant:v1.18.2` | 6333 | The vector database behind the Knowledge Base. The studio reaches it in-network at `http://qdrant:6333`; the host port is published so its dashboard is available for debugging. Runs with RBAC on, so the studio can mint read-only per-collection tokens instead of sharing the master key. | | `xterm` | `quay.io/duplocloud/duplo-xterm` | 6061 | In-browser terminal. The browser reaches it on the published host port, so `AIStudio__XtermHost` must be host-reachable. | -| `init-perms` | `busybox:1.36` | — | One-shot. Chowns the shared named volumes to uid 1001 so the non-root studio and agent can write them. Named volumes are created root-owned, so this runs on every startup. | +| `installer` | `quay.io/duplocloud/helpdesk-installer` | none | Installs and upgrades extensions through the studio's API, from your builds in the read-only `extensions/` mount and, when configured, the license server's catalog. The studio calls it at `Extensions__InstallerUrl`, so it publishes no host port. See [Installing an extension](../README.md#installing-an-extension). | +| `init-perms` | `busybox:1.36` | none | One-shot. Chowns the shared named volumes to uid 1001 so the non-root studio and agent can write them, and the installer's state volume to 65532, its distroless nonroot user. Named volumes are created root-owned, so this runs on every startup. | Ports shown are the dev kit's `.env.example` defaults, mostly offset from the platform's standard ports so this stack can run alongside a full local platform. See [configuration.md](configuration.md#host-ports). @@ -44,6 +45,7 @@ the UI, but each upload fails. See [configuration.md](configuration.md#knowledge | `extension_studio_data` | studio | Unpacked hot-loaded extension bundles. | | `claude_sessions` | agent | Agent sessions, under its real home (`/home/appuser/.claude`). | | `qdrant_data` | qdrant | Knowledge Base vectors and their payloads. | +| `installer_state` | installer | The installer's status record, `installer-status.json`. | Because extensions live in a named volume **and** are recorded in Mongo, they survive a container recreate: `ExtensionReplayHostedService` re-registers the active records on boot. That is why diff --git a/docs/configuration.md b/docs/configuration.md index 1b91098..d44e1df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,6 +110,7 @@ Pin the published images the stack runs. | `AGENT_TAG` | `main-d27e511` | The `claude-code-agent` image that executes provisioning tickets. A `main` build — it carries no extension-specific changes. | | `UI_TAG` | `2a949c536be71efd4501dd60ca6fdda4f7b4676a` | The Angular portal image. A `duplo-ui` git SHA, pinned to a build that renders the license state. | | `XTERM_TAG` | `main-d323e0b` | The in-browser terminal image. | +| `INSTALLER_TAG` | `main-8d05eb4e` | The extension installer image. Its own keys are under [Installing an extension](../README.md#installing-an-extension). | | `STUDIO_PLATFORM` | `linux/amd64` | Docker platform for the studio image. On Apple Silicon the studio image is amd64-only and runs emulated. Leave as-is unless you have an arm64 image. | `STUDIO_TAG` and `UI_TAG` are required — `run.sh` exits if either is empty: diff --git a/docs/getting-started/build-your-first-extension.md b/docs/getting-started/build-your-first-extension.md index b043ecc..6adec5f 100644 --- a/docs/getting-started/build-your-first-extension.md +++ b/docs/getting-started/build-your-first-extension.md @@ -228,6 +228,11 @@ hot-loads it. Those last two steps are the same two commands you would run by ha ./scripts/deploy-extension.sh extensions/s3-guard/dist/extension.zip ``` +With the installer service running, the build alone is enough, since the installer loads it. A build loaded +by `deploy-extension.sh` is not one the installer reinstalls at the same version, so after a script load, +bump the version or remove the extension before relying on the installer for a rebuild (see +[Installing an extension](../../README.md#installing-an-extension)). + Their flags are in the [CLI reference](../cli-reference.md). If the build fails to reach the host SDK, that symptom is in [troubleshooting.md](../troubleshooting.md#extensions). diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 8500f95..bde77f0 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -169,9 +169,12 @@ You are done when you see: Build & deploy your extension (scripts read the target from .env — no DUPLO_BASE= prefix needed): ./scripts/build-extension.sh extensions/ # your extensions live in extensions// - ./scripts/deploy-extension.sh extensions//dist/extension.zip + # the installer service loads each build in extensions//dist/ by itself, within about 20 seconds + # without the installer, or on a remote target: ./scripts/deploy-extension.sh extensions//dist/extension.zip # or build every extension: ./scripts/build-all.sh # or build a bundled sample: ./scripts/build-extension.sh samples/helloworld + # then load it, since the installer reads only extensions/: + # ./scripts/deploy-extension.sh samples/helloworld/dist/extension.zip # re-attach the agent to another workspace: ./scripts/register-agent.sh ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a975af7..c8deb4a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -655,13 +655,14 @@ host's version — but your list stays a **subset** of the host's, never a copy packages your extension doesn't depend on, e.g. `yaml`, and `lookupVersion()` throws if you copy those in). There is no standalone dev server. Extension frontends are remotes loaded into the portal, so use the -build → deploy → hot-load loop: +build and hot-load loop. The installer service loads each build in `extensions//dist/` by itself: ```bash ./scripts/build-extension.sh extensions/ -./scripts/deploy-extension.sh extensions//dist/extension.zip ``` +On a remote target or a stack without the installer, load it with `./scripts/deploy-extension.sh extensions//dist/extension.zip`. + See [Build your first extension](getting-started/build-your-first-extension.md). ### More entries belong here diff --git a/docs/upgrading.md b/docs/upgrading.md index 47ee694..ae0b3ac 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -39,7 +39,7 @@ Framework-shipped defaults — the image tags and `STUDIO_PLATFORM` — live in `.env.example`. An upgrade changes that file, but it never touches your `.env`. `run.sh` bridges the two on every run. -The tracked keys are `STUDIO_TAG`, `AGENT_TAG`, `UI_TAG`, `XTERM_TAG`, and `STUDIO_PLATFORM`. Nothing +The tracked keys are `STUDIO_TAG`, `AGENT_TAG`, `UI_TAG`, `XTERM_TAG`, `INSTALLER_TAG`, and `STUDIO_PLATFORM`. Nothing licensing-related is adopted or overwritten this way — `Licensing__Token` is yours, not a framework default, and the license server URL is a built-in rather than a shipped `.env` value. diff --git a/hackday/Installing DevKit.md b/hackday/Installing DevKit.md index 19e3fd5..09a4ce9 100644 --- a/hackday/Installing DevKit.md +++ b/hackday/Installing DevKit.md @@ -12,7 +12,7 @@ image pulls over conference wifi. ## What you're setting up -The whole DuploCloud platform — six containers — running on your laptop via Docker. On top of it you +The whole DuploCloud platform, seven containers, running on your laptop via Docker. On top of it you build an **Agent**: your own backend, your own UI, your own provisioning workflow, hot-loaded into the running platform with no restart. You describe what you want in plain language and Claude Code builds it. diff --git a/logs.sh b/logs.sh index 5ac30d7..e96a206 100755 --- a/logs.sh +++ b/logs.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tail logs. Optional service name: duplo-ai-studio | claude-code-agent | duplo-ui | mongo | xterm +# Tail logs. Optional service name: duplo-ai-studio | claude-code-agent | duplo-ui | mongo | xterm | installer set -euo pipefail cd "$(dirname "$0")" . ./scripts/_runtime.sh # → $RUNTIME (docker | podman) diff --git a/run.sh b/run.sh index 50c9314..37f6d46 100755 --- a/run.sh +++ b/run.sh @@ -626,7 +626,7 @@ fi # kept. The last-applied defaults are remembered in .env.defaults (git-ignored) so we can tell "you changed # it" from "the default changed". To re-track a pinned key, delete its line from .env (or .env.defaults). DEFAULTS_LOCK=.env.defaults -DEFAULT_KEYS="STUDIO_TAG AGENT_TAG UI_TAG XTERM_TAG STUDIO_PLATFORM" # add ports/DUPLO_TARGET here to track them too +DEFAULT_KEYS="STUDIO_TAG AGENT_TAG UI_TAG XTERM_TAG INSTALLER_TAG STUDIO_PLATFORM" # add ports/DUPLO_TARGET here to track them too if [ -f .env.example ]; then adopted="" for k in $DEFAULT_KEYS; do @@ -1069,6 +1069,9 @@ done # so on a box with both installed the choice is otherwise invisible until it goes wrong. echo "==> Using $(runtime_label)" echo "==> Pulling images…"; "$RUNTIME" compose pull +# The installer mounts extensions/. A bind source the runtime has to create comes out root-owned under rootful docker +# on Linux, which neither fetch-terraform-extension.sh below nor a build could then write. +mkdir -p extensions echo "==> Starting…"; "$RUNTIME" compose up -d STUDIO_PORT="$(getenv STUDIO_PORT)"; [ -z "$STUDIO_PORT" ] && STUDIO_PORT=60021 @@ -1256,9 +1259,12 @@ cat < # your extensions live in extensions// - ./scripts/deploy-extension.sh extensions//dist/extension.zip + # the installer service loads each build in extensions//dist/ by itself, within about 20 seconds + # without the installer, or on a remote target: ./scripts/deploy-extension.sh extensions//dist/extension.zip # or build every extension: ./scripts/build-all.sh # or build a bundled sample: ./scripts/build-extension.sh samples/helloworld + # then load it, since the installer reads only extensions/: + # ./scripts/deploy-extension.sh samples/helloworld/dist/extension.zip # re-attach the agent to another workspace: ./scripts/register-agent.sh # attach the devops persona elsewhere: ./scripts/register-persona.sh EOF diff --git a/scripts/_runtime.sh b/scripts/_runtime.sh index 2b33521..88b6b3d 100644 --- a/scripts/_runtime.sh +++ b/scripts/_runtime.sh @@ -403,7 +403,7 @@ The podman machine is too small to build in. memory: $((mib / 1024)) GiB ($mib MiB) required: $((min_mem / 1024)) GiB ($min_mem MiB) minimum, $((rec_mem / 1024)) GiB recommended -That ceiling is shared by this stack's six containers and by every extension build. The Angular +That ceiling is shared by this stack's containers and by every extension build. The Angular frontend build is what runs out first, and when it does the only clue is 'exit status 137' at the end of a Go stack trace — so this stops here instead. diff --git a/scripts/check-image-arches.sh b/scripts/check-image-arches.sh index ef4afba..87d34b8 100755 --- a/scripts/check-image-arches.sh +++ b/scripts/check-image-arches.sh @@ -30,6 +30,7 @@ IMAGES=( "duplocloud/duplo-agent:AGENT_TAG" "duplocloud/duplo-ai-helpdesk-ui:UI_TAG" "duplocloud/duplo-xterm:XTERM_TAG" + "duplocloud/helpdesk-installer:INSTALLER_TAG" ) # _tag_value [] -> the value of VAR= in (default .env.example), or empty. diff --git a/tests/test-installer-wiring.sh b/tests/test-installer-wiring.sh new file mode 100755 index 0000000..3d59c9c --- /dev/null +++ b/tests/test-installer-wiring.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Checks that docker-compose.yml, .env.example, run.sh and the README wire the extension installer in. +# +# The static checks need nothing installed. The rendered checks run `$RUNTIME compose config` against a copy of the +# compose file and a fixture .env in a temp directory, so they never read your .env, pull or start anything. They need +# Docker Compose's `config --format json`, which podman-compose lacks, and are skipped without it. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0; SKIP=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +COMPOSE=docker-compose.yml + +echo "installer wiring, static:" + +t "compose defines an installer service on the published installer image" +if awk '/^ installer:$/{f=1;next} f&&/^ [a-z]/{exit} f' "$COMPOSE" \ + | grep -qE '^ image: quay\.io/duplocloud/helpdesk-installer:\$\{INSTALLER_TAG\}$'; then ok +else bad "no installer service with image quay.io/duplocloud/helpdesk-installer:\${INSTALLER_TAG}"; fi + +# A floating tag would move under a developer with every compose pull, and run.sh's adoption could never tell a new +# default from an old one. +t ".env.example pins INSTALLER_TAG to an immutable build" +TAG="$(grep -E '^INSTALLER_TAG=' .env.example | head -1 | cut -d= -f2-)" +case "$TAG" in + ""|latest|main) bad "INSTALLER_TAG is '${TAG:-unset}'" ;; + *) ok ;; +esac + +t "run.sh adopts INSTALLER_TAG through DEFAULT_KEYS" +if grep -E '^DEFAULT_KEYS=' run.sh | grep -qw INSTALLER_TAG; then ok; else bad "INSTALLER_TAG is not in DEFAULT_KEYS"; fi + +t "the image architecture check covers the installer image" +if grep -q '"duplocloud/helpdesk-installer:INSTALLER_TAG"' scripts/check-image-arches.sh; then ok +else bad "scripts/check-image-arches.sh IMAGES has no helpdesk-installer line"; fi + +# A bind source the runtime has to create comes out root-owned under rootful docker on Linux, and then neither +# fetch-terraform-extension.sh nor a build can write extensions/. +t "run.sh creates extensions/ before the stack starts" +MK="$(grep -n '^mkdir -p extensions$' run.sh | head -1 | cut -d: -f1)" +UP="$(grep -n 'compose up -d' run.sh | head -1 | cut -d: -f1)" +if [ -n "$MK" ] && [ -n "$UP" ] && [ "$MK" -lt "$UP" ]; then ok; else bad "no 'mkdir -p extensions' ahead of compose up"; fi + +t "README covers installing a local build and from the Available tab" +if grep -q 'extensions/\[name\]/dist/' README.md && grep -q 'Available' README.md \ + && grep -q 'LICENSE_SERVER_URL' README.md; then ok +else bad "README lacks the extensions/[name]/dist/ build, the Available tab or the catalog keys"; fi + +# in_readme TEXT -> whether README.md holds TEXT, with its prose line breaks read as spaces. +in_readme() { tr '\n' ' ' < README.md | grep -qF -- "$1"; } + +# A build deploy-extension.sh loaded leaves the installer no digest to compare, so a same-version rebuild of it reads as +# already installed. The hints name the installer as the load step, and the README says how to get past that. +t "build hints load through the installer, and deploy-extension.sh only without it" +STALE="" +for f in run.sh docs/troubleshooting.md; do + if ! grep -q 'installer service loads each build' "$f" \ + || grep 'deploy-extension.sh extensions//dist/extension.zip' "$f" | grep -qv 'without the installer'; then + STALE="$STALE $f" + fi +done +if [ -z "$STALE" ]; then ok; else bad "deploy-extension.sh is still the load step in:$STALE"; fi + +# The installer bind-mounts extensions/ only, so a sample zip at samples/helloworld/dist never reaches it. +t "a bundled sample still names deploy-extension.sh" +SAMPLE_STALE="" +for f in run.sh docs/getting-started/install.md; do + grep -q 'deploy-extension.sh samples/helloworld/dist/extension.zip' "$f" || SAMPLE_STALE="$SAMPLE_STALE $f" +done +if [ -z "$SAMPLE_STALE" ]; then ok; else bad "sample load step missing in:$SAMPLE_STALE"; fi + +t "README says how a rebuild installs after a deploy-extension.sh load" +if in_readme "After a \`deploy-extension.sh\` load, bump the version or run \`./scripts/remove-extension.sh [id]\`" +then ok; else bad "README lacks the bump-or-remove step after a deploy-extension.sh load"; fi + +t "README gives the manual recovery for a load that hangs the studio" +if in_readme "docker compose stop duplo-ai-studio" \ + && in_readme "docker compose exec mongo mongosh -u authuser -p authpass --authenticationDatabase admin duplo-ai-helpdesk \\" \ + && in_readme "db.loaded_extensions.updateOne({ExtensionId: \"[id]\", IsCurrent: true}, {\$set: {Status: NumberInt(1)}})" \ + && in_readme "docker compose start duplo-ai-studio" \ + && in_readme 'The rebuild has to differ in content' +then ok; else bad "README lacks the stop, disable-the-row, restart or fixed-rebuild steps"; fi + +echo +echo "installer wiring, rendered:" + +. scripts/_runtime.sh +if ! runtime_resolve 2>/dev/null || ! "$RUNTIME" compose version 2>/dev/null | grep -q 'Docker Compose' \ + || ! command -v python3 >/dev/null 2>&1; then + echo " skipped: needs a Docker Compose implementation and python3" + SKIP=1 +else + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + cp "$COMPOSE" "$TMP/docker-compose.yml" + + # render [.env lines…] -> the resolved config as JSON, from the fixture alone, never the caller's environment. + render() { + printf '%s\n' "$@" > "$TMP/.env" + (cd "$TMP" && env -i PATH="$PATH" HOME="$HOME" ${XDG_RUNTIME_DIR:+"XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"} \ + "$RUNTIME" compose config --format json 2>"$TMP/err") + } + + # check MODE < json -> one "PASS|label" or "FAIL|label|detail" line per check. + check() { + python3 -c ' +import json, sys +mode = sys.argv[1] +d = json.load(sys.stdin) +svc = d.get("services", {}) +inst, studio, perms = svc.get("installer", {}), svc.get("duplo-ai-studio", {}), svc.get("init-perms", {}) +env = inst.get("environment") or {} +def out(label, good, detail=""): + print(("PASS|%s" % label) if good else ("FAIL|%s|%s" % (label, detail))) +def unset(k): + return env.get(k) in (None, "") +if mode == "set": + out("installer image follows INSTALLER_TAG", inst.get("image") == "quay.io/duplocloud/helpdesk-installer:fixture-tag", + inst.get("image")) + for k, v in (("INSTALLER_PLATFORM", "compose"), ("INSTALLER_BACKEND_URL", "http://duplo-ai-studio:60021"), + ("INSTALLER_STATE_DIR", "/var/lib/installer"), ("INSTALLER_LOCAL_DIR", "/extensions")): + out("installer %s=%s" % (k, v), env.get(k) == v, env.get(k)) + out("installer reports the image the studio runs", env.get("INSTALLER_BACKEND_IMAGE") == studio.get("image"), + "%s vs %s" % (env.get("INSTALLER_BACKEND_IMAGE"), studio.get("image"))) + out("JWT secret comes from .env", env.get("Authentication__JwtSharedSecret") == "fixture-secret", + env.get("Authentication__JwtSharedSecret")) + out("license token comes from .env", env.get("Licensing__Token") == "fixture-license", env.get("Licensing__Token")) + out("an optional selection key passes through from .env", env.get("EXTENSIONS_EXCLUDE") == "[\"a.b\"]", + env.get("EXTENSIONS_EXCLUDE")) + out("a catalog key passes through from .env", env.get("LICENSE_SERVER_URL") == "https://license.example", + env.get("LICENSE_SERVER_URL")) + leaked = [k for k in ("ANTHROPIC_API_KEY", "AWS_SECRET_ACCESS_KEY", "Authentication__LocalAdminPassword", + "Encryption__MasterKey") if k in env] + out("installer receives no LLM or admin credentials", not leaked and not inst.get("env_file"), ",".join(leaked)) + mounts = inst.get("volumes") or [] + state = [m for m in mounts if m.get("target") == "/var/lib/installer"] + out("state lives on a named volume", len(state) == 1 and state[0].get("type") == "volume" + and state[0].get("source") in (d.get("volumes") or {}), state) + src = state[0].get("source") if state else None + pm = [m.get("target") for m in perms.get("volumes") or [] if m.get("source") == src] + cmd = " ".join(perms.get("command") or []) + out("init-perms chowns the state volume to the distroless nonroot user", + len(pm) == 1 and ("chown -R 65532:65532 %s" % pm[0]) in cmd, "%s / %s" % (pm, cmd)) + dep = (inst.get("depends_on") or {}).get("init-perms") or {} + out("installer waits for init-perms", dep.get("condition") == "service_completed_successfully", dep) + local = [m for m in mounts if m.get("target") == "/extensions"] + out("extensions/ is mounted read-only", len(local) == 1 and local[0].get("type") == "bind" + and local[0].get("read_only") is True and local[0].get("source", "").endswith("/extensions"), local) + out("installer publishes no host port", not inst.get("ports"), inst.get("ports")) + senv = studio.get("environment") or {} + out("studio calls the installer on the compose network", senv.get("Extensions__InstallerUrl") == "http://installer:8080", + senv.get("Extensions__InstallerUrl")) +else: + out("EXTENSIONS_ENABLED defaults to true", env.get("EXTENSIONS_ENABLED") == "true", env.get("EXTENSIONS_ENABLED")) + out("EXTENSIONS_SOURCE defaults to customer", env.get("EXTENSIONS_SOURCE") == "customer", env.get("EXTENSIONS_SOURCE")) + for k in ("LICENSE_SERVER_URL", "URL_ROUTE_URL", "CHANNELS_BASE_URL", "CHANNELS_BUCKET", "EXTENSIONS_EXCLUDE", + "EXTENSIONS_ENROLLMENT_ID"): + out("%s stays unset by default" % k, unset(k), env.get(k)) +' "$1" + } + + tally() { + local line label detail + while IFS= read -r line; do + label="${line#*|}"; label="${label%%|*}" + t "$label" + case "$line" in + PASS\|*) ok ;; + *) detail="${line#FAIL|*|}"; bad "$detail" ;; + esac + done + } + + if J="$(render INSTALLER_TAG=fixture-tag STUDIO_TAG=studio-fixture Authentication__JwtSharedSecret=fixture-secret \ + Licensing__Token=fixture-license 'EXTENSIONS_EXCLUDE=["a.b"]' LICENSE_SERVER_URL=https://license.example \ + ANTHROPIC_API_KEY=sk-fixture AWS_SECRET_ACCESS_KEY=aws-fixture Authentication__LocalAdminPassword=pw \ + Encryption__MasterKey=mk)"; then + tally < <(printf '%s' "$J" | check set) + else + t "compose config renders with every key set"; bad "$(head -3 "$TMP/err" | tr '\n' ' ')" + fi + + if J="$(render INSTALLER_TAG=fixture-tag STUDIO_TAG=studio-fixture)"; then + tally < <(printf '%s' "$J" | check unset) + else + t "compose config renders with the optional keys unset"; bad "$(head -3 "$TMP/err" | tr '\n' ' ')" + fi +fi + +echo +echo "$PASS passed, $FAIL failed$([ "$SKIP" = 1 ] && echo ', rendered checks skipped')" +[ "$FAIL" -eq 0 ] From cd378178ff2bb706d3c285cdf9cc93878605e863 Mon Sep 17 00:00:00 2001 From: David Wolfe Date: Thu, 8 Oct 2026 16:51:21 -0700 Subject: [PATCH 2/3] DUPLOAI-3742: hand local builds to the installer in deploy-extension.sh An agent running `deploy-extension.sh` beside the installer raced its pass, and a same-version rebuild afterwards read as already installed, so the agent could test an older build without knowing it. - `deploy-extension.sh` on a build under `extensions/` waits for the installer's status record to show that zip's digest, and exits non-zero with the installer's reason when it does not install it. It still POSTs for a build the installer leaves alone, a zip elsewhere, a remote target, or with the installer stopped - `docker-compose.yml` defaults `INSTALLER_TAG` to the `.env.example` pin, so `docker compose up` before the next `./run.sh` still starts - The README says which `dist/` builds the installer leaves alone, and troubleshooting covers a build that never loads, with how to read the installer's status record --- README.md | 22 ++++-- docker-compose.yml | 9 ++- docs/troubleshooting.md | 38 ++++++++++ scripts/deploy-extension.sh | 86 +++++++++++++++++++++- tests/test-deploy-extension.sh | 131 +++++++++++++++++++++++++++++++++ tests/test-installer-wiring.sh | 34 ++++++--- 6 files changed, 299 insertions(+), 21 deletions(-) create mode 100755 tests/test-deploy-extension.sh diff --git a/README.md b/README.md index a28742e..cceb307 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,22 @@ The `installer` service installs extensions into the running studio and keeps th ./scripts/build-extension.sh extensions/[name] ``` -The installer installs each build in `extensions/[name]/dist/` within about 20 seconds of it changing, including a -rebuild at the same version. The studio may keep running the previous backend DLL after a same-version reload, as -`deploy-extension.sh` warns, so bump the version for backend changes. - -The installer reinstalls a same-version rebuild only over a build it installed itself. After a `deploy-extension.sh` -load, bump the version or run `./scripts/remove-extension.sh [id]` before relying on the installer for a rebuild. +The installer installs each build in `extensions/[name]/dist/` that changes while it runs, within about 20 seconds, +including a rebuild at the same version. The studio may keep running the previous backend DLL after a same-version +reload, as `deploy-extension.sh` warns, so bump the version for backend changes. + +A build already in `dist/` when the installer starts, as after `./run.sh --reset`, waits for its next rebuild, and so +does an extension you remove. The installer also leaves alone a build older than the installed version, unless that +version failed to load. + +`./scripts/deploy-extension.sh extensions/[name]/dist/extension.zip` hands that build to the installer while it runs, +waits for the installer to report it, and exits non-zero with the installer's reason when it does not install it. For +a build the installer leaves alone, with the installer stopped or `EXTENSIONS_ENABLED=false`, for an id in +`EXTENSIONS_EXCLUDE`, or for a zip outside `extensions/`, it loads the zip itself. + +The installer reinstalls a same-version rebuild only over a build it installed itself. After a load it did not make, +such as `deploy-extension.sh` with the installer stopped or Extension Studio's in-platform flow, bump the version or +run `./scripts/remove-extension.sh [id]` before relying on the installer for a rebuild. **From the catalog.** The Available tab lists the extensions your license entitles you to. Install there hands the click to this installer only once the UI tag and the studio tag in `.env.example` are images that do that. diff --git a/docker-compose.yml b/docker-compose.yml index 52f0298..3ca341e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,11 +94,12 @@ services: init-perms: condition: service_completed_successfully - # Installs and upgrades extensions in the studio: each build in extensions/[name]/dist/ by itself, and a catalog - # extension when the Available tab asks. Only the studio calls it, signing each call with the shared JWT secret, so - # no host port is published. + # Installs and upgrades extensions in the studio: each build that changes in extensions/[name]/dist/ while it runs, + # and a catalog extension when the Available tab asks. Only the studio calls it, signing each call with the shared JWT + # secret, so no host port is published. installer: - image: quay.io/duplocloud/helpdesk-installer:${INSTALLER_TAG} + # Defaults to .env.example's pin, so `docker compose up` before run.sh copies INSTALLER_TAG into .env still starts. + image: quay.io/duplocloud/helpdesk-installer:${INSTALLER_TAG:-main-8d05eb4e} restart: unless-stopped environment: - INSTALLER_PLATFORM=compose diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c8deb4a..89e4c43 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -665,6 +665,44 @@ On a remote target or a stack without the installer, load it with `./scripts/dep See [Build your first extension](getting-started/build-your-first-extension.md). +### A build in extensions/ never loads + +The installer service installs each build in `extensions//dist/`, and a build it skips or fails shows only in +its log and its status record. `deploy-extension.sh` run on that build waits for the installer and prints its reason. +Otherwise read the log: + +```bash +./logs.sh installer +``` + +or the status record. The installer's image has no shell, so copy the record out of its volume: + +```bash +docker compose cp installer:/var/lib/installer/installer-status.json - | tar -xO | python3 -c ' +import json, sys +res = json.loads(json.load(sys.stdin).get("extensions") or "{}") +print("pass:", res.get("outcome"), res.get("reason") or "") +for i in res.get("items") or []: + print(i.get("id"), i.get("declaredVersion"), i.get("outcome"), i.get("reason") or "", i.get("detail") or i.get("lastError") or "")' +``` + +Under podman, replace `docker compose cp installer:` with +`podman cp "$(podman ps -q --filter label=com.docker.compose.service=installer)":`. + +A build with no line of its own never reached the installer's decision, and the `pass:` line says why. Otherwise: + +| Outcome and reason | What to do | +|---|---| +| `already` for a build you changed | A load the installer did not make holds this id at this version, such as `deploy-extension.sh` with the installer stopped. Bump `manifest.version`, or run `./scripts/remove-extension.sh ` and build again. | +| `skipped` `held-after-failure` | This exact build failed its install or its check, and the detail says how. Fix it and rebuild. Any change to the zip releases the hold. | +| `skipped` `disabled-by-admin` | The extension is disabled in the UI. Enable it there. | +| `skipped` `excluded-by-customer-file` | Its id is in `EXTENSIONS_EXCLUDE` in `.env`. Remove it there, or let `deploy-extension.sh` load the build itself. | +| `skipped` `install-not-requested` | The build was in `dist/` before the installer started, is the one installed before you removed the extension, or is older than the installed version, which the detail then names. Rebuild it, or let `deploy-extension.sh` load it. | +| `skipped` `no-build-for-host-sdk` | The build targets an SDK the running studio does not host, and the detail names both. Rebuild against the running studio. | +| `error` `bundle-manifest-invalid` or `load-bundle-failed` | The zip's manifest, or the studio's load, refused the build. The detail carries the message. | + +The README's [Installing an extension](../README.md#installing-an-extension) covers which builds the installer leaves alone. + ### More entries belong here Per-language build failures, a deploy that succeeds without the resource type appearing, provisioning diff --git a/scripts/deploy-extension.sh b/scripts/deploy-extension.sh index 2952620..03efcb0 100755 --- a/scripts/deploy-extension.sh +++ b/scripts/deploy-extension.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Hot-load a built extension.zip into the running platform (no restart). +# Hot-load a built extension.zip into the running platform (no restart). A local build under extensions/ goes through +# the installer service instead while it runs (installer_handoff below). # Usage: ./scripts/deploy-extension.sh /dist/extension.zip # e.g. extension/dist/extension.zip set -euo pipefail cd "$(dirname "$0")/.." @@ -27,6 +28,89 @@ if command -v unzip >/dev/null && command -v jq >/dev/null; then fi fi +# Prints one decision from the installer's status record, read as the tar stream `cp ... -` writes: ok, fail, wait, or +# off when the installer leaves this build to the POST. Matches on the zip's digest, since a same-version rebuild shares +# id and version. +STATUS_PY=' +import json, sys, tarfile +want_id, want_sha = sys.argv[1], sys.argv[2] +try: + with tarfile.open(fileobj=sys.stdin.buffer, mode="r|") as tf: + rec = next(json.load(tf.extractfile(m)) for m in tf if m.isfile()) + res, st = json.loads(rec.get("extensions") or "{}"), json.loads(rec.get("extensionsState") or "{}") +except Exception: + print("wait|it has written no status yet") + sys.exit() +if (res.get("effective") or {}).get("enabled") == "false": + print("off|") + sys.exit() +it = next((i for i in res.get("items") or [] if i.get("id") == want_id and i.get("bundleSha256") == want_sha), None) +if it is None: + print("wait|its last pass ended %s %s without it" % (res.get("outcome") or "unfinished", res.get("reason") or "")) + sys.exit() +o, ver = it.get("outcome") or "", it.get("declaredVersion") or "?" +why = ": ".join(x for x in (it.get("reason"), it.get("detail") or it.get("lastError")) if x) +mine = any(a.get("id") == want_id and a.get("bundleSha256") == want_sha for a in st.get("applied") or []) +if o == "skipped" and it.get("reason") in ("excluded-by-customer-file", "install-not-requested"): + print("off|") +elif o == "converged" or (o == "already" and mine): + print("ok|Installed %s v%s." % (want_id, ver)) +elif o == "already": + print("fail|%s v%s is installed from a load the installer did not make, so it keeps that one. Bump manifest.version, " + "or run ./scripts/remove-extension.sh %s and build again." % (want_id, ver, want_id)) +elif o in ("skipped", "error", "backed-out"): + print("fail|the installer did not install this build, %s%s." % (o, ": " + why if why else "")) +else: + print("wait|it is still installing it") +' + +# Hands a build in extensions/[name]/dist/ to the installer service rather than POSTing it, since a POST beside the +# installer races its pass and leaves it no digest to compare, so a same-version rebuild afterwards reads as already +# installed. Returns 0 once the installer reports this zip installed, 1 with its reason when it refuses the build or +# does not report it within INSTALLER_WAIT_SECONDS, and 2 when the installer leaves this build alone and the POST should +# run. +installer_handoff() { + local root abs name cid meta id sha deadline res + [ "$_TARGET" = local ] && command -v python3 >/dev/null || return 2 + root="$(pwd -P)" + abs="$(cd "$(dirname "$ZIP")" && pwd -P)/$(basename "$ZIP")" + name="${abs#"$root/extensions/"}"; name="${name%/dist/extension.zip}" + case "$name" in ''|*/*) return 2 ;; esac + # shellcheck source=scripts/_runtime.sh + . scripts/_runtime.sh + runtime_resolve >/dev/null 2>&1 || return 2 + cid="$("$RUNTIME" ps -q --filter "label=com.docker.compose.project=$(runtime_compose_project)" \ + --filter label=com.docker.compose.service=installer 2>/dev/null | head -1)" + [ -n "$cid" ] || return 2 + meta="$(python3 -c ' +import hashlib, json, sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as z: + print(json.loads(z.read("manifest.json")).get("id") or "-", hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest()) +' "$ZIP" 2>/dev/null)" || return 2 + read -r id sha <<<"$meta" + [ "$id" != - ] || return 2 + echo "==> The installer service loads extensions/$name/dist/ on this target. Waiting for it to install $id" + deadline=$((SECONDS + ${INSTALLER_WAIT_SECONDS:-240})) + while :; do + res="$("$RUNTIME" cp "$cid:/var/lib/installer/installer-status.json" - 2>/dev/null \ + | python3 -c "$STATUS_PY" "$id" "$sha" || true)" + case "$res" in + ok\|*) echo "==> ${res#ok|}"; echo " Refresh the UI. The new resource type should appear in the left nav."; return 0 ;; + fail\|*) echo "ERROR: ${res#fail|}" >&2; break ;; + off\|*) return 2 ;; + esac + if [ "$SECONDS" -ge "$deadline" ]; then + echo "ERROR: the installer has not reported this build after ${INSTALLER_WAIT_SECONDS:-240}s, ${res#wait|}." >&2 + break + fi + sleep 3 + done + echo " See ./logs.sh installer, and 'A build in extensions/ never loads' in docs/troubleshooting.md." >&2 + return 1 +} +HANDOFF=0; installer_handoff || HANDOFF=$? +case "$HANDOFF" in 0) exit 0 ;; 1) exit 1 ;; esac + echo "==> Loading $ZIP ($(du -h "$ZIP" | cut -f1)) → $BASE_URL" RESP_FILE=$(mktemp) HTTP=$(curl -sSL -m 180 -o "$RESP_FILE" -w '%{http_code}' -X POST \ diff --git a/tests/test-deploy-extension.sh b/tests/test-deploy-extension.sh new file mode 100755 index 0000000..0d9e54e --- /dev/null +++ b/tests/test-deploy-extension.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Checks deploy-extension.sh's hand-off to the installer service, and the cases that still POST the zip. +# +# Runs the script from a temp copy of the kit with stub `docker` and `curl` first on PATH, so it never reaches a real +# runtime or studio. The stub `docker` reports an installer container while $STUB/up exists, and `cp` returns +# $STUB/status.json as the tar stream the real one writes. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +command -v python3 >/dev/null 2>&1 || { echo "skipped: needs python3"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +KIT="$TMP/kit"; STUB="$TMP/stub" +mkdir -p "$KIT/scripts" "$KIT/extensions/demo/dist" "$TMP/bin" "$STUB" +cp scripts/deploy-extension.sh scripts/_target.sh scripts/_runtime.sh "$KIT/scripts/" +printf 'DUPLO_ADMIN_TOKEN=fixture\nRUNTIME=docker\n' > "$KIT/.env" + +ZIP=extensions/demo/dist/extension.zip +python3 - "$KIT/$ZIP" <<'PY' +import json, sys, zipfile +with zipfile.ZipFile(sys.argv[1], "w") as z: + z.writestr("manifest.json", json.dumps({"id": "x.demo", "version": "1.0.0"})) +PY +cp "$KIT/$ZIP" "$KIT/outside.zip" +SHA="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$KIT/$ZIP")" + +cat > "$TMP/bin/docker" <<'SH' +#!/usr/bin/env bash +case "$1" in + ps) [ -f "$STUB/up" ] && echo stub-installer ;; + cp) [ -f "$STUB/status.json" ] || exit 1 + python3 -c 'import sys, tarfile +with tarfile.open(fileobj=sys.stdout.buffer, mode="w|") as t: + t.add(sys.argv[1], arcname="installer-status.json")' "$STUB/status.json" ;; +esac +SH +cat > "$TMP/bin/curl" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$STUB/curl.log" +case "$*" in *load-bundle*) printf 200 ;; esac +SH +chmod +x "$TMP/bin/docker" "$TMP/bin/curl" + +# status OUTCOME [REASON] [APPLIED_SHA] [ENABLED] -> a status record holding one x.demo item at this zip's digest. +status() { + python3 - "$STUB/status.json" "$SHA" "$@" <<'PY' +import json, sys +path, sha, outcome = sys.argv[1], sys.argv[2], sys.argv[3] +extra = sys.argv[4:] +reason, applied, enabled = extra + ["", "", "true"][len(extra):] +item = {"id": "x.demo", "declaredVersion": "1.0.0", "bundleSha256": sha if outcome != "other-digest" else "f" * 64, + "outcome": "converged" if outcome == "other-digest" else outcome, "reason": reason, "detail": ""} +res = {"outcome": item["outcome"], "reason": "", "effective": {"enabled": enabled}, "items": [item]} +st = {"applied": [{"id": "x.demo", "version": "1.0.0", "bundleSha256": applied}] if applied else []} +json.dump({"extensions": json.dumps(res), "extensionsState": json.dumps(st)}, open(path, "w")) +PY +} + +# run ZIP [VAR=value…] -> runs the copy with only the fixture .env and the stubs, leaving stdout+stderr in $OUT. +run() { + local zip="$1"; shift + : > "$STUB/curl.log" + OUT="$(cd "$KIT" && env -u DUPLO_TARGET -u DUPLO_HOST -u DUPLO_TOKEN -u DUPLO_BASE -u DUPLO_ADMIN_TOKEN \ + -u DUPLO_ENV_FILE -u RUNTIME -u COMPOSE_PROJECT_NAME PATH="$TMP/bin:$PATH" STUB="$STUB" INSTALLER_WAIT_SECONDS=0 \ + "$@" ./scripts/deploy-extension.sh "$zip" 2>&1)" + RC=$? +} +posted() { grep -q load-bundle "$STUB/curl.log"; } + +echo "deploy-extension.sh installer hand-off:" + +touch "$STUB/up" + +t "a build the installer reports converged exits 0 without a POST" +status converged; run "$ZIP" +if [ "$RC" = 0 ] && grep -q 'Installed x.demo v1.0.0' <<<"$OUT" && ! posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "a build already installed from this digest exits 0" +status already "" "$SHA"; run "$ZIP" +if [ "$RC" = 0 ] && ! posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "a same-version rebuild over a load the installer did not make fails naming the bump" +status already; run "$ZIP" +if [ "$RC" = 1 ] && grep -q 'Bump manifest.version' <<<"$OUT" && ! posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "a held build fails with the installer's reason" +status skipped held-after-failure; run "$ZIP" +if [ "$RC" = 1 ] && grep -q 'skipped: held-after-failure' <<<"$OUT" && ! posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "a build the installer never reports times out pointing at the troubleshooting entry" +status other-digest; run "$ZIP" +if [ "$RC" = 1 ] && grep -q 'has not reported this build' <<<"$OUT" && grep -q 'docs/troubleshooting.md' <<<"$OUT" \ + && ! posted; then ok; else bad "rc=$RC: $OUT"; fi + +echo +echo "deploy-extension.sh posts the zip itself:" + +t "with EXTENSIONS_ENABLED=false" +status converged "" "" false; run "$ZIP" +if [ "$RC" = 0 ] && posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "for an id in EXTENSIONS_EXCLUDE" +status skipped excluded-by-customer-file; run "$ZIP" +if [ "$RC" = 0 ] && posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "for a build the installer does not take as an install request" +status skipped install-not-requested; run "$ZIP" +if [ "$RC" = 0 ] && posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "for a zip outside extensions/" +status converged; run outside.zip +if [ "$RC" = 0 ] && posted; then ok; else bad "rc=$RC: $OUT"; fi + +t "on a remote target" +run "$ZIP" DUPLO_TARGET=remote DUPLO_HOST=https://remote.example DUPLO_TOKEN=fixture +if [ "$RC" = 0 ] && grep -q 'https://remote.example/v1/aiservicedesk/admin/extensions/load-bundle' "$STUB/curl.log" +then ok; else bad "rc=$RC: $OUT"; fi + +rm -f "$STUB/up" +t "with no installer container running" +run "$ZIP" +if [ "$RC" = 0 ] && posted; then ok; else bad "rc=$RC: $OUT"; fi + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/tests/test-installer-wiring.sh b/tests/test-installer-wiring.sh index 3d59c9c..17ed2e5 100755 --- a/tests/test-installer-wiring.sh +++ b/tests/test-installer-wiring.sh @@ -15,15 +15,18 @@ COMPOSE=docker-compose.yml echo "installer wiring, static:" -t "compose defines an installer service on the published installer image" -if awk '/^ installer:$/{f=1;next} f&&/^ [a-z]/{exit} f' "$COMPOSE" \ - | grep -qE '^ image: quay\.io/duplocloud/helpdesk-installer:\$\{INSTALLER_TAG\}$'; then ok -else bad "no installer service with image quay.io/duplocloud/helpdesk-installer:\${INSTALLER_TAG}"; fi +TAG="$(grep -E '^INSTALLER_TAG=' .env.example | head -1 | cut -d= -f2-)" + +# The compose default is what a `docker compose up` runs until run.sh adopts INSTALLER_TAG into .env, so it has to stay +# .env.example's pin. +t "compose defines an installer service on the published installer image, defaulting to .env.example's pin" +IMAGE="quay.io/duplocloud/helpdesk-installer:\${INSTALLER_TAG:-$TAG}" +if awk '/^ installer:$/{f=1;next} f&&/^ [a-z]/{exit} f' "$COMPOSE" | grep -qxF " image: $IMAGE"; then ok +else bad "no installer service with image $IMAGE"; fi # A floating tag would move under a developer with every compose pull, and run.sh's adoption could never tell a new # default from an old one. t ".env.example pins INSTALLER_TAG to an immutable build" -TAG="$(grep -E '^INSTALLER_TAG=' .env.example | head -1 | cut -d= -f2-)" case "$TAG" in ""|latest|main) bad "INSTALLER_TAG is '${TAG:-unset}'" ;; *) ok ;; @@ -51,8 +54,8 @@ else bad "README lacks the extensions/[name]/dist/ build, the Available tab or t # in_readme TEXT -> whether README.md holds TEXT, with its prose line breaks read as spaces. in_readme() { tr '\n' ' ' < README.md | grep -qF -- "$1"; } -# A build deploy-extension.sh loaded leaves the installer no digest to compare, so a same-version rebuild of it reads as -# already installed. The hints name the installer as the load step, and the README says how to get past that. +# A zip deploy-extension.sh posts itself leaves the installer no digest to compare, so a same-version rebuild of it +# reads as already installed. The hints name the installer as the load step, and the README says how to get past that. t "build hints load through the installer, and deploy-extension.sh only without it" STALE="" for f in run.sh docs/troubleshooting.md; do @@ -71,9 +74,20 @@ for f in run.sh docs/getting-started/install.md; do done if [ -z "$SAMPLE_STALE" ]; then ok; else bad "sample load step missing in:$SAMPLE_STALE"; fi -t "README says how a rebuild installs after a deploy-extension.sh load" -if in_readme "After a \`deploy-extension.sh\` load, bump the version or run \`./scripts/remove-extension.sh [id]\`" -then ok; else bad "README lacks the bump-or-remove step after a deploy-extension.sh load"; fi +t "README says deploy-extension.sh hands a local build to the installer" +if in_readme "\`./scripts/deploy-extension.sh extensions/[name]/dist/extension.zip\` hands that build to the installer" +then ok; else bad "README lacks the deploy-extension.sh hand-off"; fi + +t "README says how a rebuild installs after a load the installer did not make" +if in_readme "After a load it did not make," \ + && in_readme "bump the version or run \`./scripts/remove-extension.sh [id]\` before relying on the installer" +then ok; else bad "README lacks the bump-or-remove step after a load the installer did not make"; fi + +t "README says which builds the installer leaves alone and that deploy-extension.sh loads them" +if in_readme "A build already in \`dist/\` when the installer starts, as after \`./run.sh --reset\`, waits for its next rebuild" \ + && in_readme "The installer also leaves alone a build older than the installed version" \ + && in_readme "For a build the installer leaves alone, with the installer stopped" +then ok; else bad "README lacks the builds the installer leaves alone or the deploy-extension.sh fallback for them"; fi t "README gives the manual recovery for a load that hangs the studio" if in_readme "docker compose stop duplo-ai-studio" \ From 02df9e37a9cf8bde624bf19faaf53acdcc5d654c Mon Sep 17 00:00:00 2001 From: David Wolfe Date: Tue, 13 Oct 2026 14:26:45 -0700 Subject: [PATCH 3/3] DUPLOAI-3742: move the installer pin to main-4780968f The pinned installer reinstalled a removed extension from its `dist/` build, installed a `dist/` build older than the working installed version, and brought every `dist/` build back after `./run.sh --reset`. - `INSTALLER_TAG` in `.env.example`, the `docker-compose.yml` default and `docs/configuration.md` move to `main-4780968f`, which installs a `dist/` build only once it changes while the installer runs, and skips one older than an Active install --- .env.example | 2 +- docker-compose.yml | 2 +- docs/configuration.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index a68ab04..49a9579 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ STUDIO_TAG=dev-1.0.6-30d4b919 AGENT_TAG=dev-0.1.2-3bbb19d UI_TAG=146bc5b181363bcf360043e1c0d34676a4713ef5 XTERM_TAG=dev-0.1.2-1e96285 -INSTALLER_TAG=main-8d05eb4e +INSTALLER_TAG=main-4780968f # Qdrant vector database (Knowledge Base). The API key is fixed and not a secret — Qdrant is bound to # the compose network and localhost only. Change it if you like; compose and scripts/register-qdrant.sh diff --git a/docker-compose.yml b/docker-compose.yml index fcd33aa..780da2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,7 +99,7 @@ services: # secret, so no host port is published. installer: # Defaults to .env.example's pin, so `docker compose up` before run.sh copies INSTALLER_TAG into .env still starts. - image: quay.io/duplocloud/helpdesk-installer:${INSTALLER_TAG:-main-8d05eb4e} + image: quay.io/duplocloud/helpdesk-installer:${INSTALLER_TAG:-main-4780968f} restart: unless-stopped environment: - INSTALLER_PLATFORM=compose diff --git a/docs/configuration.md b/docs/configuration.md index d44e1df..bb18d9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,7 +110,7 @@ Pin the published images the stack runs. | `AGENT_TAG` | `main-d27e511` | The `claude-code-agent` image that executes provisioning tickets. A `main` build — it carries no extension-specific changes. | | `UI_TAG` | `2a949c536be71efd4501dd60ca6fdda4f7b4676a` | The Angular portal image. A `duplo-ui` git SHA, pinned to a build that renders the license state. | | `XTERM_TAG` | `main-d323e0b` | The in-browser terminal image. | -| `INSTALLER_TAG` | `main-8d05eb4e` | The extension installer image. Its own keys are under [Installing an extension](../README.md#installing-an-extension). | +| `INSTALLER_TAG` | `main-4780968f` | The extension installer image. Its own keys are under [Installing an extension](../README.md#installing-an-extension). | | `STUDIO_PLATFORM` | `linux/amd64` | Docker platform for the studio image. On Apple Silicon the studio image is amd64-only and runs emulated. Leave as-is unless you have an arm64 image. | `STUDIO_TAG` and `UI_TAG` are required — `run.sh` exits if either is empty: