Skip to content

Commit 7c21a40

Browse files
6RUN0claude
andcommitted
docs(examples): rework into a capability-coverage example set with build gates
Squash of the develop-only work on the example suite and its supporting tooling: - rework examples/ into a capability-coverage set, one subdir per example - add a `make test-examples` gate (static hardening lint + live build/run/assert of every example) - fire the cron-hook task every second via supercronic's seven-field (seconds) syntax, dropping the example's and test's minute-long waits - add a self-documenting `make help` target (parses `## ` annotations; explicit `.DEFAULT_GOAL := all` keeps bare `make` building the matrix) - ignore docs/superpowers/ working notes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4dc283d commit 7c21a40

29 files changed

Lines changed: 1029 additions & 56 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# Local tooling state (not part of the image build)
22
.codegraph/
33
.omc/
4+
docs/superpowers/

Makefile

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# Build the FreeUnit + PHP image matrix from a single Dockerfile.
22
#
3+
# make help # list the available targets
34
# make # build all PHP versions
45
# make php8.3 # build one variant
56
# make latest # build the default PHP and tag it :latest
67
# make test # build the default PHP and run the smoke + entrypoint tests
78
# make test-entrypoint # run the entrypoint-library unit checks (image must exist)
9+
# make test-examples # build the default PHP and verify every examples/ example
810
# make lint # run all installed linters
911
# make scan # CVE-scan the default image (trivy/grype if installed)
1012
#
@@ -38,9 +40,24 @@ SHELL_SCRIPTS := $(shell find rootfs test -type f -name '*.sh' 2>/dev/null)
3840

3941
DEFAULT_IMAGE := $(IMAGE):$(SUITE)-php$(DEFAULT_PHP)
4042

41-
.PHONY: all latest test test-entrypoint scan lint lint-dockerfile lint-shell lint-md lint-typos $(TARGETS)
43+
.PHONY: help all latest test test-entrypoint test-examples scan lint lint-dockerfile lint-shell lint-md lint-typos $(TARGETS)
4244

43-
all: $(TARGETS)
45+
# Keep bare `make` building the whole matrix even though `help` is defined first
46+
# (a target ahead of `all` would otherwise become the default goal).
47+
.DEFAULT_GOAL := all
48+
49+
# Self-documenting help: lists every target annotated with a `## ` comment, so
50+
# the description lives on the target line (single source of truth) rather than
51+
# in a separate hand-synced list. Run `make help` for the menu.
52+
help: ## list the available targets
53+
@echo 'FreeUnit + PHP image matrix. Targets:'
54+
@echo
55+
@awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_.-]+:.*## /{printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
56+
@echo
57+
@echo 'Plus one target per PHP version: $(TARGETS)'
58+
@echo 'Override defaults on the command line, e.g. make PHP_VERSIONS=8.4 or make SUITE=bookworm.'
59+
60+
all: $(TARGETS) ## build every PHP version (the default target)
4461

4562
# The immutable tag carries FREEUNIT_RELEASE (the unique build id) so rebuilding
4663
# a different build of the same version does not overwrite an existing tag; the
@@ -56,22 +73,30 @@ $(TARGETS): php%:
5673
-t $(IMAGE):$(SUITE)-php$* \
5774
.
5875

59-
latest: php$(DEFAULT_PHP)
76+
latest: php$(DEFAULT_PHP) ## build the default PHP and tag it :latest
6077
docker tag $(DEFAULT_IMAGE) $(IMAGE):latest
6178

6279
# Build the default variant and run the integration tests against it: the
6380
# end-to-end smoke test (happy path) plus the entrypoint-library unit checks
6481
# (error/timeout paths the smoke test cannot reach).
65-
test: php$(DEFAULT_PHP)
82+
test: php$(DEFAULT_PHP) ## build the default PHP and run smoke + entrypoint tests
6683
./test/smoke.sh $(DEFAULT_IMAGE)
6784
./test/entrypoint-lib.sh $(DEFAULT_IMAGE)
6885

6986
# Run just the entrypoint-library unit checks against an already-built image.
70-
test-entrypoint:
87+
test-entrypoint: ## run entrypoint-library unit checks (image must exist)
7188
./test/entrypoint-lib.sh $(DEFAULT_IMAGE)
7289

90+
# Build the default variant and verify every examples/ example end to end: a
91+
# static hardening lint over the compose files plus a live build+run+assert of
92+
# each example. The just-built default image is passed as the base the examples
93+
# FROM, so this runs without pulling from GHCR. NOT wired into CI (it is a heavier
94+
# gate); run it locally before touching examples/. Lint only: EXAMPLES_LINT_ONLY=1.
95+
test-examples: php$(DEFAULT_PHP) ## build the default PHP and verify every examples/ example
96+
EXAMPLES_BASE_IMAGE=$(DEFAULT_IMAGE) ./test/examples.sh
97+
7398
# CVE-scan the default image. Skipped (not failed) if no scanner is installed.
74-
scan:
99+
scan: ## CVE-scan the default image (trivy/grype if installed)
75100
@if command -v trivy >/dev/null 2>&1; then \
76101
echo "trivy image $(DEFAULT_IMAGE)"; \
77102
trivy image --severity HIGH,CRITICAL --exit-code 1 $(DEFAULT_IMAGE); \
@@ -82,7 +107,7 @@ scan:
82107

83108
# Run every linter that is installed (a missing tool is skipped, not an error;
84109
# an installed tool that reports problems fails the target).
85-
lint: lint-dockerfile lint-shell lint-md lint-typos
110+
lint: lint-dockerfile lint-shell lint-md lint-typos ## run all installed linters
86111

87112
lint-dockerfile:
88113
@if command -v hadolint >/dev/null 2>&1; then \

examples/README.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,65 @@
33
Each subdirectory is a self-contained example built on the `freeunit-php` base
44
image. Pick one, `cd` into it, and `docker compose up --build`.
55

6-
- [`basic/`](basic/) — the minimal self-contained, security-hardened deployment:
7-
a small `Dockerfile` bakes an app and its Unit config onto the base image, run
8-
with the recommended capability hardening. Start here.
9-
- [`cron-hook/`](cron-hook/) — the entrypoint hook system: one image runs in two
10-
roles (the Unit web server and a long-lived `supercronic` cron runner),
11-
selected per container by the command, with no second image and no forked
12-
entrypoint.
6+
## Read in this order
7+
8+
Each example adds roughly one new idea on top of the previous, from the minimal
9+
on-ramp to the densest:
10+
11+
1. [`basic/`](basic/)**bake + harden.** A small `Dockerfile` bakes an app and
12+
its Unit config onto the base image, run with the recommended capability
13+
hardening. The minimal self-contained deployment. Start here.
14+
2. [`dev/`](dev/)**iterate without rebuilding.** Bind-mount the code so host
15+
edits are live, choose the PHP line with a build arg, and use the entrypoint
16+
env knobs (`UNIT_ENTRYPOINT_QUIET_LOGS`, `APPLICATION_DIR`/`CHOWN`).
17+
3. [`routing/`](routing/)**multiple apps and routing.** One image serves two
18+
PHP apps behind URI routing with a static-file `share`/`fallback`, plus per-app
19+
PHP config (`options.admin`/`user`/`file`) and the `environment` block.
20+
4. [`web-app/`](web-app/)**HTTPS via the applicator chain.** The entrypoint's
21+
`*.sh``*.pem``*.json` order, used to generate a cert, upload it as a TLS
22+
bundle, and declare a TLS listener — plus an HTTP→HTTPS redirect.
23+
5. [`cron-hook/`](cron-hook/)**a second launch mode.** The entrypoint hook
24+
system runs one image in two roles (Unit web server and a `supercronic` cron
25+
runner), and provisions a dedicated `worker` user for the cron role.
26+
27+
## Capability → example matrix
28+
29+
| Capability | Example |
30+
| --- | --- |
31+
| Bake app + config onto the base image | `basic` |
32+
| Capability hardening (`cap_drop`/`no-new-privileges`) | all |
33+
| App worker privilege drop (`user`/`group` in config) | `basic`, all |
34+
| Bind-mounted code (live edits) | `dev` |
35+
| PHP line selection via build arg | `dev` |
36+
| `UNIT_ENTRYPOINT_QUIET_LOGS` | `dev` |
37+
| `APPLICATION_DIR` + `APPLICATION_CHOWN` (writable volume) | `dev` |
38+
| URI routing (`match` + `pass`) | `routing` |
39+
| Static `share` with `fallback` to a front controller | `routing` |
40+
| Multiple PHP applications in one image | `routing` |
41+
| `options.admin` / `options.user` | `routing` |
42+
| Per-app `options.file` (custom `php.ini`) | `routing` |
43+
| `environment` block | `routing` |
44+
| Loaded extension set (apcu/redis/gd/intl/mbstring) | `routing` |
45+
| Applicator chain `*.sh``*.pem``*.json` | `web-app` |
46+
| First-run `*.sh` applicator | `web-app` |
47+
| `*.pem` applicator (`apply_certificates`) | `web-app` |
48+
| TLS listener | `web-app` |
49+
| `return` + `location` (HTTP→HTTPS redirect) | `web-app` |
50+
| Entrypoint hook (`handle_<cmd>`, second launch mode) | `cron-hook` |
51+
| Operator flag forwarding into a hook | `cron-hook` |
52+
| `setup_user` (provision a new user from a hook) | `cron-hook` |
53+
| `exec_as_user` (privilege drop in a hook) | `cron-hook` |
54+
55+
## Deliberately not covered
56+
57+
To keep the examples teachable rather than exhaustive, a few behaviors are left
58+
out on purpose (each is documented in the base [README](../README.md) /
59+
[`CLAUDE.md`](../CLAUDE.md)):
60+
61+
- the `apply_config` warning when more than one `*.json` is present (each example
62+
ships exactly one config);
63+
- multiple certificate bundles and the `*.pem` basename validation (`web-app`
64+
uploads a single bundle);
65+
- the `unitd-debug` launch command;
66+
- the `SUPERCRONIC_CRONTAB` env override beyond the single flag-forwarding path
67+
shown in `cron-hook`.

examples/cron-hook/README.md

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ docker compose up --build # from this directory
2020
```
2121

2222
- Web: open <http://localhost:8080/>.
23-
- Cron: watch the scheduled task fire (about once a minute) with
23+
- Cron: watch the scheduled task fire (every second) with
2424

2525
```bash
2626
docker compose logs -f cron
@@ -39,11 +39,15 @@ This example adds exactly one such file,
3939

4040
```bash
4141
handle_supercronic() {
42-
local crontab=/etc/supercronic/crontab
42+
shift # drop the command word; "$@" = operator flags
43+
local crontab="${SUPERCRONIC_CRONTAB:-/etc/supercronic/crontab}"
44+
local -a default_opts=() # baked-in defaults (array: space-safe)
4345
run_entrypoint_scripts # reuse a public core-library routine
44-
log_notice "starting supercronic cron runner ($crontab)"
45-
exec_as_user "$APPLICATION_USER" "$APPLICATION_GROUP" \
46-
supercronic "$crontab" # drop root -> app user, then exec
46+
local cron_user="worker"
47+
setup_user "$cron_user" 1500 "$cron_user" 1500 # provision a dedicated user
48+
log_notice "starting supercronic cron runner ($crontab) as $cron_user"
49+
exec_as_user "$cron_user" "$cron_user" \
50+
supercronic "${default_opts[@]}" "$@" "$crontab" # drop root -> worker, then exec
4751
}
4852
```
4953

@@ -53,6 +57,16 @@ Starting the container with `command: ["supercronic"]` (see
5357
(`run_entrypoint_scripts`, `exec_as_user`, `log_notice`) and **execs** the cron
5458
runner, which then becomes the container's main process.
5559

60+
The hook is written as a reusable template rather than a one-off. Because the
61+
dispatcher invokes `handle_<cmd> "$@"` with the full argv, `$1` is always the
62+
command word: `shift` it off and `"$@"` holds exactly the flags the operator
63+
appended. So `command: ["supercronic", "-debug", "-split-logs"]` (or
64+
`docker run IMG supercronic -debug`) forwards those flags straight to the
65+
runner. Defaults live in a `default_opts` array (space-safe, and operator flags
66+
that follow can override them), the crontab path is an env-overridable
67+
`SUPERCRONIC_CRONTAB`, and the positional crontab stays last where supercronic
68+
expects it — patterns that carry over to any companion command.
69+
5670
The contract the hook follows (enforced by the entrypoint):
5771

5872
- The file only **defines** `handle_*` functions — no top-level side effects (it
@@ -62,6 +76,25 @@ The contract the hook follows (enforced by the entrypoint):
6276
- It does not shadow the core library's function names or the
6377
`UNIT_*` / `ENTRYPOINT_*` / `APPLICATION_*` variables.
6478

79+
## A dedicated user for the cron role
80+
81+
Rather than reuse the base `unit` user, the hook provisions its own `worker`
82+
user (uid/gid 1500) with the public `setup_user` routine and drops to it — so the
83+
cron jobs run under their own least-privilege identity. Two details worth noting:
84+
85+
- **A new name is required for a custom uid.** `setup_user` keeps the existing
86+
id of a user that already exists, and the base `unit` user is created by the
87+
core package's postinst with a system-range UID. So passing `1500` to a
88+
re-provisioned `unit` would be ignored (and logged). Using a fresh name
89+
(`worker`) is what makes the custom uid take effect.
90+
- **The uid/gid must be free.** `setup_user` dies with an actionable message if
91+
`1500` is already taken — pick another free id or pre-create the user.
92+
93+
`worker` is created with no app directory and is not granted write access to
94+
anything; the demo task only reads `/www` (root-owned, world-readable) and prints
95+
its identity. `cron-task.php` prints `uid=…(…) gid=…(…)`, so the logs show the
96+
jobs running as `uid=1500(worker)`, not as `unit`.
97+
6598
## What it shows
6699

67100
- [`Dockerfile`](Dockerfile)`FROM` the published base, install a pinned +
@@ -72,11 +105,23 @@ The contract the hook follows (enforced by the entrypoint):
72105
uses the default command; `cron` overrides it with `supercronic`. Both run
73106
under the same hardening (`cap_drop: [ALL]`, `cap_add: [SETUID, SETGID]`,
74107
`no-new-privileges`) — even the cron role keeps `SETUID`/`SETGID` because it
75-
drops to the app user itself via `setpriv` (`exec_as_user`), just as the Unit
76-
master does for its workers.
108+
drops to the `worker` user itself via `setpriv` (`exec_as_user`), just as the
109+
Unit master does for its workers.
77110
- The cron role never starts Unit: the hook execs `supercronic` before the
78111
entrypoint reaches its first-run/`unitd` path, so `config.json` is simply
79-
unused there.
112+
unused there. Because nothing listens on the control socket, the cron service
113+
also **disables the image `HEALTHCHECK`** (which probes that socket) — otherwise
114+
the container would report unhealthy forever.
115+
116+
## Verify
117+
118+
```bash
119+
docker compose up --build -d
120+
curl -s http://localhost:8080/ | grep 'web role' # web role serves
121+
sleep 3 # the crontab fires every second
122+
docker compose logs cron | grep 'uid=1500(worker)' # cron runs as worker, not unit
123+
docker compose down -v
124+
```
80125

81126
## Why a hook instead of a second image
82127

examples/cron-hook/crontab

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
# supercronic crontab: standard five-field cron syntax. Runs the shared task
2-
# once a minute; supercronic logs each run to stdout, so `docker compose logs -f
3-
# cron` shows it firing (allow up to a minute for the first run).
1+
# supercronic crontab. Unlike classic cron(5), supercronic's parser accepts an
2+
# optional leading SECONDS field AND a trailing YEAR field, so a full seven-field
3+
# line schedules sub-minute jobs. This one runs the shared task every second;
4+
# supercronic logs each run to stdout, so `docker compose logs -f cron` shows it
5+
# firing within a second (no minute wait).
46
#
5-
# field order: minute hour day-of-month month day-of-week command
7+
# seven-field order: second minute hour day-of-month month day-of-week year
8+
# (the seconds field is ONLY honoured with all seven fields present -- a
9+
# six-field line is read as the classic five fields plus year, not seconds, so
10+
# it still fires only once a minute; drop to five fields for plain cron(5))
611
#
7-
* * * * * php /www/cron-task.php
12+
* * * * * * * php /www/cron-task.php

examples/cron-hook/docker-compose.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,17 @@ services:
3939
# handle_supercronic hook, which execs the cron runner instead of Unit. No
4040
# ports and no Unit config are needed -- this container never starts Unit.
4141
command: ["supercronic"]
42+
# The cron role never starts Unit, so the image's HEALTHCHECK (which probes
43+
# the Unit control socket) would mark this container unhealthy forever.
44+
# Disable it here; supercronic is the liveness signal for this role.
45+
healthcheck:
46+
disable: true
4247
cap_drop:
4348
- ALL
4449
cap_add:
45-
# The hook drops root -> the app user via setpriv (exec_as_user), which
46-
# needs the same two capabilities the Unit master uses in the web role.
50+
# The hook provisions a dedicated `worker` user and drops root -> worker via
51+
# setpriv (exec_as_user), which needs the same two capabilities the Unit
52+
# master uses to drop its workers in the web role.
4753
- SETUID
4854
- SETGID
4955
security_opt:

0 commit comments

Comments
 (0)