Sounds good. If useful, here's a summary you can paste into the PR description:
Generalize webapp container wrapping into discoverable "wrapper" schemes
Introduces a wrapper abstraction: a wrapper.yml manifest pairing a base container image with a containerfile that builds app source into a servable image (base image, app containerfile, serving port, optional auto-detection rules).
webapp-base/nextjs-base build files are removed.STACK_REPO_BASE_DIR; stack webapp build auto-fetches the default wrapper repos when none match, so the zero-config UX is unchanged.stack webapp wrappers command lists available schemes.webapp build gains --wrapper; --base-container still works.tests/static-content-test/) and CI workflow, using the stack-test-static-content fixture repo.Two small things worth knowing before you push: the Webapp Test and the new Static Content Test workflows both now depend on network access to the two wrapper repos (that's by design — CI validates the real auto-fetch path), and the first CI run will be the first cold build of the base images from the external repos. Both scenarios passed locally, so I expect green, but if anything surprises us in CI I'm happy to dig in.
git checkout -b dboreham/container-wrappers && git commit -m "$(cat <<'EOF' Generalize webapp container wrapping into discoverable wrapper schemes Introduces a wrapper abstraction: a wrapper.yml manifest pairing a base container image with a containerfile that builds app source into a servable image. Wrapper definitions now live in their own repositories (stack-wrapper-webapp, stack-wrapper-static-content); the in-tree webapp-base/nextjs-base build files are removed. Wrappers are discovered from repos fetched beneath STACK_REPO_BASE_DIR, with auto-fetch of the default wrapper repos preserving the zero-config UX. Adds static content hosting (nginx wrapper) with a new test suite and CI workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qp8bk2TZMt5NLhkU1ykwJc EOF )" && git push -u origin dboreham/container-wrappers
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed. Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.
Ok the PR passed all CI so I will merge and get the local clone onto a new clean branch for the subsequent work.
---
name: user-handles-git-operations
description: David handles branch/commit/push/PR himself; Claude leaves changes in the working tree
metadata:
type: feedback
---
David prefers to create branches, commit, push, and open PRs himself. He stages files himself too. Claude should leave completed work in the working tree (and report its state), offer a PR-description draft if useful, and not run branch/commit/push even when phrasing sounds inviting ("let's push it") — he rejected a commit+push tool call in July 2026 after saying "let's push it off the cliff", which just meant he was about to make the PR himself.
**Why:** He keeps control of repo history and PR flow; ambiguous "let's..." phrasing refers to the work shipping, not a request for Claude to run git.
**How to apply:** When work is ready, summarize working-tree state and stop. Only touch git state (beyond read-only commands) on an explicit, unambiguous request.
File created successfully at: /home/david/.claude/projects/-home-david-projects-bpi-stack/memory/user-handles-git-operations.md
- [backup test CI heisenbug](backup-test-ci-heisenbug.md) — rare unreproducible SeaweedFS init failure in Backup Test; on recurrence read the dumped s3 logs first
- [backup test CI heisenbug](backup-test-ci-heisenbug.md) — rare unreproducible SeaweedFS init failure in Backup Test; on recurrence read the dumped s3 logs first - [user handles git operations](user-handles-git-operations.md) — David branches/commits/pushes/PRs himself; leave work in the working tree
The file /home/david/.claude/projects/-home-david-projects-bpi-stack/memory/MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)
--- name: container-wrapper-schemes description: Wrapper abstraction (wrapper.yml) landscape — external repos, test fixture, and remaining Phase 3 work metadata: type: project --- In July 2026 the webapp container wrapping was generalized into discoverable "wrapper" schemes (`src/stack/build/wrappers.py`, `wrapper.yml` manifests). Wrapper definitions live externally: github.com/bozemanpass/stack-wrapper-webapp (webapp-base + nextjs-base) and stack-wrapper-static-content (nginx). Test fixture: github.com/bozemanpass/stack-test-static-content. `webapp build` auto-fetches DEFAULT_WRAPPER_REPOS when no wrapper matches. David keeps local clones of these repos as siblings of the stack repo. Remaining agreed "Phase 3" work (not started as of 2026-07-23): 1. `wrapper:` field on stack.yml container entries wired into `process_container()` so wrapped repos deploy as ordinary stack components. 2. Deployment-level static-site test through full `stack deploy`. 3. Retire the `webapp-template` special case in `stack_is_external()` and the `_fixup_*` YAML rewrites in `deploy_webapp.py`. 4. Later: publish base images to ghcr; wrapper repos participate in lock files. **Why:** the plan and repo relationships span four repositories and aren't derivable from the stack repo alone. **How to apply:** when resuming this work, verify what Phase 3 items have since landed before proposing them again. See also [[user-handles-git-operations]].
File created successfully at: /home/david/.claude/projects/-home-david-projects-bpi-stack/memory/container-wrapper-schemes.md
Sounds like a plan. I've noted the current state in my project memory so the next session picks up cleanly: the wrapper abstraction and static content support are merged via your PR, the external repos (stack-wrapper-webapp, stack-wrapper-static-content, stack-test-static-content) are the source of truth, and the agreed Phase 3 backlog is:
wrapper: field on stack.yml container entries wired into process_container() — deploy a wrapped repo as an ordinary stack component.stack deploy.webapp-template special case in stack_is_external() and the _fixup_* rewrites in deploy_webapp.py.Whenever you've got the fresh branch ready, just say go and we'll start on the stack.yml integration.
git status --short && git log --oneline -3 && git branch --show-current && ls src/stack/data/container-build/ && grep -n "wrappers" src/stack/webapp/webapp.py | head -3
fe32465 Merge pull request #160 from bozemanpass/dboreham/wrapper-abstraction-phase-one 4c64c34 Abstract wrappers phase 1 and 2 11bf1ba Merge pull request #159 from bozemanpass/dboreham/log-backup-test dboreham/wrappers-phase-three build-base.sh default-build.sh 20:from stack.build.wrappers import get_available_wrappers 35:def list_wrappers(ctx): 37: for wrapper in get_available_wrappers():
ls /home/david/projects/bpi/stack-test-stacks; find /home/david/projects/bpi/stack-test-stacks -name stack.yml | head -5; echo ===; head -40 tests/deploy/run-deploy-test.sh
README.md
stack-files
/home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-backup-stack/stack.yml
/home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-stack/stack.yml
/home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-database-stack/stack.yml
===
#!/usr/bin/env bash
set -e
if [ -n "$STACK_SCRIPT_DEBUG" ]; then
set -x
fi
# Check for required utilities
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed."
echo "Please install jq to run this test script."
exit 1
fi
# Dump environment variables for debugging
echo "Environment variables:"
env
delete_cluster_exit () {
$TEST_TARGET_SO manage --dir $test_deployment_dir stop --delete-volumes
}
trap delete_cluster_exit EXIT
add_todo() {
set +e
local running=0
local check=0
local check_limit=10
url=$1
title=$2
try=0
rc=1
while [ $rc -ne 0 ] && [ $try -lt 10 ]; do
try=$((try + 1))
curl "$url" \
--fail-with-body \cat /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-stack/stack.yml; echo ===; find /home/david/projects/bpi/stack-test-stacks/stack-files -type f | head -20; echo ===; grep -n "fetch\|stack-test-stacks\|--stack" tests/deploy/run-deploy-test.sh | head -15
name: test description: "A test stack" repos: - github.com/bozemanpass/stack-test-project@test-branch containers: - name: bozemanpass/test-container # Note: the ref (repo) and path (within the repo) are inferred because we use standard naming conventions in this repo pods: - test === /home/david/projects/bpi/stack-test-stacks/stack-files/compose/composefile-test.yml /home/david/projects/bpi/stack-test-stacks/stack-files/compose/composefile-test-backup.yml /home/david/projects/bpi/stack-test-stacks/stack-files/compose/composefile-test-database.yml /home/david/projects/bpi/stack-test-stacks/stack-files/config/external-test/external-test.env /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-database-container/Containerfile /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-database-container/build.sh /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-database-client/run.sh /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-database-client/Containerfile /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-database-client/build.sh /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-container/run.sh /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-container/Containerfile /home/david/projects/bpi/stack-test-stacks/stack-files/containers/bozemanpass-test-container/build.sh /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-backup-stack/stack.yml /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-stack/stack.yml /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-stack/README.md /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-database-stack/stack.yml /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-database-stack/README.md /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-stack/deploy/commands.py === 110:$TEST_TARGET_SO fetch repo bozemanpass/example-todo-list 111:$TEST_TARGET_SO prepare --stack $STACK_NAME 116:$TEST_TARGET_SO init --stack $STACK_NAME --output $test_deployment_spec --map-ports-to-host localhost-same
sed -n 95,175p tests/deploy/run-deploy-test.sh; echo ===; cat /home/david/projects/bpi/stack-test-stacks/stack-files/compose/composefile-test.yml
TEST_TARGET_SO=$( ls -t1 ./package/stack* | head -1 )
# Set a non-default repo dir
export STACK_REPO_BASE_DIR=~/stack-test/repo-base-dir
echo "Testing this package: $TEST_TARGET_SO"
echo "Test version command"
reported_version_string=$( $TEST_TARGET_SO version )
echo "Version reported is: ${reported_version_string}"
echo "Cloning repositories into: $STACK_REPO_BASE_DIR"
rm -rf $STACK_REPO_BASE_DIR
mkdir -p $STACK_REPO_BASE_DIR
# Test bringing the test container up and down
# with and without volume removal
STACK_NAME="todo"
$TEST_TARGET_SO fetch repo bozemanpass/example-todo-list
$TEST_TARGET_SO prepare --stack $STACK_NAME
# Basic test of creating a deployment
test_deployment_dir=$STACK_REPO_BASE_DIR/test-deployment-dir
test_deployment_spec=$STACK_REPO_BASE_DIR/test-deployment-spec.yml
$TEST_TARGET_SO init --stack $STACK_NAME --output $test_deployment_spec --map-ports-to-host localhost-same
# Check the file now exists
if [ ! -f "$test_deployment_spec" ]; then
echo "deploy init test: spec file not present"
echo "deploy init test: FAILED"
exit 1
fi
echo "deploy init test: passed"
$TEST_TARGET_SO deploy --spec-file $test_deployment_spec --deployment-dir $test_deployment_dir
# Check the deployment dir exists
if [ ! -d "$test_deployment_dir" ]; then
echo "deploy deploy test: deployment directory not present"
echo "deploy deploy test: FAILED"
exit 1
fi
echo "deploy create test: passed"
# Start
$TEST_TARGET_SO manage --dir $test_deployment_dir start
wait_for_running 3
# Add a todo
todo_title="79b06705-b402-431a-83a3-a634392d2754"
add_todo http://localhost:5000 "$todo_title"
# Check that it exists
if [ "$todo_title" != "$(curl -s http://localhost:5000 | jq -r '.[] | select(.id == 1) | .title')" ]; then
echo "deploy storage: failed - todo $todo_title not found"
exit 1
fi
# Stop the stack (don't delete volumes)
$TEST_TARGET_SO manage --dir $test_deployment_dir stop
# Restart the stack
$TEST_TARGET_SO manage --dir $test_deployment_dir start
# Check that all services are running
wait_for_running 3
# Check that it is still viewable
if [ "$todo_title" != "$(curl -s http://localhost:5000 | jq -r '.[] | select(.id == 1) | .title')" ]; then
echo "deploy storage: failed - todo $todo_title not found after restart"
exit 1
fi
echo "deploy storage: passed"
# TODO: Do we need to add a check for deleting the volumes?
# Docker doesn't remove the files for a bound volume so nothing much really changes.
wget -q -O - http://localhost:3000 | grep 'bundle.js'
echo "deploy http: passed"
echo "Test passed"
===
services:
test:
image: bozemanpass/test-container:stack
restart: always
environment:
STACK_SCRIPT_DEBUG: ${STACK_SCRIPT_DEBUG}
STACK_TEST_PARAM_1: ${STACK_TEST_PARAM_1:-FAILED}
STACK_TEST_PARAM_2: "STACK_TEST_PARAM_2_VALUE"
STACK_TEST_PARAM_3: ${STACK_TEST_PARAM_3:-FAILED}
volumes:
- test-data-bind:/data
- test-data-auto:/data2
- test-config:/config:ro
ports:
- "80"
volumes:
test-data-bind:
test-data-auto:
test-config:grep -n "is_absolute\|def process_container" src/stack/build/build_containers.py | head -5; grep -n "class StackContainer" -A 18 src/stack/build/build_util.py | head -22
81:def process_container(build_context: BuildContext) -> bool:
130: if not build_dir and building_container.build and Path(building_container.build).is_absolute():
35:class StackContainer:
36- name: str
37- ref: str
38- path: str
39-
40- def __init__(self, name: str=None, ref=None, path=None):
41- self.name = name
42- self.ref = ref
43- self.path = path
44-
45- def __repr__(self):
46- return str(self)
47-
48- def __str__(self):
49- ret = { "name": self.name, "ref": self.ref, "path": self.path }
50- return json.dumps(ret)
51-
52-
53-class ContainerSpec:class StackContainer:
name: str
ref: str
path: str
def __init__(self, name: str=None, ref=None, path=None):
self.name = name
self.ref = ref
self.path = path
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "path": self.path }
return json.dumps(ret)class StackContainer:
name: str
ref: str
path: str
wrapper: str
def __init__(self, name: str=None, ref=None, path=None, wrapper=None):
self.name = name
self.ref = ref
self.path = path
self.wrapper = wrapper
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "path": self.path, "wrapper": self.wrapper }
return json.dumps(ret)The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
class ContainerSpec:
name: str
ref: str
build: str
path: str
file_path: str
repo_path: Path
def __init__(self, name: str=None, ref=None, build=None, path=None):
self.name = name
self.ref = ref
self.build = build
self.path = path
self.file_path = None
self.repo_path = None
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "build": self.build, "path": self.path, "file_path": self.file_path }
return json.dumps(ret)class ContainerSpec:
name: str
ref: str
build: str
path: str
wrapper: str
file_path: str
repo_path: Path
def __init__(self, name: str=None, ref=None, build=None, path=None, wrapper=None):
self.name = name
self.ref = ref
self.build = build
self.path = path
self.wrapper = wrapper
self.file_path = None
self.repo_path = None
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "build": self.build, "path": self.path,
"wrapper": self.wrapper, "file_path": self.file_path }
return json.dumps(ret)The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
self.ref = y["container"].get("ref")
self.build = y["container"].get("build")
self.repo_path = find_repo_root(self.path)
return self self.ref = y["container"].get("ref")
self.build = y["container"].get("build")
self.wrapper = y["container"].get("wrapper", self.wrapper)
self.repo_path = find_repo_root(self.path)
return selfThe file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
containers_in_scope.append(StackContainer(container["name"], ref=container.get("ref"), path=container.get("path"))) containers_in_scope.append(StackContainer(container["name"], ref=container.get("ref"), path=container.get("path"),
wrapper=container.get("wrapper")))The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
container_spec = ContainerSpec(stack_container.name, stack_container.ref, path=stack_container.path)
container_spec = ContainerSpec(stack_container.name, stack_container.ref, path=stack_container.path,
wrapper=stack_container.wrapper)The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (note: the file had been modified on disk since you last read it — the edit applied cleanly, but the file contains other changes not in your context. Read it before edits that depend on surrounding content.)
if os.path.exists(container_spec_yml_path):
container_spec = ContainerSpec().init_from_file(container_spec_yml_path) if os.path.exists(container_spec_yml_path):
container_spec = ContainerSpec(wrapper=stack_container.wrapper).init_from_file(container_spec_yml_path)The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_containers.py",
"offset": 81,
"limit": 70
}81 def process_container(build_context: BuildContext) -> bool:
82
83 building_container = build_context.container
84 build_envs = build_context.container_build_env
85
86 default_container_tag = f"{building_container.name}:stack"
87 log_info(f"Processing build of container: {default_container_tag}")
88
89 build_envs.update({"STACK_FULL_CONTAINER_IMAGE_TAG": default_container_tag})
90 build_envs.update({"STACK_DEFAULT_CONTAINER_IMAGE_TAG": default_container_tag})
91
92 build_dir = None
93 build_script_filename = None
94
95 # Check if this is in an external stack
96 # There may be no stack (stack.name == "None") when we build a bare container
97 # DBDB we need to find where that name is getting set to the literal string "None"
98 # because that ain't right.
99 stack = build_context.stack
100 if stack.name != "None" and stack_is_external(stack):
101 log_debug(f"Determined stack: {stack.name} is external")
102 # DBDB What is this code below doing?
103 # "build" is pulled from the container description yaml
104 # Presumably it means "the relative name of the build file"
105 if building_container.build:
106 # If the build script filename was provided, we use that
107 build_script_filename = Path(building_container.file_path).parent.joinpath(building_container.build)
108 build_dir = build_script_filename.parent
109 build_envs["STACK_BUILD_DIR"] = build_dir
110 else:
111 # If the build script filename is not explicitly provided, we try to infer it
112 # DBDB this code seems not to work because we use the bare stack name rather than a directory
113 # We go looking for a "containers" directory in the root of the container's repo.
114 container_build_script_dir = fs_path_for_repo(building_container.ref).joinpath(constants.stack_files_directory_name).joinpath(constants.containers_directory_name)
115 log_debug(f"Looking for build script in this directory: {container_build_script_dir}")
116 if os.path.exists(container_build_script_dir):
117 temp_build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
118 temp_build_script_filename = temp_build_dir.joinpath("build.sh")
119 # Now check if the container exists in the external stack.
120 log_debug(f"Looking for build script at: {temp_build_script_filename}")
121 if not temp_build_script_filename.exists():
122 # If not, revert to building an internal container
123 # DBDB Why?
124 container_build_script_dir = build_context.default_container_base_dir
125 build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
126 build_script_filename = build_dir.joinpath("build.sh")
127 build_envs["STACK_BUILD_DIR"] = build_dir
128
129 # A container spec (e.g. from a wrapper) may carry an absolute path to its build script.
130 if not build_dir and building_container.build and Path(building_container.build).is_absolute():
131 build_script_filename = Path(building_container.build)
132 build_dir = build_script_filename.parent
133 build_envs["STACK_BUILD_DIR"] = build_dir
134
135 if not build_dir:
136 build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
137 build_script_filename = build_dir.joinpath("build.sh")
138
139 log_debug(f"Build script filename: {build_script_filename}")
140 log_debug(f"Build script filename: {build_dir}")
141
142 if os.path.exists(build_script_filename):
143 build_command = build_script_filename.as_posix()
144 else:
145 log_debug(f"No script file found: {build_script_filename}, using default build script")
146 if building_container.ref:
147 repo_full_path = fs_path_for_repo(building_container.ref)
148 else:
149 repo_full_path = stack.repo_path
150 default_container_tag = f"{building_container.name}:stack"
log_info(f"Processing build of container: {default_container_tag}")
build_envs.update({"STACK_FULL_CONTAINER_IMAGE_TAG": default_container_tag})
build_envs.update({"STACK_DEFAULT_CONTAINER_IMAGE_TAG": default_container_tag})
build_dir = None
build_script_filename = None
# Check if this is in an external stack
# There may be no stack (stack.name == "None") when we build a bare container
# DBDB we need to find where that name is getting set to the literal string "None"
# because that ain't right.
stack = build_context.stack
if stack.name != "None" and stack_is_external(stack): default_container_tag = f"{building_container.name}:stack"
log_info(f"Processing build of container: {default_container_tag}")
if building_container.wrapper:
return _process_wrapped_container(build_context)
build_envs.update({"STACK_FULL_CONTAINER_IMAGE_TAG": default_container_tag})
build_envs.update({"STACK_DEFAULT_CONTAINER_IMAGE_TAG": default_container_tag})
build_dir = None
build_script_filename = None
# A container spec (e.g. from a wrapper) may carry an absolute path to its build script.
if building_container.build and Path(building_container.build).is_absolute():
build_script_filename = Path(building_container.build)
build_dir = build_script_filename.parent
build_envs["STACK_BUILD_DIR"] = build_dir
# Check if this is in an external stack
# There may be no stack (stack.name == "None") when we build a bare container
# DBDB we need to find where that name is getting set to the literal string "None"
# because that ain't right.
stack = build_context.stack
if not build_dir and stack.name != "None" and stack_is_external(stack):The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
# A container spec (e.g. from a wrapper) may carry an absolute path to its build script.
if not build_dir and building_container.build and Path(building_container.build).is_absolute():
build_script_filename = Path(building_container.build)
build_dir = build_script_filename.parent
build_envs["STACK_BUILD_DIR"] = build_dir
if not build_dir:if not build_dir:
The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def build_containers(parent_stack,
def _process_wrapped_container(build_context: BuildContext) -> bool:
building_container = build_context.container
wrapper = resolve_wrapper(building_container.wrapper)
if not wrapper:
fetch_default_wrapper_repos()
wrapper = resolve_wrapper(building_container.wrapper)
if not wrapper:
error_exit(f"Unknown wrapper {building_container.wrapper} for container: {building_container.name}")
log_info(f"Building {building_container.name} using wrapper: {wrapper.name}")
wrapper_build_script = str(wrapper.build_script_path()) if wrapper.build_script_path().exists() else None
# First build the wrapper's base container.
base_context = BuildContext(
build_context.stack,
ContainerSpec(wrapper.base_container, build=wrapper_build_script),
build_context.default_container_base_dir,
dict(build_context.container_build_env),
build_context.dev_root_path,
)
if not process_container(base_context):
return False
# Now wrap the app source, using the same build script but with the
# wrapper's containerfile and the app source repo as the build context.
if building_container.ref:
app_source_dir = fs_path_for_repo(building_container.ref)
else:
app_source_dir = build_context.stack.repo_path
if building_container.path and building_container.path != ".":
app_source_dir = Path(app_source_dir).joinpath(building_container.path)
app_build_env = dict(build_context.container_build_env)
app_build_env["STACK_WEBAPP_BUILD_RUNNING"] = "true"
app_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = str(app_source_dir)
app_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = str(wrapper.containerfile_path())
app_build_env["STACK_CONTAINER_BUILD_TAG"] = f"{building_container.name}:stack"
app_context = BuildContext(
build_context.stack,
ContainerSpec(building_container.name, build=wrapper_build_script),
build_context.default_container_base_dir,
app_build_env,
build_context.dev_root_path,
)
return process_container(app_context)
def build_containers(parent_stack,The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
from stack.build.publish import publish_image
from stack.build.publish import publish_image from stack.build.wrappers import fetch_default_wrapper_repos, resolve_wrapper
The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
cat /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-backup-stack/stack.yml; git -C /home/david/projects/bpi/stack-test-stacks status --short; git -C /home/david/projects/bpi/stack-test-stacks branch --show-current
name: test-backup-stack description: "A test stack for backup and restore" containers: - bozemanpass/test-container pods: - test-backup main
name: test-static-content
description: "A test stack hosting static content via the static-content wrapper"
containers:
- name: bozemanpass/stack-test-static-content
ref: github.com/bozemanpass/stack-test-static-content
wrapper: static-content
pods:
- test-static-content
services:
static-content:
image: bozemanpass/stack-test-static-content:stack
restart: always
ports:
- "80"
File created successfully at: /home/david/projects/bpi/stack-test-stacks/stack-files/stacks/test-static-content-stack/stack.yml (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-test-stacks/stack-files/compose/composefile-test-static-content.yml (file state is current in your context — no need to Read it back)
Now extend the static content test with the deployment phase. Let me re-read the merged script first:
{
"file_path": "/home/david/projects/bpi/stack/tests/static-content-test/run-static-content-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 hosting static content with the static-content wrapper
12 echo "Running stack static content 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/static-content-repo-base-dir
20 # Overridable for local testing against an unpushed content repo
21 TEST_CONTENT_REPO=${STACK_TEST_STATIC_CONTENT_REPO:-https://github.com/bozemanpass/stack-test-static-content.git}
22 echo "Testing this package: $TEST_TARGET_SO"
23 echo "Test version command"
24 reported_version_string=$( $TEST_TARGET_SO version )
25 echo "Version reported is: ${reported_version_string}"
26 echo "Cloning repositories into: $STACK_REPO_BASE_DIR"
27 rm -rf $STACK_REPO_BASE_DIR
28 mkdir -p $STACK_REPO_BASE_DIR
29 git clone $TEST_CONTENT_REPO $STACK_REPO_BASE_DIR/stack-test-static-content
30
31 # Test webapp command execution with the static-content wrapper
32 $TEST_TARGET_SO webapp build --wrapper static-content --source-repo $STACK_REPO_BASE_DIR/stack-test-static-content
33
34 set +e
35
36 app_image_name="bozemanpass/stack-test-static-content:stack"
37
38 CONTAINER_ID=$(docker run -p 3000:80 -d ${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.index http://localhost:3000/
45 wget -O test.subdir http://localhost:3000/pages/about.html
46 wget -O test.css http://localhost:3000/css/style.css
47 wget -O test.git http://localhost:3000/.git/config
48 git_rc=$?
49
50 docker logs $CONTAINER_ID
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 "###########################################################################"
58 echo ""
59
60 grep "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" test.index > /dev/null
61 if [ $? -ne 0 ]; then
62 echo "INDEX: FAILED"
63 exit 1
64 else
65 echo "INDEX: PASSED"
66 fi
67
68 grep "STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER" test.subdir > /dev/null
69 if [ $? -ne 0 ]; then
70 echo "SUBDIR: FAILED"
71 exit 1
72 else
73 echo "SUBDIR: PASSED"
74 fi
75
76 grep "font-family" test.css > /dev/null
77 if [ $? -ne 0 ]; then
78 echo "CSS: FAILED"
79 exit 1
80 else
81 echo "CSS: PASSED"
82 fi
83
84 # The .git directory must not be served
85 if [ $git_rc -eq 0 ]; then
86 echo "GIT-NOT-SERVED: FAILED"
87 exit 1
88 else
89 echo "GIT-NOT-SERVED: PASSED"
90 fi
91
92 rm -f test.index test.subdir test.css test.git
93
94 exit 0
95 # The .git directory must not be served if [ $git_rc -eq 0 ]; then echo "GIT-NOT-SERVED: FAILED" exit 1 else echo "GIT-NOT-SERVED: PASSED" fi rm -f test.index test.subdir test.css test.git exit 0
# The .git directory must not be served
if [ $git_rc -eq 0 ]; then
echo "GIT-NOT-SERVED: FAILED"
exit 1
else
echo "GIT-NOT-SERVED: PASSED"
fi
rm -f test.index test.subdir test.css test.git
# Now test deploying static content as a stack component, via the wrapper field in stack.yml
echo "Running static content deployment test"
set -e
# Overridable for local testing against an unpushed stacks repo
if [ -n "$STACK_TEST_STACKS_REPO" ]; then
git clone $STACK_TEST_STACKS_REPO $STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-test-stacks
else
$TEST_TARGET_SO fetch repo bozemanpass/stack-test-stacks
fi
$TEST_TARGET_SO prepare --stack test-static-content
test_deployment_dir=$STACK_REPO_BASE_DIR/test-deployment-dir
test_deployment_spec=$STACK_REPO_BASE_DIR/test-deployment-spec.yml
$TEST_TARGET_SO init --stack test-static-content --output $test_deployment_spec --map-ports-to-host localhost-same
if [ ! -f "$test_deployment_spec" ]; then
echo "DEPLOY-INIT: FAILED"
exit 1
fi
echo "DEPLOY-INIT: PASSED"
$TEST_TARGET_SO deploy --spec-file $test_deployment_spec --deployment-dir $test_deployment_dir
if [ ! -d "$test_deployment_dir" ]; then
echo "DEPLOY-CREATE: FAILED"
exit 1
fi
echo "DEPLOY-CREATE: PASSED"
delete_cluster_exit () {
$TEST_TARGET_SO manage --dir $test_deployment_dir stop --delete-volumes
}
trap delete_cluster_exit EXIT
$TEST_TARGET_SO manage --dir $test_deployment_dir start
set +e
wget --tries 20 --retry-connrefused --waitretry=3 -O test.deployed http://localhost:80/
grep "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" test.deployed > /dev/null
if [ $? -ne 0 ]; then
echo "DEPLOY-INDEX: FAILED"
exit 1
else
echo "DEPLOY-INDEX: PASSED"
fi
wget -O test.deployed-subdir http://localhost:80/pages/about.html
grep "STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER" test.deployed-subdir > /dev/null
if [ $? -ne 0 ]; then
echo "DEPLOY-SUBDIR: FAILED"
exit 1
else
echo "DEPLOY-SUBDIR: PASSED"
fi
rm -f test.deployed test.deployed-subdir
exit 0The file /home/david/projects/bpi/stack/tests/static-content-test/run-static-content-test.sh has been updated successfully. (file state is current in your context — no need to Read it back)
CONTRIBUTING.md README.md backup-implementation.md backup.md commands commands.md fetching-containers.md hooks.md ingress.md install.md k8s-deployment-enhancements.md recent-features.md stack-files.md subcommands.md webapp.md docs/stack-files.md
grep -n "wrapper\|^##\|ref:\|path:" docs/stack-files.md | head -30
1:## stack.yml 6:### Example 21: ref: bozemanpass/gitea-containers 30: path: ./act-runner 32: ref: bozemanpass/gitea-containers 33: path: ./gitea 39: path: ./gitea 48: path: ./act-runner 53:## container.yml 65:### Example 74: ref: gitea.com/gitea/act_runner 80:## container.lock 89:## composefile.yml 96:### Example 117:### Environment Variables for Service Hostnames
{
"file_path": "/home/david/projects/bpi/stack/docs/stack-files.md",
"offset": 1,
"limit": 90
}1 ## stack.yml
2
3 The `stack.yml` file defines the structure and configuration of a deployment stack. It specifies the containers, pods,
4 and any pre/post-start commands required for the deployment.
5
6 ### Example
7
8 ```yaml
9 # The name of the stack.
10 name: gitea
11 # A brief description of the stack (optional).
12 description: "Gitea SCM and Actions"
13 # A list of containers to be used in the stack.
14 containers:
15 # The name of the container in the form `<organization>/<name>`. The pod's composefile.yml will need to use the
16 # same name for the image with the tag `stack`. For example: `image: bozemanpass/act-runner:stack`
17 - name: bozemanpass/act-runner
18 # An (optional) reference to the container's repository. The format is: [hostname/]organization/repo[@tag_or_branch]
19 # The hostname is optional. When omitted, github.com is assumed. The tag is also optional. If omitted,
20 # the main repo branch is used. If `ref` is omitted entirely, the current repo is assumed.
21 ref: bozemanpass/gitea-containers
22 # The relative path in the repo to the container build info. This directory must contain one (or more) of:
23 # - container.yml descriptor file (more info below)
24 # - build.sh build script
25 # The result of execution should be a local image tagged `<name>:stack`. The exact tag is available
26 # in the script build environment under ${STACK_DEFAULT_CONTAINER_IMAGE_TAG}.
27 # - Dockerfile
28 # The container will be built using the Dockerfile in this directory similar to:
29 # docker build -t ${STACK_DEFAULT_CONTAINER_IMAGE_TAG} .
30 path: ./act-runner
31 - name: bozemanpass/gitea
32 ref: bozemanpass/gitea-containers
33 path: ./gitea
34 # Pods are groups of containers that are deployed together. Each pod corresponds to one composefile.yml.
35 pods:
36 # The name of the pod.
37 - name: gitea
38 # The relative path in this repo to the directory containing the pod composefile.yml and other files.
39 path: ./gitea
40 # An (optional) command to run just _before_ the pod starts. The command is executed on the host, and the location
41 # is relative to the `path` specified above. The deployment directory will be set in the environment under
42 # ${STACK_DEPLOYMENT_DIR}, allowing a script to execute commands _inside_ the service containers with:
43 # stack manage --dir ${STACK_DEPLOYMENT_DIR} exec <service> <command>
44 pre_start_command: "run-this-first.sh"
45 # Similar to pre_start_command, but executed _after_ the pod starts.
46 post_start_command: "initialize-gitea.sh"
47 - name: act-runner
48 path: ./act-runner
49 pre_start_command: "pre_start.sh"
50 post_start_command: "post_start.sh"
51 ```
52
53 ## container.yml
54
55 The `container.yml` file defines the build configuration for individual containers within a stack. It specifies the container's name,
56 the repository reference, and the build script or command to be used for building the container image.
57
58 The build script path is relative to the `container.yml` file, not the target repo. In practice, this allows for build
59 scripts to be located in a separate repo from the container's source code, which is very useful for building customized
60 container images from repositories that are not under your control.
61
62 The following example from the `bozemanpass/gitea-containers` repo builds the `bozemanpass/act-runner` container directly from
63 the `gitea.com/gitea/act_runner` repo, for example.
64
65 ### Example
66 ```yaml
67 container:
68 # The name of the container in the form `<organization>/<name>`. The pod's composefile.yml will need to use the
69 # same name for the image with the tag `stack`. For example: `image: bozemanpass/act-runner:stack`
70 name: bozemanpass/act-runner
71 # An optional reference to the container's repository. The format is: [hostname/]organization/repo[@tag_or_branch]
72 # The hostname is optional. When omitted, github.com is assumed. The tag is also optional. If omitted,
73 # the main repo branch is used. If `ref` is omitted entirely, the current repo is assumed.
74 ref: gitea.com/gitea/act_runner
75 # Optional path to the container build script or command. This path is relative to the `container.yml` file.
76 # If no build script is provided, the default build command will be used.
77 build: ./build.sh
78 ```
79
80 ## container.lock
81
82 The `container.lock` file contains the git commit hash of the target repo. If not already present, the file is
83 automatically generated when the container is built. It can be committed to the repo to ensure the build will be
84 repeatable in the future, and when the repository is pulled by `stack` the appropriate commit will be checked out.
85
86 > Note: Even when `container.lock` is present, any local code changes will be included when building the container,
87 > since the hash is used only when the repository is cloned or pulled.
88
89 ## composefile.yml
90 path: ./act-runner
- name: bozemanpass/gitea
ref: bozemanpass/gitea-containers
path: ./gitea path: ./act-runner
- name: bozemanpass/gitea
ref: bozemanpass/gitea-containers
path: ./gitea
# An (optional) container wrapper scheme (see docs/webapp.md). When specified, the container image is built by
# wrapping the contents of the repo at `ref` using the named wrapper, e.g. `static-content` serves the repo
# contents with nginx, and `nextjs` builds and serves a Next.js webapp. The repo does not need to contain any
# container build files. Available wrappers can be listed with `stack webapp wrappers`.
- name: bozemanpass/my-static-site
ref: myorg/my-static-site
wrapper: static-contentThe file /home/david/projects/bpi/stack/docs/stack-files.md has been updated successfully. (file state is current in your context — no need to Read it back)
uv run flake8 --config tox.ini && echo LINT-OK && SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad && rm -rf $SCRATCH/stacks-repo && cp -r /home/david/projects/bpi/stack-test-stacks $SCRATCH/stacks-repo && rm -rf $SCRATCH/stacks-repo/.git && git -C $SCRATCH/stacks-repo init -q && git -C $SCRATCH/stacks-repo add -A && git -C $SCRATCH/stacks-repo -c user.email=test@test -c user.name=test commit -qm test && echo SCRATCH-REPO-OK
./scripts/build_shiv_package.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/shiv-build3.log 2>&1 && docker rmi -f bozemanpass/stack-test-static-content:stack bozemanpass/static-content-base:stack >/dev/null 2>&1; STACK_TEST_STACKS_REPO=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/stacks-repo ./tests/static-content-test/run-static-content-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-test-phase3.log 2>&1; echo "exit: $?"; grep -E "PASSED|FAILED|Error|error_exit" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-test-phase3.log | head -20
Command running in background with ID: br0ehd9j0. Output is being written to: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/tasks/br0ehd9j0.output. You will be notified when it completes. To check interim output, use Read on that file path.
Phase 3 implementation is in place; the full test (build phase + new deployment phase) is running in the background against a freshly built package. What's implemented:
wrapper: field on stack.yml container entries — parsed into StackContainer/ContainerSpec (also honored in container.yml, so a repo can declare its own wrapping). process_container() routes wrapped containers to a new _process_wrapped_container(): resolve the wrapper (auto-fetching the default wrapper repos if needed), build its base container, then wrap the app source repo using the wrapper's build script + containerfile. The existing repo-hash/tag/lock machinery in build_containers() is untouched and applies to wrapped containers as-is — so a wrapped container gets content-hash tagging for free.process_container(), which would otherwise crash on file_path=None when a wrapped build happens inside an external stack (exactly the new test's scenario).stack-test-stacks clone: new test-static-content stack — a container entry pointing at the pure-HTML stack-test-static-content repo with wrapper: static-content, plus its compose file. This is the original vision end to end: a repo containing only HTML, deployed by listing it in a stack.run-static-content-test.sh now has a deployment phase — fetch/prepare/init/deploy/manage start against the test-static-content stack, verifies the served markers on the mapped port, and tears down with --delete-volumes on exit. It supports a STACK_TEST_STACKS_REPO override for local runs (used now, since your stack-test-stacks additions aren't pushed yet).wrapper: documented in docs/stack-files.md's stack.yml reference.Lint is clean. I'll report the test outcome when it lands.