Ok useful background. I suspect that we were doing this (the original webapp idea) prior to some of those "prior art". In particular Railway wasn't even launched when we started work on this internally. Yes, please proceed.
1 # Copyright © 2022, 2023 Vulcanize
2 # Copyright © 2025 Bozeman Pass, Inc.
3
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http:#www.gnu.org/licenses/>.
16
17 # Builds webapp containers
18
19 # env vars:
20 # STACK_REPO_BASE_DIR defaults to ~/.config/stack/repos
21
22 # TODO: display the available list of containers; allow re-build of either all or specific containers
23
24 import click
25 import os
26 import sys
27
28 from pathlib import Path
29
30 from stack.build import build_containers
31 from stack.build.build_types import BuildContext
32 from stack.build.build_util import ContainerSpec
33 from stack.config.util import get_dev_root_path
34 from stack.deploy.stack import Stack
35 from stack.deploy.webapp.util import determine_base_container
36 from stack.util import error_exit
37 from stack.log import log_debug, output_main
38
39
40 @click.command()
41 @click.option("--base-container")
42 @click.option("--source-repo", help="directory containing the webapp to build", required=True)
43 @click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
44 @click.option("--extra-build-args", help="Supply extra arguments to build")
45 @click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
46 @click.pass_context
47 def command(ctx, base_container, source_repo, force_rebuild, extra_build_args, tag):
48 '''build the specified webapp container'''
49
50 # See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
51 container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
52
53 dev_root_path = get_dev_root_path()
54
55 log_debug(f"Dev Root is: {dev_root_path}")
56
57 if not base_container:
58 base_container = determine_base_container(source_repo)
59
60 # First build the base container.
61 container_build_env = build_containers.make_container_build_env(dev_root_path, container_build_dir,
62 force_rebuild, extra_build_args)
63
64 log_debug(f"Building base container: {base_container}")
65
66 build_context_1 = BuildContext(
67 Stack(),
68 ContainerSpec(base_container),
69 container_build_dir,
70 container_build_env,
71 dev_root_path,
72 )
73 ok = build_containers.process_container(build_context_1)
74 if not ok:
75 error_exit("Build failed.")
76
77 log_debug(f"Base container {base_container} build finished.")
78
79 # Now build the target webapp. We use the same build script, but with a different Dockerfile and work dir.
80 container_build_env["STACK_WEBAPP_BUILD_RUNNING"] = "true"
81 container_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = os.path.abspath(source_repo)
82 container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = os.path.join(container_build_dir,
83 base_container.replace("/", "-"),
84 "Containerfile.webapp")
85 if not tag:
86 webapp_name = os.path.abspath(source_repo).split(os.path.sep)[-1]
87 tag = f"bozemanpass/{webapp_name}:stack"
88
89 container_build_env["STACK_CONTAINER_BUILD_TAG"] = tag
90
91 log_debug(f"Building app container: {tag}")
92
93 build_context_2 = BuildContext(
94 Stack(),
95 ContainerSpec(base_container),
96 container_build_dir,
97 container_build_env,
98 dev_root_path,
99 )
100 ok = build_containers.process_container(build_context_2)
101 if not ok:
102 error_exit("Build failed.")
103
104 log_debug(f"App container {base_container} build finished.")
105 output_main("webapp build complete")
106 {
"file_path": "/home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py",
"limit": 80
}1 # Copyright © 2023 Vulcanize
2 # Copyright © 2025 Bozeman Pass, Inc.
3
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http:#www.gnu.org/licenses/>.
16
17 import hashlib
18 import json
19 import os
20 import random
21 import sys
22 import tempfile
23
24 from stack.util import run_shell_command, error_exit
25 from stack.log import log_error, log_info
26
27
28 class AttrDict(dict):
29 def __init__(self, *args, **kwargs):
30 super(AttrDict, self).__init__(*args, **kwargs)
31 self.__dict__ = self
32
33 def __getattribute__(self, attr):
34 __dict__ = super(AttrDict, self).__getattribute__("__dict__")
35 if attr in __dict__:
36 v = super(AttrDict, self).__getattribute__(attr)
37 if isinstance(v, dict):
38 return AttrDict(v)
39 return v
40
41
42 def file_hash(filename):
43 return hashlib.sha1(open(filename).read().encode()).hexdigest()
44
45
46 def determine_base_container(clone_dir, app_type="webapp"):
47 if not app_type or not app_type.startswith("webapp"):
48 raise Exception(f"Unsupported app_type {app_type}")
49
50 base_container = "bozemanpass/webapp-base"
51 if app_type == "webapp/next":
52 base_container = "bozemanpass/nextjs-base"
53 elif app_type == "webapp":
54 pkg_json_path = os.path.join(clone_dir, "package.json")
55 if os.path.exists(pkg_json_path):
56 pkg_json = json.load(open(pkg_json_path))
57 if "next" in pkg_json.get("dependencies", {}):
58 base_container = "bozemanpass/nextjs-base"
59
60 return base_container
61
62
63 def build_container_image(app_record, tag, extra_build_args=None):
64 if extra_build_args is None:
65 extra_build_args = []
66 tmpdir = tempfile.mkdtemp()
67
68 # TODO: determine if this code could be calling into the Python git library like fetch repositories
69 try:
70 record_id = app_record["id"]
71 ref = app_record.attributes.repository_ref
72 repo = random.choice(app_record.attributes.repository)
73 clone_dir = os.path.join(tmpdir, record_id)
74
75 log_info(f"Cloning repository {repo} to {clone_dir} ...")
76 # Set github credentials if present running a command like:
77 # git config --global url."https://${TOKEN}:@github.com/".insteadOf "https://github.com/"
78 github_token = os.environ.get("DEPLOYER_GITHUB_TOKEN")
79 if github_token:
80 log_info("Github token detected, setting it in the git environment")find /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base -type f | sort
/home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile.webapp /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/build.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/scripts/apply-runtime-env.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/scripts/build-app.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/scripts/find-env.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/scripts/start-serving-app.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/Containerfile /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/Containerfile.webapp /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/build.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/scripts/apply-runtime-env.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/scripts/apply-webapp-config.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/scripts/build-app.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/scripts/convert-to-runtime-env.sh /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/scripts/start-serving-app.sh
{
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_containers.py"
}1 # Copyright © 2024 Vulcanize
2 # Copyright © 2025 Bozeman Pass, Inc.
3
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http:#www.gnu.org/licenses/>.
16
17 import base64
18 import importlib.resources
19 import git
20 import json
21 import os
22 import platform
23 import subprocess
24
25 from pathlib import Path
26 from python_on_whales import DockerClient
27
28 import stack.deploy.stack as stack_util
29
30 from stack.log import log_debug, log_info
31 from stack.repos.repo_util import find_repo_root
32 from stack.util import warn_exit, get_yaml, error_exit
33
34
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:
54 name: str
55 ref: str
56 build: str
57 path: str
58 file_path: str
59 repo_path: Path
60
61 def __init__(self, name: str=None, ref=None, build=None, path=None):
62 self.name = name
63 self.ref = ref
64 self.build = build
65 self.path = path
66 self.file_path = None
67 self.repo_path = None
68
69 def __repr__(self):
70 return str(self)
71
72 def __str__(self):
73 ret = { "name": self.name, "ref": self.ref, "build": self.build, "path": self.path, "file_path": self.file_path }
74 return json.dumps(ret)
75
76 def init_from_file(self, file_path: Path):
77 self.file_path = Path(file_path).as_posix()
78 self.path = Path(self.file_path).parent.as_posix()
79
80 y = get_yaml().load(open(file_path, "r"))
81 if "container" not in y:
82 error_exit(f"No 'container' section in {file_path}.")
83 self.name = y["container"].get("name", self.name)
84 if not self.name:
85 error_exit(f"Missing required property 'name' in 'container' section of {file_path}.")
86 self.ref = y["container"].get("ref")
87 self.build = y["container"].get("build")
88 self.repo_path = find_repo_root(self.path)
89 return self
90
91 def get_repo_ref(self):
92 repo_url = self.get_repo_url()
93 if not repo_url:
94 return None
95
96 if repo_url.startswith("https://") or repo_url.startswith("http://"):
97 repo_url = repo_url.split("://", 2)[1]
98 repo_host, repo_name = repo_url.split("/", 1)
99 elif repo_url.startswith("git@"):
100 repo_host, repo_name = repo_url.split(":", 1)
101 repo_host = repo_host[4:]
102
103 if repo_name.endswith(".git"):
104 repo_name = repo_name[:-4]
105
106 return f"{repo_host}/{repo_name}"
107
108 def get_repo_name(self):
109 ref = self.get_repo_ref()
110 if ref:
111 return ref.split("/", 1)[-1]
112 return None
113
114 def get_repo_url(self):
115 if self.repo_path:
116 repo = git.Repo(self.repo_path)
117 return repo.remotes[0].url
118 return None
119
120
121 def get_containers_in_scope(stack):
122 containers_in_scope = []
123 if stack:
124 if isinstance(stack, str):
125 stack_config = stack_util.get_parsed_stack_config(stack)
126 else:
127 stack_config = stack
128 raw_containers = stack_config.get("containers", [])
129 if not raw_containers and not stack.is_super_stack():
130 warn_exit(f"stack {stack} does not define any containers")
131 else:
132 # See: https://stackoverflow.com/a/20885799/1701505
133 from stack import data
134 with importlib.resources.open_text(data, "container-image-list.txt") as container_list_file:
135 raw_containers = container_list_file.read().splitlines()
136
137
138 for container in raw_containers:
139 if isinstance(container, str):
140 containers_in_scope.append(StackContainer(container))
141 else:
142 containers_in_scope.append(StackContainer(container["name"], ref=container.get("ref"), path=container.get("path")))
143
144 log_debug(f"Containers: {containers_in_scope}")
145 if stack:
146 log_debug(f"Stack: {stack}")
147
148 return containers_in_scope
149
150
151 def container_exists_locally(tag):
152 docker = DockerClient()
153 try:
154 return docker.image.exists(tag)
155 except Exception as e:
156 if "image not known" in str(e):
157 return False
158 raise e
159
160
161 def container_exists_remotely(tag, registries=None, arch=None):
162 if not arch:
163 arch = local_container_arch()
164
165 if not registries:
166 registries = [None]
167 else:
168 registries.append(None)
169
170 for registry in registries:
171 manifests = _docker_manifest_inspect(tag, registry)
172 for manifest in manifests:
173 platform = manifest.get("Descriptor", {}).get("platform", {})
174 if arch == platform.get("architecture") and "linux" == platform.get("os"):
175 return True, registry
176
177 return False, None
178
179
180 def _docker_manifest_inspect(tag, registry=None):
181 manifest = None
182 full_tag = tag
183 if registry:
184 full_tag = f"{registry}/{tag}"
185
186 log_debug(f"Checking for {full_tag}")
187
188 # Basic docker command
189 manifest_cmd = ["docker", "manifest", "inspect", "--verbose", full_tag]
190
191 # podman does not properly support the manifest command, so we cheat by having podman run the docker-cli
192 docker_version = subprocess.run(["docker", "--version"], capture_output=True, text=True)
193 if "podman" in docker_version.stdout:
194 inspect_str = f"docker manifest inspect --verbose {full_tag}"
195 if registry:
196 username = None
197 password = None
198 registry_root = registry
199 if "/" in registry:
200 registry_root = registry.split("/")[0]
201 if os.path.exists(f"{os.environ['XDG_RUNTIME_DIR']}/containers/auth.json"):
202 auths = json.load(open(f"{os.environ['XDG_RUNTIME_DIR']}/containers/auth.json", "rt")).get("auths", {})
203 login_info = None
204 if registry in auths and "auth" in auths[registry]:
205 login_info = auths[registry]["auth"]
206 elif registry_root in auths and "auth" in auths[registry_root]:
207 login_info = auths[registry_root]["auth"]
208 if login_info:
209 username, password = base64.standard_b64decode(login_info).decode().split(":", 2)
210
211 if username and password:
212 inspect_str = f"""docker login --username "{username}" --password "{password}" {registry} >/dev/null && {inspect_str}"""
213
214 manifest_cmd = ["podman", "run", "-q", "alpinelinux/docker-cli", "sh", "-c", inspect_str]
215
216 result = subprocess.run(manifest_cmd, capture_output=True, text=True)
217 if 0 == result.returncode:
218 manifest = json.loads(result.stdout)
219 if not isinstance(manifest, list):
220 manifest = [manifest]
221
222 return manifest if manifest else []
223
224
225 def local_container_arch():
226 this_machine = platform.machine()
227 # Translate between Python and docker platform names
228 if this_machine == "x86_64":
229 this_machine = "amd64"
230 if this_machine == "aarch64":
231 this_machine = "arm64"
232 return this_machine
233 1 # Copyright © 2022, 2023 Vulcanize
2 # Copyright © 2025 Bozeman Pass, Inc.
3
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http:#www.gnu.org/licenses/>.
16
17 # Builds or pulls containers for the system components
18
19 # env vars:
20 # STACK_REPO_BASE_DIR defaults to ~/.config/stack/repos
21
22 import click
23 import git
24 import os
25
26 from pathlib import Path
27 from python_on_whales import DockerClient
28
29 from stack.base import get_npm_registry_url
30 from stack.build.build_types import BuildContext
31 from stack.build.build_util import ContainerSpec, get_containers_in_scope, container_exists_locally, container_exists_remotely, local_container_arch
32 from stack.build.publish import publish_image
33 from stack.config.util import get_config_setting, get_dev_root_path, debug_enabled
34 from stack.constants import container_file_name, container_lock_file_name
35 from stack.deploy.stack import get_parsed_stack_config, resolve_stack
36 from stack.log import log_info, log_debug, log_warn, output_main
37 from stack.opts import opts
38 from stack.repos.repo_util import host_and_path_for_repo, image_registry_for_repo, fs_path_for_repo, process_repo, get_repo_current_hash, is_repo_dirty, get_container_tag_for_repo
39 from stack.util import include_exclude_check, stack_is_external, error_exit, get_yaml
40 from stack.util import run_shell_command
41 from stack import constants
42
43 docker = DockerClient()
44
45 BUILD_POLICIES = [
46 "as-needed",
47 "build",
48 "build-force",
49 "prebuilt",
50 "prebuilt-local",
51 "prebuilt-remote",
52 ]
53
54
55 # TODO: find a place for this
56 # epilog="Config provided either in .env or settings.ini or env vars: STACK_REPO_BASE_DIR (defaults to ~/bpi)"
57
58
59 def make_container_build_env(dev_root_path: str, default_container_base_dir: str, force_rebuild: bool, extra_build_args: str):
60 container_build_env = {
61 "STACK_NPM_REGISTRY_URL": get_npm_registry_url(),
62 "STACK_GO_AUTH_TOKEN": get_config_setting("STACK_GO_AUTH_TOKEN", default=""),
63 "STACK_NPM_AUTH_TOKEN": get_config_setting("STACK_NPM_AUTH_TOKEN", default=""),
64 "STACK_REPO_BASE_DIR": dev_root_path,
65 "STACK_CONTAINER_BASE_DIR": default_container_base_dir,
66 "STACK_HOST_UID": f"{os.getuid()}",
67 "STACK_HOST_GID": f"{os.getgid()}",
68 "STACK_IMAGE_LOCAL_TAG": "stack",
69 "DOCKER_BUILDKIT": os.environ.get("DOCKER_BUILDKIT", default="1"),
70 }
71 container_build_env.update({"STACK_SCRIPT_DEBUG": "true"} if debug_enabled() else {})
72 container_build_env.update({"STACK_FORCE_REBUILD": "true"} if force_rebuild else {})
73 container_build_env.update({"STACK_CONTAINER_EXTRA_BUILD_ARGS": extra_build_args} if extra_build_args else {})
74 docker_host_env = os.getenv("DOCKER_HOST")
75 if docker_host_env:
76 container_build_env.update({"DOCKER_HOST": docker_host_env})
77
78 return container_build_env
79
80
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 if not build_dir:
130 build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
131 build_script_filename = build_dir.joinpath("build.sh")
132
133 log_debug(f"Build script filename: {build_script_filename}")
134 log_debug(f"Build script filename: {build_dir}")
135
136 if os.path.exists(build_script_filename):
137 build_command = build_script_filename.as_posix()
138 else:
139 log_debug(f"No script file found: {build_script_filename}, using default build script")
140 if building_container.ref:
141 repo_full_path = fs_path_for_repo(building_container.ref)
142 else:
143 repo_full_path = stack.repo_path
144
145 if building_container.path:
146 repo_full_path = repo_full_path.joinpath(building_container.path)
147 repo_dir_or_build_dir = repo_full_path if repo_full_path and repo_full_path.exists() else build_dir
148 build_command = (
149 os.path.join(build_context.default_container_base_dir, "default-build.sh")
150 + f" {default_container_tag} {repo_dir_or_build_dir}"
151 )
152 build_envs["STACK_BUILD_DIR"] = repo_dir_or_build_dir
153
154 build_envs["STACK_IMAGE_NAME"] = building_container.name
155
156 build_envs["STACK_REPO_STACK_DIR"] = str(stack.repo_path) if stack.repo_path else ""
157 build_envs["STACK_REPO_CONTAINER_DIR"] = str(build_context.container.repo_path) if building_container.repo_path else build_envs["STACK_REPO_STACK_DIR"]
158 build_envs["STACK_REPO_SOURCE_DIR"] = str(fs_path_for_repo(building_container.ref)) if building_container.ref else build_envs["STACK_REPO_CONTAINER_DIR"]
159
160 if not opts.o.dry_run:
161 # No PATH at all causes failures with podman.
162 if "PATH" not in build_envs:
163 build_envs["PATH"] = os.environ["PATH"]
164 log_debug(f"Executing: {build_command} with environment: {build_envs}")
165
166 build_result = run_shell_command(build_command, env=build_envs, quiet=opts.o.quiet)
167
168 log_debug(f"Build command return code is: {build_result}")
169 if build_result != 0:
170 return False
171 else:
172 return True
173 else:
174 log_info("Skipped for dry run")
175 return True
176
177
178 def build_containers(parent_stack,
179 build_policy=get_config_setting("build-policy", BUILD_POLICIES[0]),
180 image_registry=get_config_setting("image-registry"),
181 publish_images=get_config_setting("publish-images", False),
182 include=None,
183 exclude=None,
184 extra_build_args=None,
185 git_ssh=get_config_setting("git-ssh", False),
186 git_pull=False,
187 dont_pull_repo_fs_paths=None,
188 target_arch=None,
189 dont_pull_images=False):
190 dev_root_path = get_dev_root_path()
191 required_stacks = parent_stack.get_required_stacks_paths()
192 if not dont_pull_repo_fs_paths:
193 dont_pull_repo_fs_paths = []
194
195 all_containers_in_scope = []
196 finished_containers = {}
197 for stack in required_stacks:
198 stack = get_parsed_stack_config(stack)
199 containers_in_scope = [c for c in get_containers_in_scope(stack) if include_exclude_check(c.name, include, exclude)]
200 all_containers_in_scope.extend(containers_in_scope)
201
202 log_info(f"Found {len(all_containers_in_scope)} containers in {len(required_stacks)} stacks: "
203 f"{', '.join([c.name for c in all_containers_in_scope])}", bold=True)
204
205 for stack in required_stacks:
206 stack = get_parsed_stack_config(stack)
207
208 if build_policy not in BUILD_POLICIES:
209 error_exit(f"{build_policy} is not one of {BUILD_POLICIES}")
210
211 # See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
212 default_container_base_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
213
214 log_debug("Dev Root is: {dev_root_path}")
215
216 if not os.path.isdir(dev_root_path):
217 log_debug("Dev root directory doesn't exist, creating")
218
219 if target_arch and target_arch != local_container_arch():
220 if not dont_pull_images:
221 error_exit("--target-arch requires --dont-pull-images")
222 if build_policy != "prebuilt-remote":
223 error_exit("--target-arch requires --build-policy prebuilt-remote")
224
225
226 # check if we have any repos that specify the container targets / build info
227 containers_in_scope = [c for c in get_containers_in_scope(stack) if include_exclude_check(c.name, include, exclude)]
228 for stack_container in containers_in_scope:
229
230 # No container ref means use the stack repo.
231 if (not stack_container.ref or stack_container.ref == ".") and stack.get_repo_ref():
232 stack_container.ref = stack.get_repo_ref()
233
234 container_spec_yml_path = None
235 container_lock_file_path = None
236 target_hash = None
237 container_needs_built = True
238 container_was_built = False
239 container_was_pulled = False
240 container_needs_pulled = False
241 container_tag = None
242 container_spec = ContainerSpec(stack_container.name, stack_container.ref, path=stack_container.path)
243 stack_local_tag = f"{container_spec.name}:stack"
244 stack_legacy_tag = f"{container_spec.name}:local"
245 image_registry_to_pull_this_container = image_registry
246 image_registry_to_push_this_container = image_registry
247
248 log_info(f"Preparing {container_spec.name} ({len(finished_containers)+1} of {len(all_containers_in_scope)})", bold=True)
249
250 if stack_container.ref:
251 fs_path_for_container_specs = fs_path_for_repo(stack_container.ref, dev_root_path)
252 if not os.path.exists(fs_path_for_container_specs) or (git_pull and fs_path_for_container_specs not in dont_pull_repo_fs_paths):
253 process_repo(git_pull, False, git_ssh, dev_root_path, [], stack_container.ref)
254 dont_pull_repo_fs_paths.append(fs_path_for_container_specs)
255
256 image_registries_to_check = [r for r in [image_registry, image_registry_for_repo(stack_container.ref)] if r]
257
258 container_spec_yml_path = os.path.join(fs_path_for_container_specs, container_file_name)
259 container_lock_file_path = os.path.join(fs_path_for_container_specs, container_lock_file_name)
260 if stack_container.path:
261 container_spec_yml_path = os.path.join(fs_path_for_container_specs, stack_container.path, container_file_name)
262 container_lock_file_path = os.path.join(fs_path_for_container_specs, stack_container.path, container_lock_file_name)
263
264 if os.path.exists(container_spec_yml_path):
265 container_spec = ContainerSpec().init_from_file(container_spec_yml_path)
266
267 if container_spec.ref:
268 locked_hash = None
269 if os.path.exists(container_lock_file_path):
270 locked_hash = get_yaml().load(open(container_lock_file_path, "r")).get("hash")
271 log_debug("Locked hash is: " + str(locked_hash))
272
273 target_hash = locked_hash
274 repo_host, repo_path, branch_or_hash_from_spec = host_and_path_for_repo(container_spec.ref)
275 log_debug("Branch or hash from spec is: " + str(branch_or_hash_from_spec))
276 repo = f"https://{repo_host}/{repo_path}"
277 if git_ssh:
278 repo = f"git@{repo_host}:{repo_path}"
279 target_fs_repo_path = fs_path_for_repo(container_spec.ref, dev_root_path)
280 # does the ref include a hash?
281 if (
282 branch_or_hash_from_spec
283 and len(branch_or_hash_from_spec) == 40
284 and all(c in string.hexdigits for c in branch_or_hash_from_spec)
285 ):
286 if not locked_hash:
287 target_hash = branch_or_hash_from_spec
288 log_debug(f"Using specified hash {target_hash} from {container_spec.ref}")
289
290 git_hash = get_repo_current_hash(target_fs_repo_path)
291 if git_hash != target_hash:
292 error_exit(
293 f"Specified hash {branch_or_hash_from_spec} does not match current hash {git_hash}."
294 )
295 elif locked_hash != branch_or_hash_from_spec:
296 error_exit(
297 f"Specified hash {target_hash} does not match {container_lock_file_name} hash {locked_hash}. Remove {container_lock_file_path}?"
298 )
299 else:
300 if not os.path.exists(target_fs_repo_path):
301 git_client = git.cmd.Git()
302 result = git_client.ls_remote(repo, branch_or_hash_from_spec)
303 if result:
304 git_hash = result.split()[0]
305 if locked_hash:
306 if git_hash != locked_hash:
307 log_warn(
308 f"WARN: Locked hash {locked_hash} from {container_lock_file_path} behind remote hash {git_hash} for {container_spec.ref}. You may want to update."
309 )
310 else:
311 target_hash = git_hash
312 else:
313 if git_pull:
314 if locked_hash:
315 log_warn(f"WARN: Locked hash {locked_hash} from {container_lock_file_path} prevents pulling.")
316 elif not target_fs_repo_path not in dont_pull_repo_fs_paths:
317 process_repo(git_pull, False, git_ssh, dev_root_path, [], container_spec.ref)
318 dont_pull_repo_fs_paths.append(target_fs_repo_path)
319 git_hash = get_repo_current_hash(target_fs_repo_path)
320 if locked_hash:
321 if locked_hash != git_hash:
322 log_warn(
323 f"WARN: Locked hash {locked_hash} from {container_lock_file_path} does not match local hash {git_hash}."
324 )
325 else:
326 target_hash = git_hash
327
328 if is_repo_dirty(target_fs_repo_path):
329 target_hash = get_container_tag_for_repo(target_fs_repo_path)
330 log_warn(f"WARN: {target_fs_repo_path} has local modifications. Using generated hash: {target_hash}", bold=True)
331
332 container_tag = f"{container_spec.name}:{target_hash}"[:128]
333 exists_remotely = None
334 exists_locally = container_exists_locally(container_tag)
335
336 if exists_locally and build_policy in ["as-needed", "prebuilt", "prebuilt-local"]:
337 log_info(f"Container {container_tag} exists locally.")
338 container_needs_pulled = False
339 container_needs_built = False
340 # Tag the local copy to point at it.
341 docker.image.tag(container_tag, stack_local_tag)
342 else:
343 if build_policy in [ "as-needed", "prebuilt", "prebuilt-remote", ]:
344 exists_remotely, image_registry_to_pull_this_container = container_exists_remotely(container_tag, image_registries_to_check, target_arch)
345 if exists_remotely:
346 if image_registry_to_pull_this_container:
347 log_info(f"Container {image_registry_to_pull_this_container}:{container_tag} exists remotely.")
348 else:
349 log_info(f"Container {container_tag} exists remotely.")
350 container_needs_pulled = not dont_pull_images
351 container_needs_built = False
352
353 if container_needs_built:
354 if build_policy in ["prebuilt", "prebuilt-local", "prebuilt-remote"]:
355 error_exit(f"Container {container_tag} not available prebuilt.")
356 else:
357 log_info(f"Container {container_tag} needs to be built.")
358 container_needs_pulled = False
359 container_needs_built = True
360 # DBDB add comment explaining what this code below is doing.
361 if not os.path.exists(target_fs_repo_path) or (git_pull and target_fs_repo_path not in dont_pull_repo_fs_paths):
362 reconstructed_ref = f"{container_spec.ref.split('@')[0]}@{target_hash}"
363 process_repo(git_pull, False, git_ssh, dev_root_path, [], reconstructed_ref)
364 dont_pull_repo_fs_paths.append(target_fs_repo_path)
365 else:
366 log_info(f"Building {container_tag} from {target_fs_repo_path}")
367
368 if container_needs_pulled:
369 if not container_tag:
370 error_exit(f"Cannot pull container: tag missing.")
371 # Pull the remote image
372 if image_registry_to_pull_this_container:
373 run_shell_command(f"docker pull {image_registry_to_pull_this_container}/{container_tag}", quiet=opts.o.quiet)
374 # Tag the local copy to point at it.
375 docker.image.tag(f"{image_registry_to_pull_this_container}/{container_tag}", container_tag)
376 else:
377 run_shell_command(f"docker pull {container_tag}", quiet=opts.o.quiet)
378 # Tag the local copy to point at it.
379 docker.image.tag(container_tag, stack_local_tag)
380 container_was_pulled = True
381 elif container_needs_built:
382 if build_policy in ["prebuilt", "prebuilt-local", "prebuilt-remote"]:
383 error_exit(f"No prebuilt image available for: {container_spec.name}")
384
385 container_build_env = make_container_build_env(
386 dev_root_path, default_container_base_dir, "build-force" == build_policy, extra_build_args
387 )
388
389 build_context = BuildContext(stack, container_spec, default_container_base_dir, container_build_env, dev_root_path)
390
391 for tag in [stack_legacy_tag, stack_local_tag, container_tag]:
392 try:
393 docker.image.remove(tag)
394 except:
395 pass
396
397 result = process_container(build_context)
398 if result:
399 container_was_built = True
400 # Handle legacy build scripts
401 if container_exists_locally(stack_legacy_tag) and not container_exists_locally(stack_local_tag):
402 docker.image.tag(stack_legacy_tag, stack_local_tag)
403
404 # Only write the lock file if:
405 # (1) the build succeeded
406 # (2) there is a container.yml
407 # (3) it references a git repo other than its own
408 if container_lock_file_path and container_spec_yml_path and os.path.exists(container_spec_yml_path):
409 # never lock a local dev version
410 if target_hash and not target_hash.startswith("stackdev-"):
411 repo_host, repo_path, _ = host_and_path_for_repo(container_spec.ref)
412 if container_spec.ref and container_spec.ref != ".":
413 repo_host, repo_path, _ = host_and_path_for_repo(container_spec.ref)
414 if f"{repo_host}/{repo_path}" != container_spec.get_repo_ref():
415 with open(container_lock_file_path, "w") as output_file:
416 log_info(f"Writing lock file {container_lock_file_path} with hash: {target_hash}")
417 get_yaml().dump({"hash": target_hash}, output_file)
418 else:
419 error_exit(f"container build failed for: {build_context.container}")
420
421 if container_tag:
422 # We won't have a local copy with prebuilt-remote and --no-pull
423 if container_exists_locally(stack_local_tag) and container_was_built:
424 # Point the local copy at the expected name.
425 docker.image.tag(stack_local_tag, container_tag)
426
427 # Now check the other way, we have the container_tag but not the local tags
428 if container_exists_locally(container_tag):
429 if not container_exists_locally(stack_local_tag):
430 docker.image.tag(container_tag, stack_local_tag)
431 if not container_exists_locally(stack_legacy_tag):
432 docker.image.tag(container_tag, stack_legacy_tag)
433
434 if publish_images and container_tag:
435 if not image_registry_to_push_this_container:
436 error_exit(f"No image registry specified to push {container_tag}")
437 container_version = container_tag.split(":")[-1]
438 log_info(f"Publishing {container_tag} to {image_registry_to_push_this_container}")
439 publish_image(stack_local_tag, image_registry_to_push_this_container, container_version)
440
441 log_debug(f"Finished {container_spec.name} ({len(finished_containers)+1} of {len(all_containers_in_scope)})", bold=True)
442 final_status = "built" if container_was_built else "pulled" if container_was_pulled else "existing-image"
443 finished_containers[container_spec.name] = final_status
444
445 log_info(f"Prepared {len(finished_containers)} containers:")
446 max_name_len = 0
447 for name in finished_containers.keys():
448 max_name_len = max(max_name_len, len(name))
449
450 padding = 8
451 for name, status in finished_containers.items():
452 output_main(f"{name.ljust(max_name_len + padding)} {status}")
453
454
455 @click.command()
456 @click.option("--stack", help="name or path of the stack", required=False)
457 @click.option("--include", help="only build these containers")
458 @click.option("--exclude", help="don't build these containers")
459 @click.option("--git-ssh/--no-git-ssh", is_flag=True, default=get_config_setting("git-ssh", False), help="use SSH for git rather than HTTPS")
460 @click.option("--build-policy", default=BUILD_POLICIES[0], help=f"Available policies: {BUILD_POLICIES}")
461 @click.option("--extra-build-args", help="Supply extra arguments to build")
462 @click.option("--dont-pull-images", is_flag=True, default=False, help="Don't pull remote images (useful with k8s deployments).")
463 @click.option("--publish-images", is_flag=True, default=False, help="Publish the built images")
464 @click.option("--image-registry", help="Specify the remote image registry (default: auto-detect per-container)", default=get_config_setting("image-registry"))
465 @click.option("--target-arch", help="Specify a target architecture (only for use with --dont-pull-images)")
466 @click.pass_context
467 def command(ctx, stack, include, exclude, git_ssh, build_policy, extra_build_args, dont_pull_images, publish_images, image_registry, target_arch):
468 """build stack containers"""
469 stack = resolve_stack(stack)
470 build_containers(stack,
471 build_policy,
472 image_registry,
473 publish_images,
474 include,
475 exclude,
476 extra_build_args,
477 git_ssh,
478 False,
479 [],
480 target_arch,
481 dont_pull_images)
482 for f in src/stack/data/container-build/bozemanpass-webapp-base/build.sh src/stack/data/container-build/bozemanpass-webapp-base/Containerfile.webapp src/stack/data/container-build/bozemanpass-nextjs-base/build.sh src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile.webapp src/stack/data/container-build/build-base.sh; do echo "=== $f ==="; cat "$f"; done
grep -rn "determine_base_container\|webapp-base\|nextjs-base\|STACK_WEBAPP_BUILD_RUNNING" --include="*.py" --include="*.sh" src/ tests/ | grep -v ".venv"
=== src/stack/data/container-build/bozemanpass-webapp-base/build.sh ===
#!/usr/bin/env bash
# Build bozemanpass/webapp-base
source ${STACK_CONTAINER_BASE_DIR}/build-base.sh
# See: https://stackoverflow.com/a/246128/1701505
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
STACK_CONTAINER_BUILD_WORK_DIR=${STACK_CONTAINER_BUILD_WORK_DIR:-$SCRIPT_DIR}
STACK_CONTAINER_BUILD_CONTAINERFILE=${STACK_CONTAINER_BUILD_CONTAINERFILE:-$SCRIPT_DIR/Containerfile}
STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/webapp-base:stack}
docker build -t $STACK_CONTAINER_BUILD_TAG ${build_command_args} -f $STACK_CONTAINER_BUILD_CONTAINERFILE $STACK_CONTAINER_BUILD_WORK_DIR
rc=$?
if [ $rc -ne 0 ]; then
echo "BUILD FAILED" 1>&2
exit $rc
fi
if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/webapp-base:stack" ]; then
cat <<EOF
#################################################################
Built host container for $STACK_CONTAINER_BUILD_WORK_DIR with tag:
$STACK_CONTAINER_BUILD_TAG
To test locally run:
stack webapp run --image $STACK_CONTAINER_BUILD_TAG --config-file /path/to/environment.env
EOF
fi
=== src/stack/data/container-build/bozemanpass-webapp-base/Containerfile.webapp ===
FROM bozemanpass/webapp-base:stack as builder
ARG STACK_BUILD_TOOL
ARG STACK_BUILD_OUTPUT_DIR
WORKDIR /app
COPY . .
RUN rm -rf node_modules build dist .next*
RUN /scripts/build-app.sh /app /data
FROM bozemanpass/webapp-base:stack
COPY --from=builder /data /data
=== src/stack/data/container-build/bozemanpass-nextjs-base/build.sh ===
#!/usr/bin/env bash
# Build bozemanpass/nextjs-base
source ${STACK_CONTAINER_BASE_DIR}/build-base.sh
# See: https://stackoverflow.com/a/246128/1701505
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
STACK_CONTAINER_BUILD_WORK_DIR=${STACK_CONTAINER_BUILD_WORK_DIR:-$SCRIPT_DIR}
STACK_CONTAINER_BUILD_CONTAINERFILE=${STACK_CONTAINER_BUILD_CONTAINERFILE:-$SCRIPT_DIR/Containerfile}
STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/nextjs-base:stack}
docker build -t $STACK_CONTAINER_BUILD_TAG ${build_command_args} -f $STACK_CONTAINER_BUILD_CONTAINERFILE $STACK_CONTAINER_BUILD_WORK_DIR
rc=$?
if [ $rc -ne 0 ]; then
echo "BUILD FAILED" 1>&2
exit $rc
fi
if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/nextjs-base:stack" ]; then
cat <<EOF
#################################################################
Built host container for $STACK_CONTAINER_BUILD_WORK_DIR with tag:
$STACK_CONTAINER_BUILD_TAG
To test locally run:
stack webapp run --image $STACK_CONTAINER_BUILD_TAG --config-file /path/to/environment.env
EOF
fi
=== src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile.webapp ===
FROM bozemanpass/nextjs-base:stack
ARG STACK_NEXTJS_VERSION=keep
ARG STACK_BUILD_TOOL
WORKDIR /app
COPY . .
RUN rm -rf node_modules build .next*
RUN /scripts/build-app.sh /app
=== src/stack/data/container-build/build-base.sh ===
# source'ed into container build scripts to do generic command setup
if [[ -n "$STACK_SCRIPT_DEBUG" ]]; then
set -x
echo "Build environment variables:"
env
fi
build_command_args=""
if [[ ${STACK_FORCE_REBUILD} == "true" ]]; then
build_command_args="${build_command_args} --no-cache"
fi
if [[ -n "$STACK_CONTAINER_EXTRA_BUILD_ARGS" ]]; then
build_command_args="${build_command_args} ${STACK_CONTAINER_EXTRA_BUILD_ARGS}"
fisrc/stack/build/build_webapp.py:35:from stack.deploy.webapp.util import determine_base_container
src/stack/build/build_webapp.py:58: base_container = determine_base_container(source_repo)
src/stack/build/build_webapp.py:80: container_build_env["STACK_WEBAPP_BUILD_RUNNING"] = "true"
src/stack/data/container-build/bozemanpass-nextjs-base/build.sh:2:# Build bozemanpass/nextjs-base
src/stack/data/container-build/bozemanpass-nextjs-base/build.sh:11:STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/nextjs-base:stack}
src/stack/data/container-build/bozemanpass-nextjs-base/build.sh:21:if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/nextjs-base:stack" ]; then
src/stack/data/container-build/bozemanpass-webapp-base/build.sh:2:# Build bozemanpass/webapp-base
src/stack/data/container-build/bozemanpass-webapp-base/build.sh:11:STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/webapp-base:stack}
src/stack/data/container-build/bozemanpass-webapp-base/build.sh:21:if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/webapp-base:stack" ]; then
src/stack/deploy/webapp/util.py:46:def determine_base_container(clone_dir, app_type="webapp"):
src/stack/deploy/webapp/util.py:50: base_container = "bozemanpass/webapp-base"
src/stack/deploy/webapp/util.py:52: base_container = "bozemanpass/nextjs-base"
src/stack/deploy/webapp/util.py:58: base_container = "bozemanpass/nextjs-base"
src/stack/deploy/webapp/util.py:110: base_container = determine_base_container(clone_dir, app_record.attributes.app_type)I have the full picture now. Let me check the one other caller of determine_base_container before writing the new wrapper module.
{
"file_path": "/home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py",
"offset": 80,
"limit": 70
}80 log_info("Github token detected, setting it in the git environment")
81 git_config_args = [
82 "git",
83 "config",
84 "--global",
85 f"url.https://{github_token}:@github.com/.insteadOf",
86 "https://github.com/",
87 ]
88 run_shell_command(git_config_args)
89 if ref:
90 # TODO: Determing branch or hash, and use depth 1 if we can.
91 git_env = dict(os.environ.copy())
92 # Never prompt
93 git_env["GIT_TERMINAL_PROMPT"] = "0"
94 try:
95 run_shell_command(f"git clone {repo} {clone_dir}", env=git_env)
96 except Exception as e:
97 log_error(f"git clone failed. Is the repository {repo} private?")
98 raise e
99
100 try:
101 run_shell_command(f"git checkout {ref}", cwd=clone_dir, env=git_env)
102 except Exception as e:
103 log_error(f"git checkout failed. Does ref {ref} exist?")
104 raise e
105 else:
106 # TODO: why is this code different vs the branch above (run vs check_call, and no prompt disable)?
107 run_shell_command(f"git checkout {ref}", cwd=clone_dir, env=git_env)
108 run_shell_command(f"git clone --depth 1 {repo} {clone_dir}")
109
110 base_container = determine_base_container(clone_dir, app_record.attributes.app_type)
111
112 log_info("Building webapp ...")
113 build_command = [
114 sys.argv[0],
115 "--verbose",
116 "webapp build",
117 "--source-repo",
118 clone_dir,
119 "--tag",
120 tag,
121 "--base-container",
122 base_container,
123 ]
124 if extra_build_args:
125 build_command.append("--extra-build-args")
126 build_command.append(" ".join(extra_build_args))
127
128 rc = run_shell_command(build_command)
129 if rc != 0:
130 error_exit(f"{build_command} failed")
131 finally:
132 run_shell_command(f"rm -rf '{tmpdir}'")
133
134
135 def push_container_image(deployment_dir, logger):
136 log_info("Pushing images ...")
137 run_shell_command(f"'{sys.argv[0]}' manage --dir '{deployment_dir}' push-images")
138 log_info("Finished pushing images.")
139
140
141 def deploy_to_k8s(deploy_record, deployment_dir, recreate, logger):
142 log_info("Deploying to k8s ...")
143
144 if recreate:
145 commands_to_run = ["stop", "start"]
146 else:
147 if not deploy_record:
148 commands_to_run = ["start"]
149 else:grep -rn "build_container_image\|from stack.deploy.webapp.util import\|import util" src/stack/deploy/webapp/*.py | grep -v ".venv"
src/stack/deploy/webapp/util.py:63:def build_container_image(app_record, tag, extra_build_args=None):
# Copyright © 2026 Bozeman Pass, Inc.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
# Container "wrapper" schemes.
#
# A wrapper is a recipe for wrapping application source (e.g. a webapp or
# static content fetched from a git repository) in a container image, for
# applications that do not provide their own container build. Each wrapper
# is described by a wrapper.yml manifest that lives alongside the build
# files for its base container image:
#
# wrapper:
# name: nextjs # short name, used to select the wrapper
# description: Next.js webapp
# base-container: bozemanpass/nextjs-base # base image, built/pulled like any container
# containerfile: Containerfile.webapp # app-image build, context is the app source repo
# port: 80 # port the wrapped app serves on
# default: true # optional: fallback when detection finds no match
# detect: # optional: rules for auto-detection
# package-json-dependency: next
#
# Wrappers are currently discovered from the built-in container-build data
# directory. TODO(phase 2): also discover wrappers from external repos
# fetched beneath STACK_REPO_BASE_DIR, and teach the base-container build to
# use the wrapper's own directory rather than the built-in name convention.
import json
import os
from pathlib import Path
from stack.util import get_yaml, error_exit
class Wrapper:
name: str
description: str
base_container: str
containerfile: str
port: int
default: bool
detect: dict
dir: Path
def __init__(self):
self.name = None
self.description = None
self.base_container = None
self.containerfile = None
self.port = None
self.default = False
self.detect = {}
self.dir = None
def __repr__(self):
return str(self)
def __str__(self):
ret = {"name": self.name, "base-container": self.base_container, "dir": str(self.dir)}
return json.dumps(ret)
def init_from_file(self, file_path: Path):
self.dir = Path(file_path).absolute().parent
y = get_yaml().load(open(file_path, "r"))
if "wrapper" not in y:
error_exit(f"No 'wrapper' section in {file_path}.")
wrapper_section = y["wrapper"]
self.name = wrapper_section.get("name")
if not self.name:
error_exit(f"Missing required property 'name' in 'wrapper' section of {file_path}.")
self.base_container = wrapper_section.get("base-container")
if not self.base_container:
error_exit(f"Missing required property 'base-container' in 'wrapper' section of {file_path}.")
self.containerfile = wrapper_section.get("containerfile")
if not self.containerfile:
error_exit(f"Missing required property 'containerfile' in 'wrapper' section of {file_path}.")
self.description = wrapper_section.get("description", "")
self.port = wrapper_section.get("port")
self.default = wrapper_section.get("default", False)
self.detect = wrapper_section.get("detect", {})
return self
def containerfile_path(self) -> Path:
return self.dir.joinpath(self.containerfile)
def matches(self, source_repo_dir) -> bool:
if not self.detect:
return False
dependency = self.detect.get("package-json-dependency")
if dependency:
pkg_json_path = os.path.join(source_repo_dir, "package.json")
if os.path.exists(pkg_json_path):
pkg_json = json.load(open(pkg_json_path))
if dependency in pkg_json.get("dependencies", {}):
return True
return False
def get_available_wrappers():
wrappers = []
builtin_container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
for manifest_path in sorted(builtin_container_build_dir.glob("*/wrapper.yml")):
wrappers.append(Wrapper().init_from_file(manifest_path))
return wrappers
def resolve_wrapper(name: str):
# Accept either the wrapper name or its base container name (for
# backwards compatibility with --base-container).
for wrapper in get_available_wrappers():
if name in (wrapper.name, wrapper.base_container):
return wrapper
return None
def detect_wrapper(source_repo_dir):
default_wrapper = None
for wrapper in get_available_wrappers():
if wrapper.default:
default_wrapper = wrapper
elif wrapper.matches(source_repo_dir):
return wrapper
return default_wrapper
File created successfully at: /home/david/projects/bpi/stack/src/stack/build/wrappers.py (file state is current in your context — no need to Read it back)
wrapper: name: webapp description: Generic node.js webapp (React, vite, static site generators, etc.) base-container: bozemanpass/webapp-base containerfile: Containerfile.webapp port: 80 default: true
File created successfully at: /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml (file state is current in your context — no need to Read it back)
wrapper:
name: nextjs
description: Next.js webapp with runtime environment variable support
base-container: bozemanpass/nextjs-base
containerfile: Containerfile.webapp
port: 80
detect:
package-json-dependency: next
File created successfully at: /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml (file state is current in your context — no need to Read it back)
from stack.build import build_containers
from stack.build.build_types import BuildContext
from stack.build.build_util import ContainerSpec
from stack.config.util import get_dev_root_path
from stack.deploy.stack import Stack
from stack.deploy.webapp.util import determine_base_container
from stack.util import error_exit
from stack.log import log_debug, output_main
@click.command()
@click.option("--base-container")
@click.option("--source-repo", help="directory containing the webapp to build", required=True)
@click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
@click.option("--extra-build-args", help="Supply extra arguments to build")
@click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
@click.pass_context
def command(ctx, base_container, source_repo, force_rebuild, extra_build_args, tag):
'''build the specified webapp container'''
# See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
dev_root_path = get_dev_root_path()
log_debug(f"Dev Root is: {dev_root_path}")
if not base_container:
base_container = determine_base_container(source_repo)from stack.build import build_containers
from stack.build.build_types import BuildContext
from stack.build.build_util import ContainerSpec
from stack.build.wrappers import detect_wrapper, resolve_wrapper
from stack.config.util import get_dev_root_path
from stack.deploy.stack import Stack
from stack.util import error_exit
from stack.log import log_debug, output_main
@click.command()
@click.option("--wrapper", help="wrapper scheme to use (default: auto-detect from the app source)")
@click.option("--base-container", help="wrapper base container (deprecated: use --wrapper)")
@click.option("--source-repo", help="directory containing the webapp to build", required=True)
@click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
@click.option("--extra-build-args", help="Supply extra arguments to build")
@click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
@click.pass_context
def command(ctx, wrapper, base_container, source_repo, force_rebuild, extra_build_args, tag):
'''build the specified webapp container'''
# See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
dev_root_path = get_dev_root_path()
log_debug(f"Dev Root is: {dev_root_path}")
if wrapper or base_container:
wrapper_spec = resolve_wrapper(wrapper if wrapper else base_container)
if not wrapper_spec:
error_exit(f"Unknown wrapper: {wrapper if wrapper else base_container}")
else:
wrapper_spec = detect_wrapper(source_repo)
if not wrapper_spec:
error_exit(f"Unable to determine a wrapper for: {source_repo}")
base_container = wrapper_spec.base_container
log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}")The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = os.path.join(container_build_dir,
base_container.replace("/", "-"),
"Containerfile.webapp")container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = str(wrapper_spec.containerfile_path())
The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
def determine_base_container(clone_dir, app_type="webapp"):
if not app_type or not app_type.startswith("webapp"):
raise Exception(f"Unsupported app_type {app_type}")
base_container = "bozemanpass/webapp-base"
if app_type == "webapp/next":
base_container = "bozemanpass/nextjs-base"
elif app_type == "webapp":
pkg_json_path = os.path.join(clone_dir, "package.json")
if os.path.exists(pkg_json_path):
pkg_json = json.load(open(pkg_json_path))
if "next" in pkg_json.get("dependencies", {}):
base_container = "bozemanpass/nextjs-base"
return base_containerdef determine_base_container(clone_dir, app_type="webapp"):
if not app_type or not app_type.startswith("webapp"):
raise Exception(f"Unsupported app_type {app_type}")
if app_type == "webapp/next":
wrapper = resolve_wrapper("nextjs")
else:
wrapper = detect_wrapper(clone_dir)
if not wrapper:
raise Exception(f"Unable to determine a wrapper for app_type {app_type} in {clone_dir}")
return wrapper.base_containerThe file /home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py has been updated successfully. (file state is current in your context — no need to Read it back)
from stack.util import run_shell_command, error_exit from stack.log import log_error, log_info
from stack.build.wrappers import detect_wrapper, resolve_wrapper from stack.util import run_shell_command, error_exit from stack.log import log_error, log_info
The file /home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py has been updated successfully. (file state is current in your context — no need to Read it back)
uv run flake8 --config tox.ini src/stack/build/wrappers.py src/stack/build/build_webapp.py src/stack/deploy/webapp/util.py && uv run python -c "
from stack.build.wrappers import get_available_wrappers, resolve_wrapper, detect_wrapper
ws = get_available_wrappers()
for w in ws: print(w.name, w.base_container, w.containerfile_path(), 'default' if w.default else '', w.detect)
assert resolve_wrapper('nextjs').base_container == 'bozemanpass/nextjs-base'
assert resolve_wrapper('bozemanpass/webapp-base').name == 'webapp'
assert resolve_wrapper('nope') is None
import tempfile, os, json, pathlib
d = tempfile.mkdtemp()
assert detect_wrapper(d).name == 'webapp'
open(os.path.join(d,'package.json'),'w').write(json.dumps({'dependencies':{'next':'14'}}))
assert detect_wrapper(d).name == 'nextjs'
from stack.deploy.webapp.util import determine_base_container
assert determine_base_container(d) == 'bozemanpass/nextjs-base'
assert determine_base_container(d, 'webapp/next') == 'bozemanpass/nextjs-base'
print('resolution checks passed')
"Exit code 1 src/stack/build/build_webapp.py:26:1: F401 'sys' imported but unused src/stack/deploy/webapp/util.py:18:1: F401 'json' imported but unused
import click import os import sys
import click import os
The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
import hashlib import json import os
import hashlib import os
The file /home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py has been updated successfully. (file state is current in your context — no need to Read it back)
uv run flake8 --config tox.ini src/stack/build/wrappers.py src/stack/build/build_webapp.py src/stack/deploy/webapp/util.py && uv run python -c "
from stack.build.wrappers import get_available_wrappers, resolve_wrapper, detect_wrapper
ws = get_available_wrappers()
for w in ws: print(w.name, w.base_container, w.containerfile_path(), 'default' if w.default else '', w.detect)
assert resolve_wrapper('nextjs').base_container == 'bozemanpass/nextjs-base'
assert resolve_wrapper('bozemanpass/webapp-base').name == 'webapp'
assert resolve_wrapper('nope') is None
import tempfile, os, json
d = tempfile.mkdtemp()
assert detect_wrapper(d).name == 'webapp'
open(os.path.join(d,'package.json'),'w').write(json.dumps({'dependencies':{'next':'14'}}))
assert detect_wrapper(d).name == 'nextjs'
from stack.deploy.webapp.util import determine_base_container
assert determine_base_container(d) == 'bozemanpass/nextjs-base'
assert determine_base_container(d, 'webapp/next') == 'bozemanpass/nextjs-base'
print('resolution checks passed')
"nextjs bozemanpass/nextjs-base /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile.webapp {'package-json-dependency': 'next'}
webapp bozemanpass/webapp-base /home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/Containerfile.webapp default {}
resolution checks passed./scripts/build_shiv_package.sh >/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/shiv-build.log 2>&1 && ls -t package/ | head -3
stack-2.0.0-11bf1ba-202607231653 stack-2.0.0-fec991c-202607011238 stack-2.0.0-0648a9e-202606300818
./tests/webapp-test/run-webapp-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log 2>&1; echo "exit: $?"
Command running in background with ID: b30f0o8q0. Output is being written to: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/tasks/b30f0o8q0.output. You will be notified when it completes. To check interim output, use Read on that file path.
While that runs (the Next.js build takes a few minutes), I'll document the wrapper mechanism in docs/webapp.md.
## Building Building usually requires no additional configuration.
## Wrappers
Webapp images are built using a *wrapper*: a recipe that pairs a base container image with a
containerfile that knows how to build the app source into a servable image. Each wrapper is
described by a `wrapper.yml` manifest located with the base container's build files (see
`src/stack/data/container-build/*/wrapper.yml`), for example:
```yaml
wrapper:
name: nextjs
description: Next.js webapp with runtime environment variable support
base-container: bozemanpass/nextjs-base
containerfile: Containerfile.webapp
port: 80
detect:
package-json-dependency: next
```
By default the wrapper is auto-detected from the app source (using the manifest `detect` rules,
falling back to the wrapper marked `default`). A specific wrapper can be selected with
`--wrapper`, e.g. `stack webapp build --wrapper nextjs --source-repo ~/my-app`.
## Building
Building usually requires no additional configuration.The file /home/david/projects/bpi/stack/docs/webapp.md has been updated successfully. (file state is current in your context — no need to Read it back)
sleep 60; tail -5 /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log
<tool_use_error>Blocked: sleep 60 followed by: tail -5 /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>
uv run flake8 --config tox.ini && echo "full lint clean"; git status --short
full lint clean M docs/webapp.md M src/stack/build/build_webapp.py M src/stack/deploy/webapp/util.py ?? src/stack/build/wrappers.py ?? src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml ?? src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml
tail -3 /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log 2>/dev/null; grep -c "" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log 2>/dev/null
2026-07-23 22:54:35.896618: #6 sha256:db5e654ed9f235b871c9771e74bc59c2eab72fcf3deccab8a67d1e347d186016 33.55MB / 41.45MB 24.2s 2026-07-23 22:54:36.796999: #6 sha256:db5e654ed9f235b871c9771e74bc59c2eab72fcf3deccab8a67d1e347d186016 35.65MB / 41.45MB 25.1s 2026-07-23 22:54:37.596509: #6 sha256:db5e654ed9f235b871c9771e74bc59c2eab72fcf3deccab8a67d1e347d186016 37.75MB / 41.45MB 25.9s 146
Phase 1 code is in place and the full webapp test is now running in the background (currently pulling the node base image — it'll take a few minutes to build the PWA). I'll report the result when it finishes.
What's changed so far:
src/stack/build/wrappers.py (new) — the Wrapper class, wrapper.yml manifest loading, discovery (get_available_wrappers(), currently scanning the built-in data/container-build/*/wrapper.yml), name/base-container resolution, and detection (detect: rules with a package-json-dependency rule, falling back to the wrapper marked default: true).wrapper.yml manifests for the two existing schemes: webapp (webapp-base, the default) and nextjs (nextjs-base, detected via a next dependency in package.json).build_webapp.py now resolves a wrapper (new --wrapper option; --base-container kept working for compatibility) and takes the app-build Containerfile path from the manifest instead of the hardcoded name convention.determine_base_container() in deploy/webapp/util.py now delegates to wrapper detection, preserving its signature for the registry-deploy path.docs/webapp.md gained a short "Wrappers" section documenting the manifest and --wrapper.Lint is clean repo-wide, and unit-level checks confirm resolution/detection behaves identically to the old hardcoded logic (default → webapp-base, next dependency → nextjs-base, webapp/next app type → nextjs-base).
tail -15 /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log
2026-07-23 22:56:39.409290: #9 52.87 2026-07-23 22:56:39.409295: #9 52.89 ▲ Next.js 14.2.5 2026-07-23 22:56:39.409300: #9 52.89 - Environments: .env 2026-07-23 22:56:39.409303: #9 52.89 2026-07-23 22:56:39.409306: #9 52.92 Creating an optimized production build ... 2026-07-23 22:56:39.429808: #9 53.10 > [PWA] Compile server 2026-07-23 22:56:39.581182: #9 53.10 > [PWA] Compile server 2026-07-23 22:56:39.581209: #9 53.10 > [PWA] Compile client (static) 2026-07-23 22:56:39.581214: #9 53.10 > [PWA] Auto register service worker with: /app/node_modules/next-pwa/register.js 2026-07-23 22:56:39.581218: #9 53.10 > [PWA] Service worker: /app/public/sw.js 2026-07-23 22:56:39.581221: #9 53.10 > [PWA] url: /sw.js 2026-07-23 22:56:39.581224: #9 53.10 > [PWA] scope: / 2026-07-23 22:56:39.683949: #9 53.35 Browserslist: caniuse-lite is outdated. Please run: 2026-07-23 22:56:39.683982: #9 53.35 npx update-browserslist-db@latest 2026-07-23 22:56:39.683987: #9 53.35 Why you should do it regularly: https://github.com/browserslist/update-db#readme
The test is deep into the meaningful part — it auto-detected the nextjs wrapper and is running the Next.js production build inside the wrapper's Containerfile, so the new resolution path is working end-to-end. Still a few minutes from the final before/after env-injection checks; I'll confirm the result when it lands.
On the repos: you can create them whenever convenient — they're needed at the start of Phase 2, and empty is fine since I'll prepare the content as part of that phase. My suggestion on naming/split:
bozemanpass/webapp-base and bozemanpass/nextjs-base. The repo-name-equals-image-name convention pays off directly: image_registry_for_repo() already maps github.com repos to ghcr.io, so the existing pull-or-build machinery would find prebuilt base images at ghcr.io/bozemanpass/webapp-base with no extra plumbing. The two bases don't actually share files today (each has its own copy of scripts/), so there's no shared-code argument for a combined repo, and per-repo locking means each wrapper versions independently.wrapper.yml, Containerfile (base image), Containerfile.webapp (app build), build.sh, scripts/.bozemanpass/static-content.If you'd rather not squat those image-style names as repo names, a single bozemanpass/container-wrappers repo with one subdirectory per wrapper also works (the path: field in container refs already supports subdirectory layouts) — but the per-repo version is my recommendation.
One thing to decide before Phase 2 work starts: whether you want a PR for Phase 1 first so the external-discovery change builds on a merged baseline. I'd suggest yes — once this test passes I can branch, commit, and open it.
grep -E "PASSED|FAILED|webapp build complete|Running deployment" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log; tail -2 /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test.log
2026-07-23 22:56:51.565797: webapp build complete webapp build complete BEFORE: PASSED AFTER: PASSED Running deployment create test PASSED 2026-07-23 22:57:20.370801: WARN: --image-registry not specified, only default container registries (eg, Docker Hub) will be available PASSED
The full webapp test suite passed with the Phase 1 changes — exit code 0, all checks green:
webapp build auto-detected the nextjs wrapper and built bozemanpass/test-progressive-web-app:stack through the new wrapper path.docker run time.webapp deploy deployment-create test also passed.So Phase 1 is complete and validated: the wrapper abstraction is in, the two existing schemes are expressed as wrapper.yml manifests, and behavior is unchanged. The working tree has the changes uncommitted (3 modified files, 3 new). Say the word and I'll branch, commit, and open the Phase 1 PR — and as covered earlier, you can create the two external repos (bozemanpass/webapp-base, bozemanpass/nextjs-base) whenever you like; Phase 2 starts by populating them.
Great. I suggest we name the new repositories something like bozemanpass/stack-wrapper-webapp and bozemanpass/stack-wrapper-static-content in order to make it easy to discover them (and for me to remember where they are!)
Good naming — the stack-wrapper- prefix makes them self-describing and greppable in the org, and it costs nothing technically: the base image name (bozemanpass/webapp-base etc.) is declared inside wrapper.yml, not derived from the repo name, and ghcr.io package names don't need to match repo names either, so the prebuilt-base-image pull path is unaffected.
One detail to settle: the nextjs wrapper. Your two names suggest either a bozemanpass/stack-wrapper-nextjs as a third repo, or folding nextjs into stack-wrapper-webapp as a subdirectory (the two are close cousins — both node bases, and wrapper.yml discovery by rglob handles multiple wrappers per repo fine). I'd lean to the separate stack-wrapper-nextjs repo purely so each wrapper versions and locks independently — but if you'd rather remember one repo for "the node webapp wrappers," a combined stack-wrapper-webapp containing both manifests works just as well with the discovery mechanism as designed. Your call when you create them; Phase 2 code is identical either way.
Meanwhile, shall I go ahead and branch/commit/open the Phase 1 PR? Everything is validated and sitting uncommitted in the working tree.
I'll handle creating the branch and PR. I did make the two external repositories and have cloned them as sibling directories to this one.
ls -la /home/david/projects/bpi/ && for d in /home/david/projects/bpi/stack-wrapper-*; do echo "=== $d ==="; ls -la "$d"; git -C "$d" remote -v | head -2; done
total 40 drwxr-xr-x 10 david david 4096 Jul 23 17:00 . drwxr-xr-x 9 david david 4096 Mar 27 13:02 .. drwxr-xr-x 6 david david 4096 Jun 29 21:30 backup-stack drwxr-xr-x 6 david david 4096 Jun 29 19:19 docker-ingress-stack drwxr-xr-x 12 david david 4096 Apr 8 12:43 machine drwxr-xr-x 12 david david 4096 Jul 23 16:57 stack drwxr-xr-x 4 david david 4096 Jun 30 06:13 stack-test-stacks drwxr-xr-x 3 david david 4096 Jul 23 17:01 stack-wrapper-static-content drwxr-xr-x 3 david david 4096 Jul 23 17:00 stack-wrapper-webapp drwxr-xr-x 9 david david 4096 Mar 27 13:01 test-python-cli-tool === /home/david/projects/bpi/stack-wrapper-static-content === total 16 drwxr-xr-x 3 david david 4096 Jul 23 17:01 . drwxr-xr-x 10 david david 4096 Jul 23 17:00 .. drwxr-xr-x 8 david david 4096 Jul 23 17:01 .git -rw-r--r-- 1 david david 30 Jul 23 17:01 README.md origin git@github.com:bozemanpass/stack-wrapper-static-content.git (fetch) origin git@github.com:bozemanpass/stack-wrapper-static-content.git (push) === /home/david/projects/bpi/stack-wrapper-webapp === total 16 drwxr-xr-x 3 david david 4096 Jul 23 17:00 . drwxr-xr-x 10 david david 4096 Jul 23 17:00 .. drwxr-xr-x 8 david david 4096 Jul 23 17:00 .git -rw-r--r-- 1 david david 22 Jul 23 17:00 README.md origin git@github.com:bozemanpass/stack-wrapper-webapp.git (fetch) origin git@github.com:bozemanpass/stack-wrapper-webapp.git (push)
1 # Copyright © 2024 Vulcanize 2 3 # This program is free software: you can redistribute it and/or modify 4 # it under the terms of the GNU Affero General Public License as published by 5 # the Free Software Foundation, either version 3 of the License, or 6 # (at your option) any later version. 7 8 # This program is distributed in the hope that it will be useful, 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 # GNU Affero General Public License for more details. 12 13 # You should have received a copy of the GNU Affero General Public License 14 # along with this program. If not, see <http:#www.gnu.org/licenses/>. 15 16 17 from dataclasses import dataclass 18 from pathlib import Path 19 from typing import Mapping 20 21 from stack.deploy.stack import Stack 22 from stack.build.build_util import ContainerSpec 23 24 25 @dataclass 26 class BuildContext: 27 stack: Stack 28 container: ContainerSpec 29 default_container_base_dir: Path 30 container_build_env: Mapping[str,str] 31 dev_root_path: Path 32 33
1 # Copyright © 2025 Bozeman Pass, Inc. 2 3 4 # This program is free software: you can redistribute it and/or modify 5 # it under the terms of the GNU Affero General Public License as published by 6 # the Free Software Foundation, either version 3 of the License, or 7 # (at your option) any later version. 8 9 # This program is distributed in the hope that it will be useful, 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 # GNU Affero General Public License for more details. 13 14 # You should have received a copy of the GNU Affero General Public License 15 # along with this program. If not, see <http:#www.gnu.org/licenses/>. 16 17 import click 18 19 from stack.build.build_webapp import command as build_webapp 20 from stack.deploy.webapp.deploy_webapp import create as deploy_webapp 21 from stack.deploy.webapp.run_webapp import command as run_webapp 22 23 24 @click.group() 25 @click.pass_context 26 def command(ctx): 27 """build, run, and deploy webapps""" 28 pass 29 30 31 command.add_command(build_webapp, "build") 32 command.add_command(deploy_webapp, "deploy") 33 command.add_command(run_webapp, "run") 34
grep -n "def get_dev_root_path" -A 8 /home/david/projects/bpi/stack/src/stack/config/util.py; grep -n "def locate_stacks_beneath" -A 12 /home/david/projects/bpi/stack/src/stack/deploy/stack.py
109:def get_dev_root_path():
110- return Path(get_config_setting("STACK_REPO_BASE_DIR"))
111-
112-
113-def debug_enabled():
114- return get_config_setting("STACK_DEBUG", False)
428:def locate_stacks_beneath(search_path=get_dev_root_path()):
429- stacks = []
430- if search_path.exists():
431- for path in search_path.rglob("stack.yml"):
432- stacks.append(Stack().init_from_file(path))
433-
434- return stacks
435-
436-
437-def locate_single_stack(stack_name, search_path=get_dev_root_path(), fail_on_multiple=True, fail_on_none=True):
438- stacks = locate_stacks_beneath(search_path)
439- candidates = [s for s in stacks if s.name == stack_name]
440- if len(candidates) == 1:Now I'll implement Phase 2. Stack-side changes: external wrapper discovery beneath the repo base dir, base-container builds from an external wrapper directory, and a stack webapp wrappers listing command.
head -30 /home/david/projects/bpi/stack/src/stack/config/util.py | grep -n "^import\|^from"; cat /home/david/projects/bpi/stack-wrapper-webapp/README.md /home/david/projects/bpi/stack-wrapper-static-content/README.md
16:import os 17:import stack.util 19:from pathlib import Path # stack-wrapper-webapp# stack-wrapper-static-content
# Wrappers are currently discovered from the built-in container-build data # directory. TODO(phase 2): also discover wrappers from external repos # fetched beneath STACK_REPO_BASE_DIR, and teach the base-container build to # use the wrapper's own directory rather than the built-in name convention. import json import os from pathlib import Path from stack.util import get_yaml, error_exit
# Wrappers are discovered from external repos fetched beneath the dev root # (STACK_REPO_BASE_DIR), e.g. github.com/bozemanpass/stack-wrapper-webapp, # and from the built-in container-build data directory. An external wrapper # with the same name as a built-in one takes precedence. import json import os from pathlib import Path from stack.config.util import get_dev_root_path from stack.util import get_yaml, error_exit
The file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def containerfile_path(self) -> Path:
return self.dir.joinpath(self.containerfile) def containerfile_path(self) -> Path:
return self.dir.joinpath(self.containerfile)
def build_script_path(self) -> Path:
return self.dir.joinpath("build.sh")
def is_builtin(self) -> bool:
return self.dir.is_relative_to(_builtin_container_build_dir())The file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def get_available_wrappers():
wrappers = []
builtin_container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
for manifest_path in sorted(builtin_container_build_dir.glob("*/wrapper.yml")):
wrappers.append(Wrapper().init_from_file(manifest_path))
return wrappersdef _builtin_container_build_dir() -> Path:
return Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
def get_available_wrappers():
manifest_paths = []
dev_root_path = get_dev_root_path()
if dev_root_path and dev_root_path.exists():
manifest_paths.extend(sorted(dev_root_path.rglob("wrapper.yml")))
manifest_paths.extend(sorted(_builtin_container_build_dir().glob("*/wrapper.yml")))
# External wrappers (earlier in the list) shadow built-in ones with the same name.
wrappers = []
seen_names = set()
for manifest_path in manifest_paths:
wrapper = Wrapper().init_from_file(manifest_path)
if wrapper.name not in seen_names:
seen_names.add(wrapper.name)
wrappers.append(wrapper)
return wrappersThe file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
if not build_dir:
build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
build_script_filename = build_dir.joinpath("build.sh") # 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:
build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
build_script_filename = build_dir.joinpath("build.sh")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)
base_container = wrapper_spec.base_container
log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}") base_container = wrapper_spec.base_container
log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}")
base_container_spec = ContainerSpec(
base_container,
build=str(wrapper_spec.build_script_path()) if wrapper_spec.build_script_path().exists() else None,
)The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
build_context_1 = BuildContext(
Stack(),
ContainerSpec(base_container),
container_build_dir,
container_build_env,
dev_root_path,
) build_context_1 = BuildContext(
Stack(),
base_container_spec,
container_build_dir,
container_build_env,
dev_root_path,
)The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
build_context_2 = BuildContext(
Stack(),
ContainerSpec(base_container),
container_build_dir,
container_build_env,
dev_root_path,
) build_context_2 = BuildContext(
Stack(),
base_container_spec,
container_build_dir,
container_build_env,
dev_root_path,
)The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
import click
from stack.build.build_webapp import command as build_webapp
from stack.deploy.webapp.deploy_webapp import create as deploy_webapp
from stack.deploy.webapp.run_webapp import command as run_webapp
@click.group()
@click.pass_context
def command(ctx):
"""build, run, and deploy webapps"""
pass
command.add_command(build_webapp, "build")
command.add_command(deploy_webapp, "deploy")
command.add_command(run_webapp, "run")import click
from stack.build.build_webapp import command as build_webapp
from stack.build.wrappers import get_available_wrappers
from stack.deploy.webapp.deploy_webapp import create as deploy_webapp
from stack.deploy.webapp.run_webapp import command as run_webapp
from stack.log import output_main
@click.group()
@click.pass_context
def command(ctx):
"""build, run, and deploy webapps"""
pass
@click.command()
@click.pass_context
def list_wrappers(ctx):
"""list the available container wrapper schemes"""
for wrapper in get_available_wrappers():
source = "builtin" if wrapper.is_builtin() else str(wrapper.dir)
output_main(f"{wrapper.name.ljust(16)} {wrapper.base_container.ljust(36)} {source.ljust(24)} {wrapper.description}")
command.add_command(build_webapp, "build")
command.add_command(deploy_webapp, "deploy")
command.add_command(run_webapp, "run")
command.add_command(list_wrappers, "wrappers")The file /home/david/projects/bpi/stack/src/stack/webapp/webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
cd /home/david/projects/bpi/stack/src/stack/data/container-build && cp -r bozemanpass-webapp-base /home/david/projects/bpi/stack-wrapper-webapp/webapp-base && cp -r bozemanpass-nextjs-base /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base && find /home/david/projects/bpi/stack-wrapper-webapp -type f -not -path "*/.git/*" | sort
/home/david/projects/bpi/stack-wrapper-webapp/README.md /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile.webapp /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/build.sh /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/scripts/apply-runtime-env.sh /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/scripts/build-app.sh /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/scripts/find-env.sh /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/scripts/start-serving-app.sh /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/wrapper.yml /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile.webapp /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/build.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/scripts/apply-runtime-env.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/scripts/apply-webapp-config.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/scripts/build-app.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/scripts/convert-to-runtime-env.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/scripts/start-serving-app.sh /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/wrapper.yml
wrapper: name: static-content description: Static HTML content served by nginx base-container: bozemanpass/static-content-base containerfile: Containerfile.app port: 80
FROM nginx:alpine RUN rm -rf /usr/share/nginx/html/*
FROM bozemanpass/static-content-base:stack as builder COPY . /content RUN rm -rf /content/.git /content/.github FROM bozemanpass/static-content-base:stack COPY --from=builder /content /usr/share/nginx/html
File created successfully at: /home/david/projects/bpi/stack-wrapper-static-content/wrapper.yml (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-wrapper-static-content/Containerfile (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-wrapper-static-content/Containerfile.app (file state is current in your context — no need to Read it back)
#!/usr/bin/env bash
# Build bozemanpass/static-content-base
source ${STACK_CONTAINER_BASE_DIR}/build-base.sh
# See: https://stackoverflow.com/a/246128/1701505
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
STACK_CONTAINER_BUILD_WORK_DIR=${STACK_CONTAINER_BUILD_WORK_DIR:-$SCRIPT_DIR}
STACK_CONTAINER_BUILD_CONTAINERFILE=${STACK_CONTAINER_BUILD_CONTAINERFILE:-$SCRIPT_DIR/Containerfile}
STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/static-content-base:stack}
docker build -t $STACK_CONTAINER_BUILD_TAG ${build_command_args} -f $STACK_CONTAINER_BUILD_CONTAINERFILE $STACK_CONTAINER_BUILD_WORK_DIR
rc=$?
if [ $rc -ne 0 ]; then
echo "BUILD FAILED" 1>&2
exit $rc
fi
if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/static-content-base:stack" ]; then
cat <<EOF
#################################################################
Built host container for $STACK_CONTAINER_BUILD_WORK_DIR with tag:
$STACK_CONTAINER_BUILD_TAG
To test locally run:
stack webapp run --image $STACK_CONTAINER_BUILD_TAG
EOF
fi
File created successfully at: /home/david/projects/bpi/stack-wrapper-static-content/build.sh (file state is current in your context — no need to Read it back)
# stack-wrapper-static-content A container wrapper scheme for the [stack](https://github.com/bozemanpass/stack) tool that serves static HTML content with nginx. A repository containing only static content (an `index.html` and friends) can be built into a servable container image with no containerization knowledge required: ``` $ stack fetch repo bozemanpass/stack-wrapper-static-content $ stack webapp build --wrapper static-content --source-repo ~/my-static-site ``` ## Contents - `wrapper.yml` — the wrapper manifest (see the stack tool's `docs/webapp.md`) - `Containerfile` — build for the `bozemanpass/static-content-base` base image - `Containerfile.app` — wraps the app source into a servable image (build context is the app repository) - `build.sh` — build script invoked by the stack tool
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
# stack-wrapper-webapp Container wrapper schemes for the [stack](https://github.com/bozemanpass/stack) tool that build and serve node.js webapps, without the app needing to provide its own container build: - `webapp-base/` — generic node.js webapp (React, vite, static site generators, etc.) - `nextjs-base/` — Next.js webapp, with runtime (rather than build-time) environment variable support ``` $ stack fetch repo bozemanpass/stack-wrapper-webapp $ stack webapp build --source-repo ~/my-webapp ``` The wrapper is auto-detected from the app source (a `next` dependency in `package.json` selects `nextjs`); select explicitly with `--wrapper webapp` or `--wrapper nextjs`. Each wrapper directory contains a `wrapper.yml` manifest (see the stack tool's `docs/webapp.md`), the base image `Containerfile`, the app-image `Containerfile.webapp`, the `build.sh` build script, and the runtime scripts baked into the base image.
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
# stack-wrapper-static-content A container wrapper scheme for the [stack](https://github.com/bozemanpass/stack) tool that serves static HTML content with nginx. A repository containing only static content (an `index.html` and friends) can be built into a servable container image with no containerization knowledge required: ``` $ stack fetch repo bozemanpass/stack-wrapper-static-content $ stack webapp build --wrapper static-content --source-repo ~/my-static-site ``` ## Contents - `wrapper.yml` — the wrapper manifest (see the stack tool's `docs/webapp.md`) - `Containerfile` — build for the `bozemanpass/static-content-base` base image - `Containerfile.app` — wraps the app source into a servable image (build context is the app repository) - `build.sh` — build script invoked by the stack tool
The file /home/david/projects/bpi/stack-wrapper-static-content/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
# stack-wrapper-webapp Container wrapper schemes for the [stack](https://github.com/bozemanpass/stack) tool that build and serve node.js webapps, without the app needing to provide its own container build: - `webapp-base/` — generic node.js webapp (React, vite, static site generators, etc.) - `nextjs-base/` — Next.js webapp, with runtime (rather than build-time) environment variable support ``` $ stack fetch repo bozemanpass/stack-wrapper-webapp $ stack webapp build --source-repo ~/my-webapp ``` The wrapper is auto-detected from the app source (a `next` dependency in `package.json` selects `nextjs`); select explicitly with `--wrapper webapp` or `--wrapper nextjs`. Each wrapper directory contains a `wrapper.yml` manifest (see the stack tool's `docs/webapp.md`), the base image `Containerfile`, the app-image `Containerfile.webapp`, the `build.sh` build script, and the runtime scripts baked into the base image.
The file /home/david/projects/bpi/stack-wrapper-webapp/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
chmod +x /home/david/projects/bpi/stack-wrapper-static-content/build.sh && cd /home/david/projects/bpi/stack && uv run flake8 --config tox.ini && echo LINT-OK && SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad && mkdir -p $SCRATCH/wrapper-dev-root && cp -r /home/david/projects/bpi/stack-wrapper-webapp /home/david/projects/bpi/stack-wrapper-static-content $SCRATCH/wrapper-dev-root/ && STACK_REPO_BASE_DIR=$SCRATCH/wrapper-dev-root uv run stack webapp wrappers
LINT-OK 2026-07-23 23:12:58.591761: static-content bozemanpass/static-content-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-static-content Static HTML content served by nginx 2026-07-23 23:12:58.591856: nextjs bozemanpass/nextjs-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-webapp/nextjs-base Next.js webapp with runtime environment variable support 2026-07-23 23:12:58.591890: webapp bozemanpass/webapp-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-webapp/webapp-base Generic node.js webapp (React, vite, static site generators, etc.) static-content bozemanpass/static-content-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-static-content Static HTML content served by nginx nextjs bozemanpass/nextjs-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-webapp/nextjs-base Next.js webapp with runtime environment variable support webapp bozemanpass/webapp-base /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/wrapper-dev-root/stack-wrapper-webapp/webapp-base Generic node.js webapp (React, vite, static site generators, etc.)
External discovery works — all three wrappers found, and the external node wrappers correctly shadow the in-tree ones. Now the end-to-end proof: build and serve a static site through the new wrapper.
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad && mkdir -p $SCRATCH/my-static-site && printf '<html><body><h1>STATIC_WRAPPER_TEST_MARKER</h1></body></html>\n' > $SCRATCH/my-static-site/index.html && STACK_REPO_BASE_DIR=$SCRATCH/wrapper-dev-root uv run stack webapp build --wrapper static-content --source-repo $SCRATCH/my-static-site 2>&1 | tail -5
2026-07-23 23:13:22.906577: 2026-07-23 23:13:22.906598: stack webapp run --image bozemanpass/my-static-site:stack 2026-07-23 23:13:22.906612: 2026-07-23 23:13:22.906795: webapp build complete webapp build complete
CID=$(docker run -d -p 18080:80 bozemanpass/my-static-site:stack) && sleep 2 && curl -s http://localhost:18080/ ; echo "---"; docker exec $CID ls /usr/share/nginx/html; docker rm -f $CID >/dev/null
The static-content wrapper works end to end: image built through the external wrapper directory (absolute build-script path), nginx serving the content. Now the regression gate — rerun the full webapp test (which exercises the built-in nextjs path):
./tests/webapp-test/run-webapp-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test-phase2.log 2>&1; echo "exit: $?"
Command running in background with ID: bo04k37nu. Output is being written to: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/tasks/bo04k37nu.output. You will be notified when it completes. To check interim output, use Read on that file path.
While that runs, update the docs to cover external wrapper repos and the new listing command:
By default the wrapper is auto-detected from the app source (using the manifest `detect` rules, falling back to the wrapper marked `default`). A specific wrapper can be selected with `--wrapper`, e.g. `stack webapp build --wrapper nextjs --source-repo ~/my-app`.
By default the wrapper is auto-detected from the app source (using the manifest `detect` rules, falling back to the wrapper marked `default`). A specific wrapper can be selected with `--wrapper`, e.g. `stack webapp build --wrapper nextjs --source-repo ~/my-app`. Wrappers can also live in their own repositories, e.g. [stack-wrapper-webapp](https://github.com/bozemanpass/stack-wrapper-webapp) and [stack-wrapper-static-content](https://github.com/bozemanpass/stack-wrapper-static-content). Any `wrapper.yml` found in a repository fetched beneath the repo base directory is discovered automatically, and takes precedence over a built-in wrapper with the same name: ``` $ stack fetch repo bozemanpass/stack-wrapper-static-content $ stack webapp build --wrapper static-content --source-repo ~/my-static-site ``` The available wrappers can be listed with: ``` $ stack webapp wrappers ```
The file /home/david/projects/bpi/stack/docs/webapp.md has been updated successfully. (file state is current in your context — no need to Read it back)
Phase 2 implementation is done and locally validated; the full webapp regression test is running in the background now. Status so far:
Stack tool changes (on top of Phase 1, all uncommitted in this repo):
- get_available_wrappers() now also discovers wrapper.yml manifests in repos fetched beneath the repo base dir (STACK_REPO_BASE_DIR), with external wrappers shadowing built-ins of the same name.
- process_container() honors an absolute build-script path on a ContainerSpec, and webapp build passes the wrapper's own build.sh — so base containers build correctly from external wrapper directories that don't follow the in-tree naming convention.
- New stack webapp wrappers command lists discovered wrappers with their source (builtin vs. path).
- docs/webapp.md updated for external wrapper repos and the listing command.
Wrapper repo clones populated (files written, not committed — I'll leave commits to you or do them on request):
- ~/projects/bpi/stack-wrapper-webapp: webapp-base/ and nextjs-base/ (copied verbatim from in-tree), plus a README.
- ~/projects/bpi/stack-wrapper-static-content: the new wrapper — wrapper.yml, nginx-based Containerfile, two-stage Containerfile.app (strips .git from the served content), build.sh, README.
Validated locally:
- stack webapp wrappers with a dev root containing the two repos lists all three wrappers, external paths shadowing builtins.
- End-to-end static content: built bozemanpass/my-static-site:stack from a bare index.html directory via --wrapper static-content, ran it, and nginx served the marker page correctly.
I'll report when the regression test finishes. Note the in-tree wrapper copies are deliberately still present — removing them (and deciding whether webapp build should auto-fetch stack-wrapper-webapp when no wrapper is found) is the step that can only land after you push the wrapper repos to GitHub.