Claude Code transcript - page 1/11

Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.

/model model

Set model to Fable 5 and saved as your default for new sessions

The current project is a Python CLI tool that deals with the building, maintenance and deployment of containerized software systems. It currently supports deployment of containers that are either a) build from a git repository (allowing arbitrary content but the repository must include a container build), b) pulled as-is from an image registry (allowing any image to be used, but without the knowledge of how to build and hence modify it), or c) specified as a "web app" in which case the stack tool knows how to build a container image that hosts an application it fetches from the specifed repository. The tool does this by having a couple of options for base container images, and a basic understanding of how web apps using nextjs or the like end up being packaged and configured in a container. This allows deployment of (roughly) any web app straight from its host repository, without its developer needing to understand containerization. This is similar in concept to now some public web app hosting platforms such as Vercel work. Ok, so what I'd like to do is to make that "middle route" more generic so that it can support a range of possible kinds of container wrapping. The first most obvious candidate is to host pure static html content. Thus a repository that only contains html could be deployed and served with an http server simply by including its repository in a stack, denoting it as static content. I'd like the definiteions for the base images for these different wrapping schemes to live in their own repositories (similar in concept to the docker-ingress-stack that we already have), rather than being defined in-tree with the stack tool itself. This I think requires some sort of abstraction and way for the wrapping container schemes to be discovered and enumerated at runtime. I'd like your thoughts on this idea. The existing webapp scheme is documented at docs/webapp.md

Thinking

Let me read the existing webapp docs and implementation to ground my thoughts, plus how docker-ingress-stack is referenced.

Read
{
  "file_path": "/home/david/projects/bpi/stack/docs/webapp.md"
}
1	### Building and Running Webapps
2	
3	It is possible to build and run static, React, and Next.js webapps using the `webapp build` and `webapp run` subcommands.
4	
5	To make it easier to build once and deploy into different environments and with different configuration,
6	compilation and static page generation are separated in the `webapp build` and `webapp run` steps.
7	
8	This offers much more flexibility than standard Next.js build methods, since any environment variables accessed
9	via `process.env`, whether for pages or for API, will have values drawn from their runtime deployment environment,
10	not their build environment. 
11	
12	## Building
13	
14	Building usually requires no additional configuration.  By default, the Next.js version specified in `package.json`
15	is used, and either `yarn` or `npm` will be used automatically depending on which lock files are present.  These
16	can be overidden with the build arguments `STACK_NEXTJS_VERSION` and `STACK_BUILD_TOOL` respectively.  For example: `--extra-build-args "--build-arg STACK_NEXTJS_VERSION=13.4.12"`
17	
18	**Example**:
19	```
20	$ cd ~/bpi
21	$ git clone https://github.com/bozemanpass/test-progressive-web-app
22	$ stack webapp build --source-repo ~/bozemanpass/test-progressive-web-app
23	...
24	
25	Built host container for ~/bozemanpass/test-progressive-web-app with tag:
26	
27	    bozemanpass/test-progressive-web-app:stack
28	
29	To test locally run:
30	
31	    stack webapp run --image bozemanpass/test-progressive-web-app:stack --config-file /path/to/environment.env
32	
33	```
34	
35	## Running
36	
37	With `webapp run` a new container will be launched on the local machine, with runtime configuration provided by `--config-file` (if specified) and published on an available port.  Multiple instances can be launched with different configuration.
38	
39	**Example**:
40	```
41	# Production env
42	$ stack webapp run --image bozemanpass/test-progressive-web-app:stack --config-file /path/to/environment/production.env
43	
44	Image: bozemanpass/test-progressive-web-app:stack
45	ID: 4c6e893bf436b3e91a2b92ce37e30e499685131705700bd92a90d2eb14eefd05
46	URL: http://localhost:32768
47	
48	# Dev env
49	$ stack webapp run --image bozemanpass/test-progressive-web-app:stack --config-file /path/to/environment/dev.env
50	
51	Image: bozemanpass/test-progressive-web-app:stack
52	ID: 9ab96494f563aafb6c057d88df58f9eca81b90f8721a4e068493a289a976051c
53	URL: http://localhost:32769
54	```
55	
56	## Deploy and Run
57	
58	Use the subcommand `webapp deploy` to make a deployment directory that can be subsequently deployed to a Kubernetes cluster.
59	Example commands are shown below, assuming that the webapp container image `bozemanpass/test-progressive-web-app:stack` has already been built:
60	```
61	$ stack webapp deploy --kube-config ~/kubectl/k8s-kubeconfig.yaml --image-registry registry.digitalocean.com/laconic-registry --deployment-dir ~/bozemanpass/webapp-k8s-deployment --image bozemanpass/test-progressive-web-app:stack --url https://test-pwa-app.bpi.servesthe.world --config-file test-webapp.env
62	$ stack manage --dir ~/bozemanpass/webapp-k8s-deployment push-images
63	$ stack manage --dir ~/bozemanpass/webapp-k8s-deployment start
64	```
65	
Agent
Explore webapp build machinery
{
  "subagent_type": "Explore",
  "prompt": "In the repo /home/david/projects/bpi/stack, I need to understand the existing \"webapp\" container-wrapping machinery to evaluate a design idea. Search thoroughly (medium-thorough) and report:\n\n1. src/stack/webapp/ — what's there, how a webapp container is built (base images used, Dockerfiles, where they live in-tree, e.g. src/stack/data/...), how a stack.yml denotes a component as a webapp (what field/attribute), and how build/deploy code branches on it.\n2. How the build system decides between \"build from repo Dockerfile\", \"pull image\", and \"webapp build\" — the relevant code paths in src/stack/build/ and how stack.yml expresses each.\n3. How \"docker-ingress-stack\" or external stacks are referenced/fetched — grep for \"ingress\" and see how external repositories of stack definitions are discovered/fetched at runtime (src/stack/repos/, fetch command).\n4. Any existing extension/plugin/discovery mechanisms (dynamic subcommand loading from stacks was mentioned in CLAUDE.md — how does that work?).\n\nReport file paths with line numbers for the key mechanisms, the exact stack.yml schema fields involved, and a concise description of the flow. Don't dump whole files — summarize with pointers.",
  "run_in_background": false
}
[
  {
    "type": "text",
    "text": "I now have a comprehensive picture. Here is my report.\n\n---\n\n# BPI Stack: webapp machinery, build branching, external stacks, and plugin loading\n\n## 1. `src/stack/webapp/` and how a webapp container is built\n\n**`src/stack/webapp/webapp.py`** (33 lines) is just a thin Click group `webapp` with three subcommands (registered in `main.py:95`):\n- `webapp build` → `src/stack/build/build_webapp.py`\n- `webapp deploy` → `src/stack/deploy/webapp/deploy_webapp.py`\n- `webapp run` → `src/stack/deploy/webapp/run_webapp.py`\n\nKey point: the webapp path is a **standalone command family**, largely independent of the normal stack/container-build path. It does not key off a `stack.yml` field. There is no \"this component is a webapp\" attribute in the stack schema — instead there is a dedicated `webapp-template` stack plus a two-stage container build.\n\n### The two-stage build — `src/stack/build/build_webapp.py`\n`command(...)` (line 47):\n1. `container_build_dir = .../data/container-build` (line 51).\n2. If no `--base-container`, it calls `determine_base_container(source_repo)` (line 58).\n3. **Stage 1** builds the base container itself (`build_context_1`, lines 66–73) via `build_containers.process_container`.\n4. **Stage 2** (lines 80–100) rebuilds using the *same* build script but overrides env vars:\n   - `STACK_WEBAPP_BUILD_RUNNING=true`\n   - `STACK_CONTAINER_BUILD_WORK_DIR` = the app source repo (`--source-repo`)\n   - `STACK_CONTAINER_BUILD_CONTAINERFILE` = `<container_build_dir>/<base-container-with-slashes-as-dashes>/Containerfile.webapp`\n   - `STACK_CONTAINER_BUILD_TAG` = `--tag` or default `bozemanpass/<app_dir_name>:stack` (lines 85–89)\n\n### Base-image selection — `src/stack/deploy/webapp/util.py:46` `determine_base_container()`\n- Default `bozemanpass/webapp-base`; `app_type == \"webapp/next\"` → `bozemanpass/nextjs-base`; for plain `\"webapp\"` it sniffs `package.json` for a `next` dependency and switches to `nextjs-base` (lines 50–58). The `app_type` here comes from an external \"app record\" (`app_record.attributes.app_type`, line 110), not from stack.yml.\n\n### Where the Dockerfiles / base images live in-tree (`src/stack/data/container-build/`)\n- `bozemanpass-webapp-base/` — `Containerfile` (base image, `FROM node:${VARIANT}` 20-bullseye-slim), `Containerfile.webapp` (the per-app build), `build.sh`, and `scripts/` (`build-app.sh`, `apply-runtime-env.sh`, `convert-to-runtime-env.sh`, `start-serving-app.sh`, `apply-webapp-config.sh`).\n- `bozemanpass-nextjs-base/` — `Containerfile.webapp`.\n- `build-base.sh` — sourced by every `build.sh`/`default-build.sh`.\n\n`Containerfile.webapp` is a two-stage docker build: `FROM bozemanpass/webapp-base:stack as builder`, `COPY . .`, `RUN /scripts/build-app.sh /app /data`, then a runtime stage that copies `/data`.\n\n`build.sh` honors the `STACK_CONTAINER_BUILD_*` overrides so the generic base build script can be reused to build a specific app image.\n\n### Deploy path — `src/stack/deploy/webapp/deploy_webapp.py`\n`create_deployment()` (line 61) effectively runs `stack --stack webapp-template deploy ... init` then `create`, then:\n- `_fixup_container_tag()` (line 32) rewrites `bozemanpass/webapp-container:stack` inside `data/compose/composefile-webapp-template.yml` to the real image.\n- `_fixup_url_spec()` (line 43) appends an `http-proxy:` block (host-name + routes + `proxy-to: webapp:80`) to the generated spec.\n\nThe template stack: `src/stack/data/stacks/webapp-template/stack.yml` (containers: `bozemanpass/webapp-template-container`, pods: `webapp-template`) and compose file `src/stack/data/compose/composefile-webapp-template.yml`. Note `stack_is_external()` special-cases `\"webapp-template\"` as **not** external (`src/stack/util.py:196-198`).\n\n`run_webapp.py` just does a bare `docker run` of the image on port 80 via the compose deployer.\n\n## 2. How the build system chooses build-from-Dockerfile vs pull vs webapp\n\nCore logic is `build_containers()` and `process_container()` in **`src/stack/build/build_containers.py`**.\n\n**Build policies** (line 45): `as-needed` (default), `build`, `build-force`, `prebuilt`, `prebuilt-local`, `prebuilt-remote`.\n\n**Pull-vs-build decision** in `build_containers()`:\n- Computes a content hash → `container_tag = \"<name>:<hash>\"` (line 332).\n- If it exists locally and policy allows, tag & skip (lines 336–341).\n- Else, `container_exists_remotely(...)` (line 344, defined `build_util.py:161`, uses `docker manifest inspect`) → sets `container_needs_pulled` (line 350).\n- If neither, `container_needs_built = True` (line 353); `prebuilt*` policies `error_exit` since nothing is available prebuilt (line 354).\n- Pull happens at lines 368–380; build at lines 381–419.\n\n**Which Dockerfile/build script** is decided in `process_container()` (lines 81–166), in priority order:\n1. **External stack with explicit `build:`** — `building_container.build` gives the build script path relative to the container spec file (`build_util.py:87` reads `container.build` from the container yaml); lines 105–109.\n2. **External stack, inferred** — look for `<repo>/stack-files/containers/<name>/build.sh` (constants `stack_files_directory_name=\"stack-files\"`, `containers_directory_name=\"containers\"`); lines 114–127.\n3. **Internal container** — `data/container-build/<name-with-dashes>/build.sh`; lines 129–131.\n4. **Fallback: build from the repo's own Dockerfile** — if no `build.sh` found, use `data/container-build/default-build.sh <tag> <repo_dir>` (lines 148–152). `default-build.sh` picks `Containerfile` else `Dockerfile` in the repo (or honors `STACK_CONTAINER_BUILD_CONTAINERFILE`). This is the \"build from repo Dockerfile\" path.\n\n**stack.yml expression of each** (see `ContainerSpec.init_from_file`, `build_util.py:76-89`, and `get_containers_in_scope`, lines 121-148):\n- `containers:` is a list. Each entry is either a bare string (`- bozemanpass/foo`) or a mapping with `name`, `ref` (external repo `host/org/repo@branch`), and `path`.\n- A per-container `container.yml`/`container.lock` (`constants.container_file_name`, `container_lock_file_name`) in the referenced repo provides `container.name`, `container.ref`, `container.build`. `ref` present → clone that repo, hash-lock, pull-or-build; `build` present → explicit build script; neither → Dockerfile fallback.\n- Pull happens when a matching image exists in a registry (`image_registry_for_repo`, `repo_util.py:64` → github.com maps to `ghcr.io`).\n\nThe webapp build (item 1) reuses `process_container()` but bypasses this whole decision tree by pre-setting `STACK_CONTAINER_BUILD_CONTAINERFILE`/`WORK_DIR`/`TAG` env vars.\n\n## 3. \"docker-ingress-stack\" / external stack references and fetching\n\n- There is **no** `docker-ingress-stack` reference in this repo. Grepping `ingress` only hits the **k8s nginx ingress controller** support: `src/stack/deploy/k8s/cluster_info.py:114` (`get_ingress`), `src/stack/deploy/k8s/helpers.py:47,73` (`wait_for_ingress_in_kind`, `install_ingress_for_kind`), and the bundled manifest `src/stack/data/k8s/components/ingress/ingress-nginx-kind-deploy.yaml` (fetched from upstream kind). HTTP routing for compose/webapp is the `http-proxy` spec block, not an ingress stack.\n\n**How external stacks / repos are discovered and fetched at runtime:**\n- Fetch command: `stack fetch repo <locator>` → group `src/stack/repos/fetch.py` (`command.add_command(fetch_stack, \"repo\")`, line 28) → `src/stack/repos/fetch_stack.py:39` → `process_repo()`.\n- Core fetch logic `process_repo()` in **`src/stack/repos/repo_util.py:163`**: parses locator via `host_and_path_for_repo()` (line 50; `org/repo` defaults to github.com, `host/org/repo@branch` supported), clones/pulls into `<dev_root>/<host>/<org>/<repo>` (`fs_path_for_repo`, line 155), checks out branch/tag.\n- Dev root = `get_dev_root_path()` = config setting `STACK_REPO_BASE_DIR` (`src/stack/config/util.py:109-110`, defaults to `~/.config/stack/repos` per header comments).\n- Stack discovery over that tree: `locate_stacks_beneath()` (`stack.py:428`) rglobs `stack.yml`; `locate_single_stack()` (line 437) matches by `name`; `resolve_stack()` (line 456) resolves a name/path to a `Stack`.\n- `stack_is_external()` (`util.py:196`): a stack is \"external\" unless it is the bundled `webapp-template` or (when `STACK_USE_BUILTIN_STACK`) exists under `data/stacks/`. External stacks live in the fetched repos; internal ones under `src/stack/data/stacks/`.\n- Super-stacks / repo lists: `clone_all_repos_for_stack()` (`repo_util.py:252`) fetches a stack's own repo, its `repos:` list, child stacks (`get_required_stacks()`), and container `ref`s.\n\n**http-proxy schema** (relevant to routing, spec side): `network:` → `http-proxy:` → list of `{ host-name, routes: [{ path, proxy-to: <service>:<port> }] }`. Constants: `network_key=\"network\"`, `http_proxy_key=\"http-proxy\"`, `proxy_to_key=\"proxy-to\"` (`constants.py:40,49,55`); spec accessors `spec.py:140` `get_http_proxy()`. Stacks can also annotate proxy targets inline via `@stack http-proxy` comments in compose (`stack.py:226` `get_http_proxy_targets`, marker `constants.py:70`).\n\n## 4. Dynamic subcommand / plugin loading from stacks\n\nImplemented in **`src/stack/cli_util.py`**:\n- `StackCLI(click.Group)` (line 28) renders help in sections; sections are keyed by a `<section>-<name>` command-name convention (`format_commands`, lines 34–61).\n- `load_subcommands_from_stack(cli, stack_path)` (line 64):\n  1. `resolve_stack(stack_path)` → look for a `subcommands/` dir next to the stack's `stack.yml` (`stack.file_path.parent / \"subcommands\"`, line 66).\n  2. For each `*.py` (except `__init__.py`), load it as a module via `importlib.util.spec_from_file_location` under name `stack.plugin.<n>` (lines 70–76).\n  3. If the module has a `command` attribute, register it as `\"<section>-<name>\"` where section defaults to the safe stack name and name to the file stem, both overridable via module globals `STACK_CLI_CMD_SECTION` and `STACK_CLI_CMD_NAME` (lines 77–85). `make_safe_name()` (line 88) slugifies.\n\n**Trigger** — `src/stack/main.py:101-113`: at *import time* (with the caveat noted in the DBDB comment), only when `STACK_USE_BUILTIN_STACK` is false, it scans `sys.argv` for `--stack <path>` and calls `load_subcommands_from_stack(cli, stack_path)`, wrapped in a broad try/except. So plugin subcommands are only loaded for the specific external stack named on the command line.\n\n`STACK_USE_BUILTIN_STACK` gate is defined at `src/stack/util.py:29`.\n\n---\n\n### Design-idea takeaways\n- The webapp mechanism is a self-contained command family with its own base images under `data/container-build/` and its own `webapp-template` stack — it is *not* modeled as a stack.yml component attribute. If your design wants \"webapp-ness\" to be a first-class stack field, that concept does not yet exist; today it's inferred from `app_type`/`package.json` at `webapp build` time.\n- Extending the CLI per-stack already has a clean hook: drop a `subcommands/*.py` exporting `command` beside a stack's `stack.yml`.\n- External stack definitions are just git repos fetched into `STACK_REPO_BASE_DIR` and discovered by rglob of `stack.yml`; there's no registry/index beyond the filesystem tree."
  }
]
Thinking

This is a solid idea, and the good news is the codebase is already about 70% of the way there structurally. Here's my assessment after reviewing docs/webapp.md and the build machinery.

What exists today, and why it helps

The webapp path is already a "wrapping scheme" in all but name — it's just hardcoded and lives off to the side:

  • Base image recipes live in-tree at src/stack/data/container-build/bozemanpass-webapp-base/ and bozemanpass-nextjs-base/. Each has a base Containerfile plus a Containerfile.webapp that does the two-stage "copy app repo in, build, produce runtime image" dance.
  • The seam you need already exists: build_webapp.py drives the generic process_container() by overriding three env vars — STACK_CONTAINER_BUILD_CONTAINERFILE, STACK_CONTAINER_BUILD_WORK_DIR (the app repo), and STACK_CONTAINER_BUILD_TAG. That env contract is the wrapper interface; it just isn't formalized.
  • External container builds are already supported: process_container() looks for stack-files/containers/<name>/build.sh in fetched repos, and repos are fetched/discovered by convention under STACK_REPO_BASE_DIR. So the "definitions live in their own repos" part has an existing pattern to copy.

What's missing is exactly what you identified: webapp-ness is not a first-class concept in stack.yml (it's a separate webapp build command family with a special-cased webapp-template stack and some post-hoc YAML rewriting in deploy_webapp.py), and there's no way to enumerate or add wrapping schemes.

Proposed shape

1. Make the wrapper an explicit attribute on a container entry in stack.yml. Something like:

containers:
  - name: myorg/my-site
    ref: github.com/myorg/my-site
    wrapper: static-content        # resolved by name among fetched wrapper repos
    # or fully qualified: wrapper: github.com/bozemanpass/static-content-wrapper

This slots cleanly into the decision tree in process_container() as a fourth branch alongside "explicit build script / inferred build.sh / repo Dockerfile fallback": if wrapper is present, resolve the wrapper, then reuse the existing env-var override mechanism to build the app image from the wrapper's Containerfile with the app repo as build context. No new build engine needed.

2. Define a wrapper repo as a small, declarative artifact. A git repo (or directory in one) containing a manifest — say wrapper.yml — plus the build materials:

name: static-content
description: Serve static HTML with nginx
base-container: bozemanpass/static-base   # built/pulled like any other container
containerfile: Containerfile.app          # the wrapping build, context = app repo
port: 80
# optional: runtime-env hook, healthcheck path, detect script

Discovery then mirrors stack discovery exactly: stack fetch repo pulls it into the dev root, and enumeration is an rglob for wrapper.yml (same as locate_stacks_beneath() rglobs stack.yml). Add a stack list wrappers to surface them. clone_all_repos_for_stack() needs to learn to also fetch wrapper refs, same as it fetches container refs today.

3. The contract a wrapper must satisfy is worth writing down explicitly, because it's what keeps this generic rather than a pile of special cases:

  • A base image, built once and content-hash tagged like any other container (so it participates in the existing pull-or-build / ghcr publishing path — important so users don't have to build base images locally, and so air-gapped/offline use degrades gracefully).
  • An app-image build parameterized only by the app repo as build context.
  • A runtime convention: what port it serves on, and how runtime config is injected. This last one is where schemes genuinely differ — nextjs needs the apply-runtime-env.sh env-substitution trick; static content needs nothing. Making it a declared property of the wrapper rather than baked into deploy code is the key abstraction win.

4. Migrate, don't fork. The existing webapp-base and nextjs-base should become the first wrapper repos (alongside the new static one, which is trivial: nginx base + COPY . /usr/share/nginx/html). stack webapp build can survive as sugar — auto-detect the wrapper (package.json sniffing already exists in determine_base_container()) and delegate to the generic path. Buildpack-style auto-detection is nice for the convenience CLI, but I'd keep stack.yml explicit, as you suggested — declarative manifests shouldn't guess.

Things to watch

  • Prior art: this is conceptually Cloud Native Buildpacks / Nixpacks territory. I'd deliberately not adopt CNB — it's heavyweight and would replace your build pipeline rather than plug into it — but it's worth borrowing its vocabulary (detect/build phases, builder contract) and being able to answer "why not buildpacks" (answer: you already have a hash-locked container build system; you just need a thin parameterized-Containerfile convention on top of it).
  • Reproducibility: wrapper repos should participate in the existing lock-file scheme (like container.lock), so a deployment pins both the app repo ref and the wrapper ref. Otherwise a wrapper repo update silently changes what everyone's images look like.
  • Cleanup opportunity: the webapp-template special case in stack_is_external() and the _fixup_container_tag/_fixup_url_spec YAML-rewriting in deploy_webapp.py are symptoms of webapp-ness not being in the model. Once a wrapped container is a normal stack component, those should be deletable — a wrapped component with a declared port composes with the existing http-proxy spec machinery like anything else.
  • Bootstrap ordering: building a stack now has a cross-repo dependency (app repo + wrapper repo + possibly a prebuilt base image). The fetch/prepare flow needs to resolve wrappers before the container build starts, and error messages for "wrapper not fetched" should be good, since this will be a common first-run stumble.

Overall: I'd frame it as "formalize the env-var seam that build_webapp.py already uses into a named, discoverable wrapper contract, hosted in repos and resolved like external containers." The static-content case then becomes a ~20-line wrapper repo and a good proof that the abstraction is real. Happy to sketch the wrapper.yml schema and the process_container() changes in more detail if you want to take it further.

That all sounds awesome. As is often the case, I wasn't aware of the prior art -- beyond the event horizon for me it seems. I do like the idea of unbundling the existing webapp wrapping scheme into the new generic mechanism because apart from anything else we can check that the existing webapp test cases still pass.

Thinking

Let me take a quick look at the existing webapp test so the migration plan is grounded in what it actually exercises.

$ Bash
List tests and show webapp CI workflow
ls /home/david/projects/bpi/stack/tests/ && ls /home/david/projects/bpi/stack/tests/webapp-test/ 2>/dev/null; cat /home/david/projects/bpi/stack/.github/workflows/test-webapp.yml 2>/dev/null | head -50
backup
database
deploy
k8s-deploy
k8s-deployment-control
scripts
smoke-test
webapp-test
run-webapp-test.sh
name: Webapp Test

on:
  pull_request:
    branches:
      - '*'
  push:
    branches:
      - '*'

jobs:
  test:
    name: "Run webapp test suite"
    runs-on: ubuntu-24.04
    steps:
      - name: "Clone project repository"
        uses: actions/checkout@v5
      - name: "Install uv"
        uses: astral-sh/setup-uv@v8.2.0
        with:
          python-version: '3.12'
      - name: "Generate build version file"
        run: ./scripts/create_build_tag_file.sh
      - name: "Build local shiv package"
        run: ./scripts/build_shiv_package.sh
      - name: "Run webapp tests"
        run: ./tests/webapp-test/run-webapp-test.sh
Read
{
  "file_path": "/home/david/projects/bpi/stack/tests/webapp-test/run-webapp-test.sh"
}
1	#!/usr/bin/env bash
2	set -e
3	
4	if [ -n "$STACK_SCRIPT_DEBUG" ]; then
5	  set -x
6	fi
7	
8	# Dump environment variables for debugging
9	echo "Environment variables:"
10	env
11	# Test basic stack webapp
12	echo "Running stack webapp test"
13	if [ "$1" == "from-path" ]; then
14	    TEST_TARGET_SO="stack"
15	else
16	    TEST_TARGET_SO=$( ls -t1 ./package/stack* | head -1 )
17	fi
18	# Set a non-default repo dir
19	export STACK_REPO_BASE_DIR=~/stack-test/repo-base-dir
20	echo "Testing this package: $TEST_TARGET_SO"
21	echo "Test version command"
22	reported_version_string=$( $TEST_TARGET_SO version )
23	echo "Version reported is: ${reported_version_string}"
24	echo "Cloning repositories into: $STACK_REPO_BASE_DIR"
25	rm -rf $STACK_REPO_BASE_DIR
26	mkdir -p $STACK_REPO_BASE_DIR
27	git clone https://github.com/bozemanpass/test-progressive-web-app.git $STACK_REPO_BASE_DIR/test-progressive-web-app
28	
29	# Test webapp command execution
30	$TEST_TARGET_SO webapp build --source-repo $STACK_REPO_BASE_DIR/test-progressive-web-app
31	
32	CHECK="SPECIAL_01234567890_TEST_STRING"
33	
34	set +e
35	
36	app_image_name="bozemanpass/test-progressive-web-app:stack"
37	
38	CONTAINER_ID=$(docker run -p 3000:80 -d -e STACK_SCRIPT_DEBUG=$STACK_SCRIPT_DEBUG ${app_image_name})
39	if [ $? -ne 0 ]; then
40	  echo "Failed to start container from image ${app_image_name}"
41	  exit 1
42	fi
43	sleep 3
44	wget --tries 20 --retry-connrefused --waitretry=3 -O test.before -m http://localhost:3000
45	
46	docker logs $CONTAINER_ID
47	if [ $? -ne 0 ]; then
48	  echo "Failed to get logs from container ${CONTAINER_ID}"
49	  exit 1
50	fi
51	docker stop $CONTAINER_ID
52	if [ $? -ne 0 ]; then
53	  echo "Failed to stop container ${CONTAINER_ID}"
54	  exit 1
55	fi
56	
57	echo "Running app container test"
58	CONTAINER_ID=$(docker run -p 3000:80 -e CERC_WEBAPP_DEBUG=$CHECK -e STACK_SCRIPT_DEBUG=$STACK_SCRIPT_DEBUG -d ${app_image_name})
59	if [ $? -ne 0 ]; then
60	  echo "Failed to start container from image ${app_image_name}"
61	  exit 1
62	fi
63	sleep 3
64	wget --tries 20 --retry-connrefused --waitretry=3 -O test.after -m http://localhost:3000
65	
66	docker logs $CONTAINER_ID
67	if [ $? -ne 0 ]; then
68	  echo "Failed to get logs from container ${CONTAINER_ID}"
69	  exit 1
70	fi
71	docker stop $CONTAINER_ID
72	if [ $? -ne 0 ]; then
73	  echo "Failed to stop container ${CONTAINER_ID}"
74	  exit 1
75	fi
76	
77	echo "###########################################################################"
78	echo ""
79	
80	grep "$CHECK" test.before > /dev/null
81	if [ $? -ne 1 ]; then
82	  echo "BEFORE: FAILED"
83	  exit 1
84	else
85	  echo "BEFORE: PASSED"
86	fi
87	
88	grep "$CHECK" test.after > /dev/null
89	if [ $? -ne 0 ]; then
90	  echo "AFTER: FAILED"
91	  exit 1
92	else
93	  echo "AFTER: PASSED"
94	fi
95	
96	echo "Running deployment create test"
97	# Note: this is not a full test -- all we're testing here is that the webapp deploy command doesn't crash
98	test_deployment_dir=$STACK_REPO_BASE_DIR/test-deployment-dir
99	fake_k8s_config_file=$STACK_REPO_BASE_DIR/kube-config.yml
100	touch ${fake_k8s_config_file}
101	
102	$TEST_TARGET_SO webapp deploy --kube-config ${fake_k8s_config_file} --deployment-dir ${test_deployment_dir} --image ${app_image_name} --url https://my-test-app.example.com
103	if [ -d ${test_deployment_dir} ]; then
104	  echo "PASSED"
105	else
106	  echo "FAILED"
107	  exit 1
108	fi
109	
110	exit 0
111	
Thinking

The webapp test turns out to be a nearly ideal regression harness for this: it builds the real test-progressive-web-app from scratch with webapp build, runs the image, and — the important part — does a before/after check that a runtime env var (CERC_WEBAPP_DEBUG) shows up in served pages only when set at docker run time. That's a direct test of the runtime-env-injection contract, which is exactly the piece most likely to break when the wrapping machinery moves. It also smoke-tests webapp deploy. So "existing tests still pass" is a genuinely meaningful invariant here, not just a syntax check.

On the prior art, for your reading list: Heroku buildpacks are the original (detect/compile/release contract), Cloud Native Buildpacks (buildpacks.io) is the CNCF standardization of that idea with its own image-building machinery (pack, lifecycle phases), and Nixpacks / Railpack (Railway) are the modern lightweight takes — they auto-detect the app type and generate a build plan rather than running opaque builder images. Vercel/Netlify do the same thing internally but don't expose it as a pluggable artifact. The useful borrowable ideas are the explicit detect-vs-build phase split and the notion that a builder is a versioned, published artifact — not their tooling.

For the unbundling itself, I'd phase it so the test gates each step independently:

Phase 1 — abstraction in-tree, nothing moves. Introduce the wrapper.yml manifest and resolution code, and convert bozemanpass-webapp-base/ and nextjs-base/ into in-tree wrappers (manifest + their existing Containerfiles, essentially unchanged). Rewire webapp build to go through the generic wrapper path instead of its hardcoded determine_base_container() → env-var plumbing. The webapp test must pass with zero changes to the test script. This is the pure refactor step, and doing it in-tree first means the test failure surface is only the abstraction, not repo fetching.

Phase 2 — externalize. Move the two wrappers to their own repo(s) (e.g. bozemanpass/webapp-wrappers or one repo per wrapper, matching how you split docker-ingress-stack), teach resolution to find fetched wrappers under STACK_REPO_BASE_DIR, and have the test fetch the wrapper repo the same way it already clones test-progressive-web-app. One decision to make here: whether the two existing wrappers stay bundled as builtins (resolution order: fetched repos override in-tree) or go fully external. Fully external is cleaner and matches your stated goal; the cost is that webapp build on a fresh machine needs network to fetch the wrapper — mitigated if the base images are published to ghcr so only the small wrapper repo needs cloning.

Phase 3 — the payoff. Add the static-content wrapper as a new external repo (nginx + COPY), add the wrapper: field to stack.yml container entries wired into process_container(), and a small test that deploys a static-HTML repo through a stack. This is also when the webapp-template special case and the _fixup_* rewrites in deploy_webapp.py become deletable.

One thing Phase 1 will surface immediately: the runtime-env scripts (apply-runtime-env.sh etc.) currently live in the base image's scripts/ directory and are shared between the two bases. They'll need to belong to the wrapper (baked into its base image), which is the right home anyway — a static wrapper simply won't have them.

If you want to proceed, I'd start on Phase 1 — it's self-contained in this repo and the webapp test tells us when it's done. Want me to draft the wrapper.yml schema and the resolution module as a first PR-sized chunk?