diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 43b2110..914aaca 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -17,6 +17,8 @@ jobs: dir: webapp-base - image: bozemanpass/nextjs-base dir: nextjs-base + - image: bozemanpass/node-service-base + dir: node-service-base steps: - name: Checkout uses: actions/checkout@v5 diff --git a/README.md b/README.md index a431f44..28e911f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 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 +that build and run node.js applications, 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 +- `node-service-base/` — long-running node.js service (Express, Fastify, etc.) ``` $ stack fetch repo bozemanpass/stack-wrapper-webapp @@ -14,8 +15,52 @@ $ 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`. +selects `nextjs`, an `express` dependency selects `node-service`); select explicitly with +`--wrapper webapp`, `--wrapper nextjs` or `--wrapper node-service`. Each wrapper directory contains a `wrapper.yml` manifest (see the stack tool's -`docs/wrappers.md`), the base image `Containerfile`, the app-image `Containerfile.webapp`, -the `build.sh` build script, and the runtime scripts baked into the base image. +`docs/wrappers.md`), the base image `Containerfile`, the app-image containerfile named by +the manifest, the `build.sh` build script, and the runtime scripts baked into the base +image. + +## Webapps vs services + +The first two wrappers produce *static content*: the app is built, its output directory +(`dist`, `build`, `.next`) becomes the image payload, and a web server in the base image +serves it. Because the result runs in a browser, which has no environment of its own, +build-time placeholders are rewritten at container start so one image can be deployed to +many environments. + +`node-service` is different in kind. The application process *is* the server, so the +build keeps `package.json` and the installed `node_modules` alongside any compiled +output, and the container start command runs the app. Nothing is substituted at startup: +a node process reads `process.env` directly, so ordinary container environment variables +already do the job. + +### node-service specifics + +The build runs the package manager's install (`npm ci` when a `package-lock.json` is +present), then `npm run build` **only if** `package.json` declares a `build` script — a +plain JavaScript service with no compile step needs no configuration. `devDependencies` +are pruned afterwards, so a TypeScript service gets `tsc` at build time but ships without +it. + +At start, the wrapper runs `npm start` if the package declares one, otherwise `node` +against the package's `main` entry point. + +`PORT` is set to the wrapper's port (3000), which most node servers already honor. +Applications reading something else can be pointed at it explicitly. + +| Variable | Effect | +|----------|--------| +| `STACK_START_COMMAND` | Run this instead of `npm start` / `main` | +| `STACK_LISTEN_PORT` | Port to serve on; exported as `PORT` (default 3000) | +| `STACK_BUILD_TOOL` | Force `npm` / `yarn` / `pnpm` / `bun` instead of detecting from the lockfile | +| `STACK_BUILD_TOOL_INSTALL_SUBCOMMAND` | Override the install command | +| `STACK_BUILD_TOOL_BUILD_SUBCOMMAND` | Override the build command | +| `STACK_SKIP_PRUNE` | Set `true` to keep `devDependencies` in the final image | +| `STACK_SERVICE_DIR` | Where the app lives in the image (default `/app`) | + +A service needing something the above cannot express can provide its own +`service-build.sh` in the source root, which the wrapper runs in place of the whole +install/build/prune sequence. diff --git a/node-service-base/Containerfile b/node-service-base/Containerfile new file mode 100644 index 0000000..cce1b00 --- /dev/null +++ b/node-service-base/Containerfile @@ -0,0 +1,46 @@ +# Base image for the 'node-service' wrapper: a long-running node.js service. +# +# Unlike webapp-base, nothing here serves static files -- the application process +# itself is the server, so the base carries only a node runtime, the package +# managers needed to install and build, and the wrapper's scripts. +ARG VARIANT=22-bookworm-slim +FROM node:${VARIANT} + +ARG USERNAME=node +ARG NPM_GLOBAL=/usr/local/share/npm-global + +# Add NPM global to PATH. +ENV PATH=${NPM_GLOBAL}/bin:${PATH} +# Prevents npm from printing version warnings +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +RUN \ + # Configure global npm install location, use group to adapt to UID/GID changes + if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \ + && usermod -a -G npm ${USERNAME} \ + && umask 0002 \ + && mkdir -p ${NPM_GLOBAL} \ + && touch /usr/local/etc/npmrc \ + && chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \ + && chmod g+s ${NPM_GLOBAL} \ + && npm config -g set prefix ${NPM_GLOBAL} \ + && su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \ + # Install pnpm + && su ${USERNAME} -c "umask 0002 && npm install -g pnpm" \ + # Install bun + && su ${USERNAME} -c "umask 0002 && npm install -g bun@1.1.x" \ + && npm cache clean --force > /dev/null 2>&1 + +# jq is used to inspect package.json; procps for signal handling/debugging. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends jq procps \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts /scripts + +# The port the service is expected to listen on. Apps that read PORT (the node +# convention) need no change; others can be pointed at STACK_LISTEN_PORT. +ENV PORT=3000 +EXPOSE 3000 + +ENTRYPOINT ["/scripts/start-service.sh"] diff --git a/node-service-base/Containerfile.service b/node-service-base/Containerfile.service new file mode 100644 index 0000000..1b52683 --- /dev/null +++ b/node-service-base/Containerfile.service @@ -0,0 +1,17 @@ +# Wraps a node.js service's source into a runnable image. +# +# Two stages so that the build context's cruft (.git, CI config) and any +# devDependencies left behind by a partial prune do not reach the final image. +FROM bozemanpass/node-service-base:stack AS builder + +ARG STACK_BUILD_TOOL +ARG STACK_SKIP_PRUNE + +WORKDIR /app +COPY . . +RUN rm -rf node_modules .git .github +RUN /scripts/build-app.sh /app + +FROM bozemanpass/node-service-base:stack +WORKDIR /app +COPY --from=builder /app /app diff --git a/node-service-base/build.sh b/node-service-base/build.sh new file mode 100755 index 0000000..0dc91e4 --- /dev/null +++ b/node-service-base/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Build bozemanpass/node-service-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/node-service-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/node-service-base:stack" ]; then + cat < /dev/null && pwd ) + +if [ -n "$STACK_SCRIPT_DEBUG" ]; then + set -x +fi + +STACK_BUILD_TOOL="${STACK_BUILD_TOOL}" + +WORK_DIR="${1:-/app}" + +cd "${WORK_DIR}" || exit 1 + +# An app that needs something unusual can supply its own build script. +if [ -f "${WORK_DIR}/service-build.sh" ]; then + echo "Building service with ${WORK_DIR}/service-build.sh ..." + ./service-build.sh || exit 1 + exit 0 +fi + +if [ ! -f "${WORK_DIR}/package.json" ]; then + echo "ERROR: no package.json in ${WORK_DIR} -- nothing to build." 1>&2 + echo " A node service must be an npm package." 1>&2 + exit 1 +fi + +if [ -z "$STACK_BUILD_TOOL" ]; then + if [ -f "pnpm-lock.yaml" ]; then + STACK_BUILD_TOOL=pnpm + elif [ -f "yarn.lock" ]; then + STACK_BUILD_TOOL=yarn + elif [ -f "bun.lockb" ]; then + STACK_BUILD_TOOL=bun + else + STACK_BUILD_TOOL=npm + fi +fi +echo "Building package.json based service with ${STACK_BUILD_TOOL} ..." + +# Install *everything* -- a TypeScript service needs its devDependencies (tsc and +# friends) to build. They are pruned again further down. +if [ -n "${STACK_BUILD_TOOL_INSTALL_SUBCOMMAND}" ]; then + INSTALL_CMD="${STACK_BUILD_TOOL_INSTALL_SUBCOMMAND}" +elif [ "$STACK_BUILD_TOOL" == "npm" ] && [ -f "package-lock.json" ]; then + # 'ci' is reproducible, and errors out when the lockfile is stale rather than + # silently resolving something else. + INSTALL_CMD="ci" +else + INSTALL_CMD="install" +fi + +time $STACK_BUILD_TOOL $INSTALL_CMD || exit 1 + +# Only build if there is something to build; plain JS services have no build step. +STACK_BUILD_TOOL_BUILD_SUBCOMMAND="${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}" +if [ -z "${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}" ]; then + if jq -e '.scripts.build' package.json >/dev/null 2>&1; then + if [ "$STACK_BUILD_TOOL" == "npm" ]; then + STACK_BUILD_TOOL_BUILD_SUBCOMMAND="run build" + else + STACK_BUILD_TOOL_BUILD_SUBCOMMAND="build" + fi + else + echo "No 'build' script in package.json -- skipping build step." + fi +fi + +if [ -n "${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}" ]; then + time $STACK_BUILD_TOOL $STACK_BUILD_TOOL_BUILD_SUBCOMMAND || exit 1 +fi + +# Drop devDependencies now that the build is done. Set STACK_SKIP_PRUNE=true if the +# app resolves something at runtime that it declares as a devDependency. +if [ "true" != "${STACK_SKIP_PRUNE,,}" ]; then + case "$STACK_BUILD_TOOL" in + npm) + echo "Pruning devDependencies ..." + npm prune --omit=dev || exit 1 + ;; + pnpm) + echo "Pruning devDependencies ..." + pnpm prune --prod || exit 1 + ;; + *) + echo "Not pruning devDependencies: no supported prune for ${STACK_BUILD_TOOL}." + ;; + esac +fi + +exit 0 diff --git a/node-service-base/scripts/start-service.sh b/node-service-base/scripts/start-service.sh new file mode 100755 index 0000000..8e5c7fd --- /dev/null +++ b/node-service-base/scripts/start-service.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Run the wrapped node.js service. +# +# No runtime environment substitution happens here: unlike a browser bundle, a node +# service reads process.env directly at startup, so ordinary container environment +# variables just work. +if [ -n "$STACK_SCRIPT_DEBUG" ]; then + set -x +fi + +STACK_SERVICE_DIR="${STACK_SERVICE_DIR:-/app}" +STACK_LISTEN_PORT="${STACK_LISTEN_PORT:-${PORT:-3000}}" + +# Export both spellings: PORT is the node convention, STACK_LISTEN_PORT is the stack one. +export PORT="${STACK_LISTEN_PORT}" +export STACK_LISTEN_PORT + +cd "${STACK_SERVICE_DIR}" || exit 1 + +if [ -f ".env" ]; then + set -a + source .env + set +a +fi + +STACK_BUILD_TOOL="${STACK_BUILD_TOOL}" +if [ -z "$STACK_BUILD_TOOL" ]; then + if [ -f "pnpm-lock.yaml" ]; then + STACK_BUILD_TOOL=pnpm + elif [ -f "yarn.lock" ]; then + STACK_BUILD_TOOL=yarn + elif [ -f "bun.lockb" ]; then + STACK_BUILD_TOOL=bun + else + STACK_BUILD_TOOL=npm + fi +fi + +# An explicit command wins; otherwise prefer the package's own start script, and +# fall back to its declared entry point. +if [ -n "${STACK_START_COMMAND}" ]; then + echo "Starting service on port ${PORT}: ${STACK_START_COMMAND}" + # Via bash -c so that quoting in the command string is honored rather than being + # flattened by word splitting. bash execs a simple command in place, so the + # service still receives signals directly. + exec bash -c "${STACK_START_COMMAND}" +elif jq -e '.scripts.start' package.json >/dev/null 2>&1; then + echo "Starting service on port ${PORT}: ${STACK_BUILD_TOOL} start" + exec $STACK_BUILD_TOOL start +else + MAIN=$(jq -r '.main // empty' package.json 2>/dev/null) + if [ -n "${MAIN}" ] && [ -f "${MAIN}" ]; then + echo "Starting service on port ${PORT}: node ${MAIN}" + exec node "${MAIN}" + fi + echo "ERROR: don't know how to start this service." 1>&2 + echo " Add a 'start' script to package.json, or set STACK_START_COMMAND." 1>&2 + exit 1 +fi diff --git a/node-service-base/wrapper.yml b/node-service-base/wrapper.yml new file mode 100644 index 0000000..9af407e --- /dev/null +++ b/node-service-base/wrapper.yml @@ -0,0 +1,13 @@ +wrapper: + # Short name, used to select the wrapper. + name: node-service + description: Long-running node.js service (Express, Fastify, etc.) + base-container: bozemanpass/node-service-base + containerfile: Containerfile.service + # Node's conventional port. The base image exports PORT (and STACK_LISTEN_PORT) + # so an app that honors either binds here without any app-side change. + port: 3000 + # An app that depends on express is a service, not a webapp. Apps that use a + # different framework should name the wrapper explicitly. + detect: + package-json-dependency: express