Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/test-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ file listing is its own index.
| Rebuild giving a dirty checkout a `stackdev-` identity | [`tests/app-deploy/run-test.sh`](../tests/app-deploy/run-test.sh) | `deploy update content` | compose + kind per-PR; remote + remote-compose weekly |
| `webapp` wrapper (build and serve a Vite/React app) | [`tests/webapp-test/run-webapp-test.sh`](../tests/webapp-test/run-webapp-test.sh) | whole script | compose, per-PR and weekly |
| `static-content` wrapper | [`tests/static-content-test/run-static-content-test.sh`](../tests/static-content-test/run-static-content-test.sh) | whole script | compose, per-PR and weekly |
| `static-content` wrapper: HTTP basic auth, configured and unconfigured | [`tests/static-content-test/run-static-content-test.sh`](../tests/static-content-test/run-static-content-test.sh) | `AUTH-CHALLENGED`, `AUTH-SERVED`, `AUTH-EXCLUDE`, `AUTH-HTPASSWD`, `AUTH-HALF-CREDENTIAL` | compose, per-PR and weekly |

## Init and deploy

Expand All @@ -52,6 +53,7 @@ file listing is its own index.
| `manage update`: config change reaches the containers | [`tests/app-deploy/run-test.sh`](../tests/app-deploy/run-test.sh) | `deploy update config` | compose + kind per-PR; remote + remote-compose weekly |
| `manage update`: data survives the in-place update | [`tests/app-deploy/run-test.sh`](../tests/app-deploy/run-test.sh) | `deploy update storage` | compose + kind per-PR; remote + remote-compose weekly |
| `manage update`: rebuilt image content reaches the deployment | [`tests/app-deploy/run-test.sh`](../tests/app-deploy/run-test.sh) | `deploy update content` | compose + kind per-PR; remote + remote-compose weekly |
| `manage update`: authentication turned on and off after deployment | [`tests/static-content-test/run-static-content-test.sh`](../tests/static-content-test/run-static-content-test.sh) | `DEPLOY-AUTH-SERVED`, `DEPLOY-AUTH-CHALLENGED`, `DEPLOY-AUTH-REMOVED` | compose, per-PR and weekly |
| Spec-mapped volume path: pre-existing host data reaches the container | [`tests/volumes/run-test.sh`](../tests/volumes/run-test.sh) | `external data visible test`, `unmapped volume fresh test`, `volume write-back test` | compose + kind per-PR; remote weekly |
| `manage destroy`: the deployment is finished; later `manage` commands refuse its directory | [`tests/smoke-test/run-smoke-test.sh`](../tests/smoke-test/run-smoke-test.sh) | `deploy destroy` | compose, per-PR |
| `manage exec` against a running service | [`tests/database/run-backup-test.sh`](../tests/database/run-backup-test.sh) | `Replay dump test` | compose per-PR; remote weekly |
Expand Down
52 changes: 52 additions & 0 deletions docs/wrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,58 @@ $ stack webapp build --wrapper static-content --source-repo ~/my-static-site --c
build the same way. See [stack-files.md](./stack-files.md#path-vs-content-root) for how it
relates to `path`, with worked examples of each combination.

## Authentication for static content

The `static-content` wrapper can put the site it serves behind HTTP basic authentication.
Nothing is gated by default; a username and password in the container's environment turns
it on. Deployment config reaches every service of a deployment, so the composefile needs
no entry of its own:

```
$ stack init --stack my-site --output spec.yml \
--config STACK_AUTH_USER=alice --config STACK_AUTH_PASSWORD=secret
```

| Variable | Meaning |
|----------|---------|
| `STACK_AUTH_USER`, `STACK_AUTH_PASSWORD` | One credential. The password is hashed when the container starts, and the two must be set together — half a credential refuses to start rather than guessing which half was meant. |
| `STACK_AUTH_HTPASSWD` | The content of an htpasswd file, already hashed: several users, and no plaintext password in the environment. Additive with the pair above. |
| `STACK_AUTH_REALM` | The name the browser's prompt shows. Defaults to `Restricted`. |
| `STACK_AUTH_EXCLUDE` | Space-separated path prefixes served without credentials. |

The variables are read when the container starts rather than baked into the image, which
makes this a decision that can be made after deploying — the usual way round, since a site
is normally published before anyone asks for it to be private. Adding them to a running
deployment's `config.env` and running `update` gates it:

```
$ echo 'STACK_AUTH_USER=alice' >> <deployment-dir>/config.env
$ echo 'STACK_AUTH_PASSWORD=secret' >> <deployment-dir>/config.env
$ stack manage --dir <deployment-dir> update
```

`update` applies environment changes on every target (on Kubernetes that is explicitly all
it applies, along with images and secrets), and removing the variables again ungates the
site. The image is the same either way, so neither direction is a rebuild.

Two things to know before turning it on:

- **A composefile `healthcheck` must be excluded.** On Kubernetes a healthcheck becomes
the container's liveness probe, and a probe answered with a 401 restarts the pod for as
long as authentication is configured. Name its path in `STACK_AUTH_EXCLUDE`.
- **Basic authentication is only worth having over HTTPS**, since the password travels
with every request protected by nothing but base64. See [ingress.md](./ingress.md).

A password that matters belongs in `secrets:` rather than `--config`: a config value is
written to `config.env` in the clear, while a secret is resolved at up time from an
`env:`/`file:`/`exec:` reference and never lands in the deployment directory. `generate`
is no use here — someone has to know this password to type it — so give the secret a
reference. See [secrets.md](./secrets.md).

The other wrappers deliberately have no equivalent. They build applications that can
authenticate their own users, with a session and an account database, and a gate in front
of the whole site would be in the way of that rather than an alternative to it.

## Runtime environment

A service reads its configuration from the environment at startup, so deploying the same image
Expand Down
5 changes: 4 additions & 1 deletion skills/deploy-with-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ Rules that matter:
name in the composefile (`image: postgres:16`); an app without a Dockerfile can use a
wrapper — `static-content` for a static site, `webapp` for a built frontend, `nextjs`
for Next.js, `node-service` for a long-running node service (list them with
`stack webapp wrappers`).
`stack webapp wrappers`). A `static-content` site can be put behind HTTP basic
authentication with the `STACK_AUTH_USER` and `STACK_AUTH_PASSWORD` config
variables, which is a decision that can be made (and reversed) after deploying;
see https://github.com/bozemanpass/stack/blob/main/docs/wrappers.md
- Pods are the unit of deployment grouping; one composefile each. A single pod holding
all services is the right default for a small system.
- A pod entry can carry `pre_start_command` / `post_start_command` (host-side scripts,
Expand Down
50 changes: 50 additions & 0 deletions tests/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,56 @@ assert_file_not_contains () {
fi
}

# Wait until fetching $1 answers with HTTP status $2, reporting "<label>: PASSED"
# or failing the test. $4 overrides the number of 5-second attempts (default 20).
#
# The waiting is what makes this an assertion rather than a race: a container
# that has just been recreated is not serving yet, and the status a test wants to
# see is not the one it gets on the first try. Waiting for the status the change
# should produce -- rather than for the server generally, and then asserting --
# also means a change that never arrives fails here, naming the status that kept
# coming back instead of somewhere later for a reason that reads as unrelated.
wait_for_url_status () {
local url=$1
local expected=$2
local label=$3
local tries=${4:-20}
local try=0
local actual=""
while [ $try -lt $tries ]; do
try=$((try + 1))
actual=$( curl -s -o /dev/null -w '%{http_code}' "$url" ) || true
if [ "$actual" = "$expected" ]; then
echo "${label}: PASSED"
return
fi
echo "waiting for ${url} to answer ${expected} (answered ${actual})..."
sleep 5
done
fail "${label}: FAILED - ${url} answered ${actual}, expected ${expected}"
}

# Assert that fetching $1 answers with HTTP status $2, reporting "<label>: PASSED"
# or failing the test. Any further arguments are passed to curl (e.g. -u user:pw).
#
# Distinct from assert_url_not_served because a status is sometimes the whole
# point: a 401 from a server that is up and a connection refused by a container
# that never started are both "not served", and a test whose subject is
# authentication passes on the second one while proving nothing.
assert_url_status () {
local url=$1
local expected=$2
local label=$3
shift 3
local actual
actual=$( curl -s -o /dev/null -w '%{http_code}' "$@" "$url" ) || true
if [ "$actual" = "$expected" ]; then
echo "${label}: PASSED"
else
fail "${label}: FAILED - ${url} answered ${actual}, expected ${expected}"
fi
}

# Assert that $1 is not served -- fetching it must fail. Call this while the
# server is still up, or it passes for the wrong reason. A single attempt: the
# expected outcome is an immediate refusal from a server known to be running.
Expand Down
123 changes: 122 additions & 1 deletion tests/static-content-test/run-static-content-test.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
#!/usr/bin/env bash
source "$( dirname -- "${BASH_SOURCE[0]}" )/../lib/common.sh"

# Test hosting static content with the static-content wrapper
# Test hosting static content with the static-content wrapper.
#
# Three external repos: the content (stack-test-static-content), the wrapper
# (stack-wrapper-static-content) and the stack files (stack-test-stacks). The
# wrapper reaches this test by two paths of different freshness, which is worth
# knowing before debugging a failure that hits only some of the legs:
#
# - the `webapp build --wrapper` and `build containers` legs build through the
# wrapper, so they see whatever is on its main branch now;
# - the deployment legs run `prepare`, which matches the image published from
# stack-test-stacks by that repo's commit hash and pulls it. On that path the
# wrapper is not merely older, it is not consulted at all -- which is the
# point of the lock, and means a wrapper change does not reach these legs
# until stack-test-stacks has run its "Update Locks" workflow to regenerate
# its pins and republish.
#
# So a wrapper change lands here in two stages, and a run where every build leg
# passes and a deployment leg fails on behaviour the wrapper has just gained is
# waiting for that second stage rather than reporting a bug.
echo "Running stack static content test"
select_test_target "$@"
setup_test_dir static-content-test-dir
Expand All @@ -19,6 +37,12 @@ $TEST_TARGET_STACK webapp build --wrapper static-content --source-repo $STACK_RE

app_image_name="bozemanpass/stack-test-static-content:stack"

# Nothing below configures authentication, so these are also this test's "no
# credentials configured" leg: what they assert is that the image serves its
# content to anyone until someone asks for a gate. Shipping a gate that works
# only when it is configured, and quietly serves everything when it is not, is
# the failure they catch, so they are load-bearing rather than redundant with the
# authentication legs that follow.
start_container -p 3000:80 -d ${app_image_name}
sleep 3
fetch_url http://localhost:3000/ $scratch/test.index
Expand All @@ -38,6 +62,68 @@ assert_file_contains $scratch/test.index "STACK_STATIC_CONTENT_TEST_INDEX_MARKER
assert_file_contains $scratch/test.subdir "STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER" SUBDIR
assert_file_contains $scratch/test.css "font-family" CSS

# Now the same image with authentication configured. The credentials are read
# from the environment when the container starts, so this is the same image the
# assertions above ran against, with nothing rebuilt.
echo "Running static content authentication test"

auth_user=alice
# Deliberately awkward: a password reaches the container as an argument, and the
# quoting is worth exercising once.
auth_password='s3cr#t pw'

start_container -p 3000:80 -d \
-e STACK_AUTH_USER=${auth_user} \
-e STACK_AUTH_PASSWORD="${auth_password}" \
-e STACK_AUTH_REALM="Test Site" \
-e STACK_AUTH_EXCLUDE=/css/ \
${app_image_name}

# The excluded path is served without credentials, so waiting for it is both the
# wait for the server to come up and the assertion that the exclusion works.
wait_for_content http://localhost:3000/css/style.css "font-family"
echo "AUTH-EXCLUDE: PASSED"

assert_url_status http://localhost:3000/ 401 AUTH-CHALLENGED
assert_url_status http://localhost:3000/pages/about.html 401 AUTH-CHALLENGED-SUBDIR
assert_url_status http://localhost:3000/ 401 AUTH-WRONG-PASSWORD -u "${auth_user}:wrong"
assert_url_status http://localhost:3000/ 401 AUTH-UNKNOWN-USER -u "nobody:${auth_password}"

curl -s -D $scratch/test.auth-headers -o /dev/null http://localhost:3000/
assert_file_contains $scratch/test.auth-headers 'WWW-Authenticate: Basic realm="Test Site"' AUTH-REALM

# The content itself, not just the status: a gate that answers 200 and serves
# nothing would pass every assertion above.
fetch_url http://localhost:3000/ $scratch/test.auth-index --user=${auth_user} --password="${auth_password}"
assert_file_contains $scratch/test.auth-index "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" AUTH-SERVED

docker stop $CONTAINER_ID > /dev/null

# Several users, supplied already hashed. The hashes are made with the image's
# own htpasswd, which is the tool the container would have used on a plaintext
# password anyway.
bob_entry=$( docker run --rm --entrypoint htpasswd ${app_image_name} -nbB bob bob-password )
carol_entry=$( docker run --rm --entrypoint htpasswd ${app_image_name} -nbB carol carol-password )

start_container -p 3000:80 -d -e STACK_AUTH_HTPASSWD="${bob_entry}
${carol_entry}" ${app_image_name}

fetch_url http://localhost:3000/ $scratch/test.auth-htpasswd --user=bob --password=bob-password
assert_file_contains $scratch/test.auth-htpasswd "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" AUTH-HTPASSWD
assert_url_status http://localhost:3000/ 200 AUTH-HTPASSWD-SECOND-USER -u carol:carol-password
assert_url_status http://localhost:3000/ 401 AUTH-HTPASSWD-CHALLENGED

docker stop $CONTAINER_ID > /dev/null

# Half a credential must fail the container's start rather than resolve itself
# one way or the other: taking the user alone would serve the content to anyone,
# and taking the password alone would lock it behind a name nobody knows. No
# published port here -- the container is expected to exit.
if docker run --rm -e STACK_AUTH_USER=${auth_user} ${app_image_name} > /dev/null 2>&1; then
fail "AUTH-HALF-CREDENTIAL: FAILED - the container started with a user and no password"
fi
echo "AUTH-HALF-CREDENTIAL: PASSED"

# Test wrapping only a subdirectory of the source repo, with --content-root
subdir_image_name="bozemanpass/stack-test-static-content-content-root:stack"
$TEST_TARGET_STACK webapp build --wrapper static-content \
Expand Down Expand Up @@ -101,6 +187,41 @@ assert_file_contains $scratch/test.deployed "STACK_STATIC_CONTENT_TEST_INDEX_MAR
fetch_url http://localhost:80/pages/about.html $scratch/test.deployed-subdir
assert_file_contains $scratch/test.deployed-subdir "STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER" DEPLOY-SUBDIR

# Turning authentication on after the deployment exists, which is the case worth
# testing: a site is normally published before anyone decides it should be
# private. A deployment's config lives in config.env, and `update` is what
# applies a change to it -- no rebuild, and the same image is left running.
echo "Running post-deployment authentication test"

deployed_password=deployed-secret
cat >> $test_deployment_dir/config.env <<EOF
STACK_AUTH_USER=${auth_user}
STACK_AUTH_PASSWORD=${deployed_password}
EOF

$TEST_TARGET_STACK manage --dir $test_deployment_dir update

# The gate is what this waits for, not the server. An authenticated fetch would
# come up first and serve as the wait, but it answers 200 whether the gate is
# there or not -- which is what an image predating the gate looks like, and it
# passed that way once before this was written.
wait_for_url_status http://localhost:80/ 401 DEPLOY-AUTH-CHALLENGED

fetch_url http://localhost:80/ $scratch/test.deployed-auth --user=${auth_user} --password=${deployed_password}
assert_file_contains $scratch/test.deployed-auth "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" DEPLOY-AUTH-SERVED

# And off again: the same edit in reverse ungates the site, which is the half of
# the claim that a test of turning it on does not make.
grep -v '^STACK_AUTH_' $test_deployment_dir/config.env > $test_deployment_dir/config.env.ungated
mv $test_deployment_dir/config.env.ungated $test_deployment_dir/config.env

$TEST_TARGET_STACK manage --dir $test_deployment_dir update

wait_for_url_status http://localhost:80/ 200 DEPLOY-AUTH-REMOVED

fetch_url http://localhost:80/ $scratch/test.deployed-ungated
assert_file_contains $scratch/test.deployed-ungated "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" DEPLOY-AUTH-REMOVED-CONTENT

# Finally, build a stack whose container entry uses content-root in stack.yml.
$TEST_TARGET_STACK build containers --stack test-static-content-subdir

Expand Down