Replace Python development tooling with Make and containerized shell

This commit is contained in:
Stephan D committed 2026-09-12 23:14:34 +02:00
1 parent 23f7b78c3b
commit 1b8800ee73
27 files changed
+620 -627

No files matched your search

-39
View File
@@ -1,39 +0,0 @@
"""Build per-service Vault Agent configs (no secret values in configs)."""
import json
from pathlib import Path
BASE = (Path(__file__).parent / 'vault' / 'agent.hcl').read_text()
def kv(path, field):
return '{{ with secret "kv/data/' + path + '" }}{{ .Data.data.' + field + ' }}{{ end }}'
def config(service):
env = {}
if service == 'nats':
env['NATS_TOKEN'] = kv('nats', 'token')
else:
env['TLS_CA'] = kv('tls', 'ca')
if service == 'bff':
env['SXCHNGE_JWT_SECRET'] = kv('bff', 'jwt')
env['SXCHNGE_NOTIFICATIONS_TOKEN'] = kv('caller', 'token')
else:
env['SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN'] = kv('caller', 'token')
env['SXCHNGE_NATS_URL'] = ('{{ with secret "kv/data/nats" }}'
'nats://{{ .Data.data.token }}@nats:4222{{ end }}')
env['TLS_CERT'] = kv('notifications-tls', 'certificate')
env['TLS_KEY'] = kv('notifications-tls', 'private_key')
env['EMAIL_API_KEY'] = kv('email', 'api_key')
# JSON is valid YAML and escapes arbitrary provider configuration.
env['EMAIL_CONFIG'] = ('{{ with secret "kv/data/email" }}'
'email:\n driver: {{ .Data.data.driver | toJSON }}\n'
' settings:\n api_key_env: EMAIL_API_KEY\n'
' sender_address: {{ .Data.data.sender_address | toJSON }}\n'
' sender_name: {{ .Data.data.sender_name | toJSON }}\n{{ end }}')
result = BASE
for name, template in env.items():
result += '\nenv_template ' + json.dumps(name) + ' {\n contents = ' + json.dumps(template) + '\n error_on_missing_key = true\n}\n'
command = '/usr/local/bin/start-nats' if service == 'nats' else '/usr/local/bin/start-service'
result += '\nexec {\n command = [' + json.dumps(command) + ']\n restart_on_secret_changes = "always"\n restart_stop_signal = "SIGTERM"\n}\n'
return result
-105
View File
@@ -1,105 +0,0 @@
"""Idempotent provisioning for the isolated local Compose project."""
import json
import os
import subprocess
import time
from agents import config
from vault import STATE, write
ROOT = STATE.parents[1]
COMPOSE = ['docker', 'compose', '--env-file', str(STATE / 'images.env')]
def compose(*args, capture=False, input=None):
result = subprocess.run(COMPOSE + list(args), cwd=ROOT, input=input, text=True,
capture_output=capture)
if result.returncode:
# Bootstrap stdin and child errors may contain credentials. Never echo them.
raise RuntimeError('Compose command failed: ' + ' '.join(args[:3]))
return result.stdout if capture else None
def mongo(script, password):
auth = 'const admin = db.getSiblingDB("admin"); admin.auth("local-admin", ' + json.dumps(password) + ');\n'
return compose('exec', '-T', 'mongo', 'mongosh', '--quiet', '--file', '/dev/stdin', capture=True, input=auth + script)
def provision_mongo(vault):
secret = vault.ensure('bootstrap/mongo', lambda: {'password': vault.random(), 'keyfile': vault.random(384), 'manager_password': vault.random()})
write(STATE / 'mongo' / 'password', secret['password'])
write(STATE / 'mongo' / 'keyfile', secret['keyfile'])
compose('up', '-d', 'mongo')
for attempt in range(90):
try:
mongo('try { rs.status(); } catch(e) { if(e.code === 94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e; }', secret['password'])
if 'true' in mongo('print(db.hello().isWritablePrimary)', secret['password']):
break
except RuntimeError:
pass
time.sleep(2)
else:
raise RuntimeError('Mongo replica set did not become primary')
if vault.call('database/config/mongo', missing=True) is None:
mongo('if (!admin.getUser("vault-manager")) admin.createUser({user:"vault-manager",pwd:' + json.dumps(secret['manager_password']) + ',roles:[{role:"userAdminAnyDatabase",db:"admin"}]});', secret['password'])
vault.call('database/config/mongo', {'plugin_name': 'mongodb-database-plugin',
'allowed_roles': ['bff', 'notifications'],
'connection_url': 'mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0',
'username': 'vault-manager', 'password': secret['manager_password']})
vault.call('database/rotate-root/mongo', {})
for role, database in [('bff', 'sxchnge'), ('notifications', 'sxchnge_notifications')]:
vault.call('database/roles/' + role, {'db_name': 'mongo',
'creation_statements': json.dumps({'db': 'admin', 'roles': [{'role': 'readWrite', 'db': database}]}),
'default_ttl': '1h', 'max_ttl': '24h'})
# Public browser client: an identifier, never a client secret in the web bundle.
mongo('db.getSiblingDB("sxchnge").clients.updateOne({clientId:"sxchnge-local-web"}, {$setOnInsert: {clientName:"Local browser",allowedCIDRs:["0.0.0.0/0","::/0"],allowedScopes:["account"],redirectURIs:["https://localhost:5126"],grantTypes:["password","refresh_token"],tokenEndpointAuthMethod:"none",isRevoked:false}}, {upsert:true});', secret['password'])
def provision_agents(vault):
paths = {'bff': ['bff', 'caller', 'tls'],
'notifications': ['caller', 'tls', 'notifications-tls', 'nats', 'email'],
'nats': ['nats']}
for service, secrets in paths.items():
directory = STATE / 'agents' / service
directory.mkdir(parents=True, exist_ok=True, mode=0o755)
os.chmod(directory, 0o755)
policy = ''.join('path "kv/data/' + p + '" { capabilities = ["read"] }\n' for p in secrets)
if service != 'nats':
policy += 'path "database/creds/' + service + '" { capabilities = ["read"] }\n'
vault.call('sys/policies/acl/' + service, {'policy': policy})
vault.call('auth/approle/role/' + service, {'token_policies': [service],
'token_period': '1h', 'secret_id_ttl': '0', 'secret_id_num_uses': 0})
role_id = vault.call('auth/approle/role/' + service + '/role-id')['data']['role_id']
write(directory / 'role-id', role_id, 0o444)
if not (directory / 'secret-id').exists():
secret_id = vault.call('auth/approle/role/' + service + '/secret-id', {})['data']['secret_id']
write(directory / 'secret-id', secret_id, 0o444)
write(directory / 'config.hcl', config(service), 0o444)
def up(vault):
if not (STATE / 'images.env').exists():
raise RuntimeError('Run make build before make up')
(STATE / 'vault').mkdir(parents=True, exist_ok=True, mode=0o755)
compose('up', '-d', 'vault')
vault.initialize()
vault.mounts()
vault.ensure('nats', lambda: {'token': vault.random()})
vault.ensure('caller', lambda: {'token': vault.random()})
vault.ensure('bff', lambda: {'jwt': vault.random()})
vault.ensure('email', lambda: {'driver': 'resend', 'api_key': '',
'sender_address': 'notifications@localhost.test', 'sender_name': 'SXCHNGE local'})
vault.certificates()
provision_mongo(vault)
provision_agents(vault)
compose('up', '-d', '--wait', '--wait-timeout', '120', 'nats')
email = vault.kv('email')
key, sender = email.get('api_key'), email.get('sender_address')
if (not isinstance(key, str) or not key.strip()
or not isinstance(sender, str) or not sender.strip()
or sender == 'notifications@localhost.test'):
raise RuntimeError('Vault kv/email needs a Resend api_key and verified sender_address. Run python3 dev/local.py email, then make up.')
compose('up', '-d', '--wait', '--wait-timeout', '180')
compose('exec', '-T', 'frontend', 'nginx', '-s', 'reload', capture=True)
print('Local stack ready: https://localhost:5126; Vault: http://127.0.0.1:18200')
print('Local CA: .local/dev/ca.pem. Email driver: ' + vault.kv('email')['driver'])
+143
View File
@@ -0,0 +1,143 @@
initialize() {
attempt=0
until initialized=$(vault sys/init | jq -er '.initialized|tostring'); do
attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Vault did not start'; sleep 1
done
if [ "$initialized" = false ]; then
[ ! -e "$STATE/admin/init.json" ] || fail 'Vault data missing but bootstrap material exists; restore matching volume or reset both'
printf '{"secret_shares":1,"secret_threshold":1}' | vault sys/init POST | write "$STATE/admin/init.json"
fi
existing
jq -r '.keys_base64[0]' "$STATE/admin/init.json" | write "$STATE/vault/unseal" 444
attempt=0
until vault sys/seal-status | jq -e '.sealed==false' >/dev/null; do
attempt=$((attempt+1)); [ "$attempt" -lt 60 ] || fail 'Vault did not unseal'; sleep 1
done
}
mounts() {
vault sys/mounts > "$WORK/mounts"
for name in kv database pki; do
if ! jq -e --arg key "$name/" 'has($key)' < "$WORK/mounts" >/dev/null; then
if ! jq -e --arg key "$name/" '.data|has($key)' < "$WORK/mounts" >/dev/null; then
jq -n --arg name "$name" '{type:$name} + (if $name=="kv" then {options:{version:"2"}} else {} end)' | vault "sys/mounts/$name" POST >/dev/null
fi
fi
done
if ! vault sys/auth | jq -e '.data|has("approle/")' >/dev/null; then
printf '{"type":"approle"}' | vault sys/auth/approle POST >/dev/null
fi
}
certificates() {
printf '{"max_lease_ttl":"87600h"}' | vault sys/mounts/pki/tune POST >/dev/null
ca=$(kv tls)
if [ "$ca" = null ]; then
printf '{"common_name":"SXCHNGE local development CA","ttl":"87600h"}' | vault pki/root/generate/internal POST | jq '{ca:.data.certificate}' | put tls
fi
printf '{"allowed_domains":["localhost","notifications"],"allow_bare_domains":true,"allow_ip_sans":true,"max_ttl":"720h"}' | vault pki/roles/local POST >/dev/null
for name in notifications-tls frontend-tls; do
host=notifications; [ "$name" != frontend-tls ] || host=localhost
kv "$name" > "$WORK/cert"
if ! jq -e '.!=null and .expiration>(now+604800)' "$WORK/cert" >/dev/null; then
jq -n --arg host "$host" '{common_name:$host,ip_sans:"127.0.0.1",ttl:"720h"}' | vault pki/issue/local POST | jq '.data' > "$WORK/cert"
put "$name" < "$WORK/cert"
fi
if [ "$name" = frontend-tls ]; then
jq -r '.certificate' "$WORK/cert" | write "$STATE/frontend/server.crt"
jq -r '.private_key' "$WORK/cert" | write "$STATE/frontend/server.key"
fi
done
kv tls | jq -r '.ca' | write "$STATE/ca.pem" 644
}
provision_mongo() {
if [ "$(kv bootstrap/mongo)" = null ]; then
random > "$WORK/password"; random 384 > "$WORK/keyfile"; random > "$WORK/manager"
jq -n --rawfile password "$WORK/password" --rawfile keyfile "$WORK/keyfile" --rawfile manager "$WORK/manager" '{password:($password|rtrimstr("\n")),keyfile:($keyfile|rtrimstr("\n")),manager_password:($manager|rtrimstr("\n"))}' | put bootstrap/mongo
fi
kv bootstrap/mongo > "$WORK/mongo-secret"
jq -r '.password' "$WORK/mongo-secret" | write "$STATE/mongo/password"
jq -r '.keyfile' "$WORK/mongo-secret" | write "$STATE/mongo/keyfile"
compose up -d mongo
admin_auth > "$WORK/admin.js"
attempt=0
while :; do
if { cat "$WORK/admin.js"; printf 'try {rs.status()} catch(e) {if(e.code===94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e;} print(db.hello().isWritablePrimary);'; } | mongo > "$WORK/primary" && grep -q true "$WORK/primary"; then break; fi
attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Mongo replica set did not become primary'; sleep 2
done
if [ "$(vault database/config/mongo GET yes)" = null ]; then
{ cat "$WORK/admin.js"; jq -r '"if(!admin.getUser(\"vault-manager\")) admin.createUser({user:\"vault-manager\",pwd:"+(.manager_password|tojson)+",roles:[{role:\"userAdminAnyDatabase\",db:\"admin\"}]});"' "$WORK/mongo-secret"; } | mongo >/dev/null || fail 'Mongo manager provisioning failed'
jq '{plugin_name:"mongodb-database-plugin",allowed_roles:["bff","notifications"],connection_url:"mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0",username:"vault-manager",password:.manager_password}' "$WORK/mongo-secret" | vault database/config/mongo POST >/dev/null
printf '{}' | vault database/rotate-root/mongo POST >/dev/null
fi
for role in bff notifications; do
database=sxchnge; [ "$role" != notifications ] || database=sxchnge_notifications
jq -n --arg database "$database" '{db_name:"mongo",creation_statements:({db:"admin",roles:[{role:"readWrite",db:$database}]}|tojson),default_ttl:"1h",max_ttl:"24h"}' | vault "database/roles/$role" POST >/dev/null
done
{ cat "$WORK/admin.js"; cat dev/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
}
provision_agents() {
for service in bff notifications nats; do
directory="$STATE/agents/$service"
mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in
bff) paths='bff caller tls';;
notifications) paths='caller tls notifications-tls nats email';;
nats) paths=nats;;
esac
: > "$WORK/policy"
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
if [ ! -f "$directory/secret-id" ]; then
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
fi
cat "dev/vault/$service.hcl" | write "$directory/config.hcl" 444
done
}
up() {
[ -f "$STATE/images.env" ] || fail 'Run make build before make up'
exec 9> "$STATE/bootstrap.lock"
flock -n 9 || fail 'Another local bootstrap is running'
mkdir -p "$STATE/vault"; chmod 755 "$STATE/vault"
compose up -d vault
# On first bootstrap Compose creates the network after this tool container starts.
if ! docker inspect "$HOSTNAME" --format '{{json .NetworkSettings.Networks}}' | jq -e 'has("sxchnge-local_default")' >/dev/null; then
docker network connect sxchnge-local_default "$HOSTNAME"
fi
initialize
mounts
for path in nats caller bff; do
if [ "$(kv "$path")" = null ]; then
field=token; [ "$path" != bff ] || field=jwt
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
fi
done
printf '{"driver":"resend","api_key":"","sender_address":"notifications@localhost.test","sender_name":"SXCHNGE local"}' | ensure email
certificates
provision_mongo
provision_agents
compose up -d --wait --wait-timeout 120 nats
kv email | email_valid || fail 'Vault kv/email needs a Resend api_key and verified sender_address. Run make email, then make up.'
compose up -d --wait --wait-timeout 180
compose exec -T frontend nginx -s reload
echo 'Local stack ready: https://localhost:5126; Vault: http://127.0.0.1:18200'
}
email() {
existing
printf 'Verified Resend sender address: ' >&2
IFS= read -r address
printf 'Resend API key (hidden): ' >&2
tty_state=$(stty -g)
trap 'stty "$tty_state"; cleanup' EXIT
stty -echo
IFS= read -r key
stty "$tty_state"
printf '\n' >&2
[ -n "$key" ] || fail 'A key is required'
case "$address" in *@*) ;; *) fail 'A sender email address is required';; esac
printf '%s' "$key" > "$WORK/email-key"
printf '%s' "$address" > "$WORK/email-address"
jq -n --rawfile key "$WORK/email-key" --rawfile address "$WORK/email-address" '{driver:"resend",api_key:$key,sender_address:$address,sender_name:"SXCHNGE"}' | put email
echo 'Email configuration saved in Vault. Run make up.'
}
+60
View File
@@ -0,0 +1,60 @@
# Shared container-side primitives. Secret JSON travels through stdin or private files.
set -eu
set -o pipefail
umask 077
STATE="$PWD/.local/dev"
TOOL_STATE=$STATE
mkdir -p "$STATE"
WORK=$(mktemp -d)
cleanup() {
rm -rf "$WORK"
chown -R "${LOCAL_UID:-0}:${LOCAL_GID:-0}" "$TOOL_STATE"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fail() { echo "$*" >&2; exit 1; }
compose() { docker compose --env-file "$STATE/images.env" "$@"; }
write() {
target=$1; mode=${2:-600}
mkdir -p "$(dirname "$target")"
cat > "$target.tmp"
chmod "$mode" "$target.tmp"
mv "$target.tmp" "$target"
}
# Token is never placed in curl's arguments, errors or response logs.
vault() (
path=$1; method=${2:-GET}; missing=${3:-no}
header=$(mktemp "$WORK/header.XXXXXX")
response=$(mktemp "$WORK/response.XXXXXX")
trap 'rm -f "$header" "$response"' EXIT
printf 'Content-Type: application/json\n' > "$header"
if [ -n "${VAULT_TOKEN:-}" ]; then printf 'X-Vault-Token: %s\n' "$VAULT_TOKEN" >> "$header"; fi
if [ "$method" = GET ]; then
code=$(curl -s --connect-timeout 5 --max-time 30 -H "@$header" -o "$response" -w '%{http_code}' "http://vault:8200/v1/$path") || exit 1
else
code=$(curl -s --connect-timeout 5 --max-time 30 -H "@$header" -X "$method" --data-binary @- -o "$response" -w '%{http_code}' "http://vault:8200/v1/$path") || exit 1
fi
case "$code" in
200|204) if [ -s "$response" ]; then cat "$response"; else printf '{}'; fi;;
404) if [ "$missing" = yes ]; then printf 'null'; else echo "Vault $path returned HTTP $code" >&2; exit 1; fi;;
*) echo "Vault $path returned HTTP $code" >&2; exit 1;;
esac
)
existing() {
[ -f "$STATE/admin/init.json" ] || fail 'Missing .local/dev/admin/init.json; restore matching bootstrap material'
VAULT_TOKEN=$(jq -er '.root_token' "$STATE/admin/init.json")
export VAULT_TOKEN
}
kv() { vault "kv/data/$1" GET yes | jq 'if . == null then null else .data.data end'; }
put() { jq '{data:.}' | vault "kv/data/$1" POST >/dev/null; }
random() { printf '{"bytes":%s,"format":"hex"}' "${1:-32}" | vault sys/tools/random POST | jq -er '.data.random_bytes'; }
ensure() (
path=$1
value=$(kv "$path")
if [ "$value" = null ]; then put "$path"; fi
)
# Never forward mongosh errors: authentication/command errors may include secrets.
mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; }
admin_auth() { kv bootstrap/mongo | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; }
email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S")) and (.sender_address|type=="string") and (.sender_address|test("\\S")) and .sender_address!="notifications@localhost.test"' >/dev/null; }
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
set -o pipefail
. dev/common.sh
case "${1:-}" in
images) . dev/images.sh; images;;
up) . dev/bootstrap.sh; up;;
email) . dev/bootstrap.sh; email;;
verify|verify-infra) . dev/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;;
rotation) . dev/verify.sh; . dev/rotation.sh; existing; rotation;;
test) sh dev/test.sh;;
*) fail 'Unknown local tooling command';;
esac
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env python3
"""Resolve stable upstream releases on every build; pin their registry digests."""
import concurrent.futures
import json
import os
from pathlib import Path
import re
import subprocess
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / '.local' / 'dev'
def get(url):
with urllib.request.urlopen(url, timeout=45) as response:
return json.load(response)
def image(repo, alias):
url = f'https://hub.docker.com/v2/repositories/{repo}/tags'
selected = get(f'{url}/{alias}')
digest = selected['digest']
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
raise ValueError('Registry returned an invalid digest')
# Find the exact numeric tag represented by the maintained stable alias.
candidates = get(f'{url}?page_size=100')['results']
names = [x['name'] for x in candidates if x.get('digest') == digest
and re.fullmatch(r'\d+\.\d+(?:\.\d+)?(?:-alpine)?', x['name'])]
if not names:
raise ValueError(f'No stable version tag for {repo}:{alias}')
tag = max(names, key=lambda x: tuple(map(int, x.split('-')[0].split('.'))))
return f'{repo}:{tag}@{digest}'
def flutter():
commit = get('https://api.github.com/repos/flutter/flutter/commits/stable')['sha']
tags = subprocess.check_output(['git', 'ls-remote', '--tags',
'https://github.com/flutter/flutter.git'], text=True)
names = [line.split('refs/tags/')[1] for line in tags.splitlines()
if line.startswith(commit + '\trefs/tags/')
and re.fullmatch(r'\d+\.\d+\.\d+', line.split('refs/tags/')[1])]
if not names:
raise ValueError('Flutter stable does not point at a stable release tag')
return max(names, key=lambda x: tuple(map(int, x.split('.')))), commit
def main():
go = get('https://go.dev/dl/?mode=json')[0]
if not go['stable'] or not re.fullmatch(r'go\d+\.\d+\.\d+', go['version']):
raise ValueError('Go release is not stable')
specs = {'VAULT_IMAGE': ('hashicorp/vault', 'latest'),
'MONGO_IMAGE': ('library/mongo', 'latest'),
'NATS_IMAGE': ('library/nats', 'latest'),
'NGINX_IMAGE': ('library/nginx', 'stable-alpine'),
'GO_IMAGE': ('library/golang', go['version'][2:] + '-alpine'),
'UBUNTU_IMAGE': ('library/ubuntu', 'latest')}
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = {key: pool.submit(image, *spec) for key, spec in specs.items()}
refs = {key: future.result() for key, future in futures.items()}
refs['FLUTTER_VERSION'], refs['FLUTTER_COMMIT'] = flutter()
STATE.mkdir(parents=True, exist_ok=True, mode=0o700)
target = STATE / 'images.env'
temporary = target.with_suffix('.tmp')
temporary.write_text(''.join(f'{key}={value}\n' for key, value in refs.items()))
os.replace(temporary, target)
for key, value in refs.items():
print(f'{key}={value}')
if __name__ == '__main__':
main()
+24
View File
@@ -0,0 +1,24 @@
# Resolve maintained stable aliases to exact numeric tags and digests.
get() { curl -fsS --connect-timeout 10 --max-time 60 "$1"; }
image() (
repo=$1; alias=$2
digest=$(get "https://hub.docker.com/v2/repositories/$repo/tags/$alias" | jq -er '.digest | select(test("^sha256:[a-f0-9]{64}$"))') || exit 1
tag=$(get "https://hub.docker.com/v2/repositories/$repo/tags?page_size=100" | jq -er --arg digest "$digest" '
[.results[] | select(.digest==$digest) | .name | select(test("^[0-9]+\\.[0-9]+(\\.[0-9]+)?(-alpine)?$"))]
| sort_by(split("-")[0] | split(".") | map(tonumber)) | last // error("No stable numeric tag matches digest")') || exit 1
printf '%s:%s@%s\n' "$repo" "$tag" "$digest"
)
images() (
go=$(get 'https://go.dev/dl/?mode=json' | jq -er '.[0] | select(.stable) | .version | select(test("^go[0-9]+\\.[0-9]+\\.[0-9]+$"))') || exit 1
for spec in VAULT_IMAGE:hashicorp/vault:latest MONGO_IMAGE:library/mongo:latest NATS_IMAGE:library/nats:latest NGINX_IMAGE:library/nginx:stable-alpine "GO_IMAGE:library/golang:${go#go}-alpine" UBUNTU_IMAGE:library/ubuntu:latest; do
key=${spec%%:*}; rest=${spec#*:}; repo=${rest%:*}; alias=${rest##*:}
ref=$(image "$repo" "$alias") || exit 1
printf '%s=%s\n' "$key" "$ref" >> "$WORK/images.env"
done
commit=$(get https://api.github.com/repos/flutter/flutter/commits/stable | jq -er '.sha | select(test("^[a-f0-9]{40}$"))') || exit 1
git ls-remote --tags https://github.com/flutter/flutter.git > "$WORK/flutter-tags" || exit 1
tag=$(jq -Rrs --arg commit "$commit" '[split("\n")[] | split("\t") | select(.[0]==$commit) | .[1] | sub("^refs/tags/";"") | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".")|map(tonumber)) | last // error("Flutter stable has no stable release tag")' "$WORK/flutter-tags") || exit 1
printf 'FLUTTER_VERSION=%s\nFLUTTER_COMMIT=%s\n' "$tag" "$commit" >> "$WORK/images.env"
cat "$WORK/images.env" | write "$STATE/images.env"
cat "$STATE/images.env"
)
+3
View File
@@ -0,0 +1,3 @@
ARG DOCKER_IMAGE
FROM ${DOCKER_IMAGE}
RUN apk add --no-cache curl jq git openssl util-linux shellcheck
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env python3
"""Local stack lifecycle. Requires Python 3 and Docker Compose."""
import sys
from bootstrap import up
from vault import Vault
def main():
vault = Vault()
if len(sys.argv) == 2 and sys.argv[1] == 'up':
import fcntl
from vault import STATE
STATE.mkdir(parents=True, exist_ok=True, mode=0o700)
with (STATE / 'bootstrap.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError('Another local bootstrap is running') from None
up(vault)
elif len(sys.argv) == 2 and sys.argv[1] == 'verify':
from verify import verify
vault.existing()
verify(vault)
elif len(sys.argv) == 2 and sys.argv[1] == 'verify-infra':
from verify import infrastructure
vault.existing()
infrastructure(vault)
elif len(sys.argv) == 2 and sys.argv[1] == 'email':
import getpass
vault.existing()
address = input('Verified Resend sender address: ').strip()
key = getpass.getpass('Resend API key (hidden): ').strip()
if not key or '@' not in address:
raise RuntimeError('A key and sender address are required')
vault.put('email', {'driver': 'resend', 'api_key': key,
'sender_address': address, 'sender_name': 'SXCHNGE'})
print('Email configuration saved in Vault. Run make up.')
else:
raise RuntimeError('Usage: python3 dev/local.py up|verify|verify-infra|email')
if __name__ == '__main__':
try:
main()
except (RuntimeError, OSError) as error:
print('Local development setup failed: ' + str(error), file=sys.stderr)
sys.exit(1)
+32
View File
@@ -0,0 +1,32 @@
// Run using a leased application identity, never the bootstrap administrator.
if (db.hello().setName !== "rs0" || !db.hello().isWritablePrimary) {
throw new Error("replica set is not primary");
}
const own = db.getSiblingDB(database);
const name = "local_check_" + new ObjectId().toHexString();
const session = db.getMongo().startSession();
try {
own.createCollection(name);
const collection = session.getDatabase(database).getCollection(name);
session.startTransaction();
collection.insertMany([{_id: "first"}, {_id: "second"}]);
session.commitTransaction();
if (own.getCollection(name).countDocuments({}) !== 2) throw new Error("commit failed");
session.startTransaction();
collection.deleteOne({_id: "first"});
collection.insertOne({_id: "aborted"});
session.abortTransaction();
if (own.getCollection(name).countDocuments({}) !== 2 ||
!own.getCollection(name).findOne({_id: "first"}) ||
own.getCollection(name).findOne({_id: "aborted"})) throw new Error("rollback failed");
let denied = false;
try {
const result = db.getSiblingDB(forbidden).runCommand({find: name, filter: {}});
denied = result.code === 13;
} catch (error) { denied = error.code === 13; }
if (!denied) throw new Error("cross-database access allowed");
print("scope-ok");
} finally {
session.endSession();
own.getCollection(name).drop();
}
+9
View File
@@ -0,0 +1,9 @@
// Public browser client: this identifier is not a client secret.
db.getSiblingDB("sxchnge").clients.updateOne(
{clientId: "sxchnge-local-web"},
{$setOnInsert: {
clientName: "Local browser", allowedCIDRs: ["0.0.0.0/0", "::/0"],
allowedScopes: ["account"], redirectURIs: ["https://localhost:5126"],
grantTypes: ["password", "refresh_token"], tokenEndpointAuthMethod: "none", isRevoked: false
}}, {upsert: true}
);
-92
View File
@@ -1,92 +0,0 @@
"""Exercise real Vault database rotation under BFF HTTP transaction traffic.
Run after make build/bootstrap. No Resend credential or live email is needed.
Temporarily shortens the BFF database role TTL, restores it on every exit, and
preserves whether BFF/frontend were running before this check.
"""
import concurrent.futures
import json
import ssl
import threading
import time
import urllib.error
import urllib.request
import uuid
from bootstrap import compose
from vault import STATE, Vault
def verify_rotation():
vault = Vault()
vault.existing()
role = 'database/roles/bff'
original = vault.call(role)['data']
running = {json.loads(line)['Service'] for line in compose('ps', '--format', 'json', capture=True).splitlines()}
stop = threading.Event()
successes = []
failures = []
lock = threading.Lock()
context = ssl.create_default_context(cafile=str(STATE / 'ca.pem'))
def requests():
while not stop.is_set():
# Exactly 43 characters invokes the activation transaction and token
# lookup, then returns the expected invalid-token response. No user
# or notification is created and the test never sends an email.
token = uuid.uuid4().hex + uuid.uuid4().hex[:11]
request = urllib.request.Request('https://localhost:5126/api/v1/accounts/verification/confirm',
data=json.dumps({'token': token}).encode(), headers={
'Content-Type': 'application/json', 'Origin': 'https://localhost:5126',
'X-Auth-Transport': 'cookie', 'X-CSRF-Protection': '1'})
try:
with urllib.request.urlopen(request, context=context, timeout=5) as response:
code = response.status
body = response.read()
except urllib.error.HTTPError as error:
code = error.code
body = error.read()
except Exception:
code = 0
body = b''
with lock:
(successes if code == 400 and b'Verification is invalid or already used' in body else failures).append(code)
stop.wait(0.03)
try:
vault.call(role, dict(original, default_ttl='12s', max_ttl='24s'))
compose('up', '-d', '--no-deps', '--force-recreate', '--wait', '--wait-timeout', '90', 'bff', 'frontend')
process = compose('exec', '-T', 'bff', 'pidof', 'service', capture=True).strip()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
workers = [pool.submit(requests) for _ in range(4)]
try:
deadline = time.monotonic() + 40
while time.monotonic() < deadline:
time.sleep(1)
current = compose('exec', '-T', 'bff', 'pidof', 'service', capture=True).strip()
if current != process:
raise RuntimeError('BFF process restarted during credential rotation')
logs = compose('logs', '--no-color', 'bff', capture=True)
if logs.count('Database pool replaced without restarting service') >= 3:
break
else:
raise RuntimeError('Three database pool rotations were not observed')
finally:
stop.set()
for worker in workers:
worker.result()
if failures or len(successes) < 100:
raise RuntimeError(f'Rotation traffic check: {len(successes)} expected responses, {len(failures)} failures')
print(f'PASS: three real Vault rotations, unchanged BFF PID, {len(successes)} transaction-backed HTTP requests, zero failures')
finally:
stop.set()
vault.call(role, original)
newly_started = [name for name in ['frontend', 'bff'] if name not in running]
if newly_started:
compose('stop', *newly_started)
compose('rm', '-f', *newly_started)
# Existing services stay online; their next refresh picks up normal TTLs.
if __name__ == '__main__':
verify_rotation()
+60
View File
@@ -0,0 +1,60 @@
# Temporarily deploy BFF/frontend and shorten the role lifetime. Always restore it.
rotation() (
exec 9> "$STATE/bootstrap.lock"
flock -n 9 || fail 'Another local bootstrap or rotation check is running'
vault database/roles/bff | jq '.data' > "$WORK/original-role"
compose ps --services --status running > "$WORK/running"
workers=''
restore() {
touch "$WORK/stop"
for pid in $workers; do wait "$pid" || true; done
restore_failed=0
vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; }
for service in frontend bff; do
if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi
done
return "$restore_failed"
}
trap restore EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null
compose up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend
process=$(compose exec -T bff pidof service)
for worker in 1 2 3 4; do
(
while [ ! -f "$WORK/stop" ]; do
# 43 characters passes syntax validation, executes a Mongo transaction/read,
# and returns invalid-token without creating users or sending email.
token=$(openssl rand -hex 22 | cut -c1-43)
code=$(printf '{"token":"%s"}' "$token" | https -o "$WORK/body-$worker" -w '%{http_code}' \
-H 'Content-Type: application/json' -H 'Origin: https://localhost:5126' \
-H 'X-Auth-Transport: cookie' -H 'X-CSRF-Protection: 1' --data-binary @- \
https://localhost:5126/api/v1/accounts/verification/confirm) || code=0
if [ "$code" = 400 ] && grep -q 'Verification is invalid or already used' "$WORK/body-$worker"; then
echo ok >> "$WORK/results-$worker"
else echo fail >> "$WORK/results-$worker"; fi
sleep 0.03
done
) &
workers="$workers $!"
done
count=0; attempt=0
while [ "$attempt" -lt 40 ]; do
sleep 1
[ "$(compose exec -T bff pidof service)" = "$process" ] || fail 'BFF restarted during rotation'
compose logs --no-color bff > "$WORK/logs"
count=$(grep -c 'Database pool replaced without restarting service' "$WORK/logs" || true)
[ "$count" -lt 3 ] || break
attempt=$((attempt+1))
done
touch "$WORK/stop"
for pid in $workers; do wait "$pid"; done
workers=''
[ "$count" -ge 3 ] || fail 'Three pool rotations were not observed'
cat "$WORK"/results-* > "$WORK/results"
! grep -q fail "$WORK/results" || fail 'Unexpected HTTP responses during rotation'
count=$(wc -l < "$WORK/results")
[ "$count" -ge 100 ] || fail 'Insufficient rotation traffic'
echo "PASS: three real Vault rotations, unchanged BFF PID, $count transaction-backed HTTP requests, zero failures"
)
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
set -o pipefail
# Shell syntax and static analysis cover the actual container shell scripts.
shellcheck -s ash -x -S warning dev/dispatch.sh dev/test.sh
shellcheck -x -S warning dev/tools.sh dev/start-*.sh
. dev/common.sh
. dev/images.sh
. dev/bootstrap.sh
# Offline fixtures exercise stable selection, reject prereleases, and preserve pins.
get() {
case "$1" in
*/tags/latest) printf '{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}';;
*page_size*) cat <<'JSON'
{"results":[{"name":"2.1.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"name":"2.2.0-rc1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
JSON
;;
*) return 1;;
esac
}
[ "$(image hashicorp/vault latest)" = 'hashicorp/vault:2.1.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ] || fail 'Stable release selection failed'
get() { printf '{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","results":[{"name":"3.0.0-rc1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}'; }
if image hashicorp/vault latest >/dev/null 2>&1; then fail 'Prerelease accepted'; fi
# Isolate state from the developer stack during failure tests.
STATE="$WORK/state"; mkdir "$STATE"; printf 'previous build' > "$STATE/images.env"
get() { return 1; }
if images >/dev/null 2>&1; then fail 'Offline resolution succeeded'; fi
[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins'
if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi
printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid
# Initialized Vault without matching local keys must fail without initializing it again.
vault() { printf '{"initialized":true}'; }
if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi
grep -q 'Missing .local' "$WORK/initialize-error"
for service in bff notifications; do
if grep -Eq 'database/creds/|SXCHNGE_MONGO_URI' "dev/vault/$service.hcl"; then fail 'Database credentials trigger Agent restart'; fi
done
echo 'PASS: shell analysis, stable selection, failed-resolution preservation, email validation, missing-key protection and Agent contract'
if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then fail 'Tool image unexpectedly contains Python'; fi
-71
View File
@@ -1,71 +0,0 @@
"""Focused regression tests for release selection and bootstrap failure paths."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import agents
import bootstrap
import images
from vault import Vault
class Releases(unittest.TestCase):
def test_selects_exact_stable_digest_not_prerelease(self):
digest = 'sha256:' + 'a' * 64
versions = [{'name': name, 'digest': digest} for name in ['latest', '2', '2.1', '2.1.0', '2.2.0-rc1']]
with patch.object(images, 'get', side_effect=[{'digest': digest}, {'results': versions}]):
self.assertEqual(images.image('hashicorp/vault', 'latest'), 'hashicorp/vault:2.1.0@' + digest)
def test_missing_stable_mapping_fails(self):
digest = 'sha256:' + 'a' * 64
with patch.object(images, 'get', side_effect=[{'digest': digest}, {'results': [{'name': '3.0.0-rc1', 'digest': digest}]}]):
with self.assertRaises(ValueError):
images.image('hashicorp/vault', 'latest')
def test_upstream_failure_does_not_replace_existing_pins(self):
with tempfile.TemporaryDirectory() as temporary:
state = Path(temporary)
target = state / 'images.env'
target.write_text('previous build')
with patch.object(images, 'STATE', state), patch.object(images, 'get', side_effect=OSError('offline')):
with self.assertRaises(OSError):
images.main()
self.assertEqual(target.read_text(), 'previous build')
class AgentContract(unittest.TestCase):
def test_database_lease_does_not_trigger_process_restart(self):
for service in ['bff', 'notifications']:
config = agents.config(service)
self.assertNotIn('database/creds/', config)
self.assertNotIn('SXCHNGE_MONGO_URI', config)
class Bootstrap(unittest.TestCase):
def test_missing_resend_credentials_stops_before_product_start(self):
with tempfile.TemporaryDirectory() as temporary:
state = Path(temporary)
(state / 'images.env').touch()
from unittest.mock import Mock
vault = Mock()
vault.kv.return_value = {'driver': 'resend', 'api_key': '', 'sender_address': 'notifications@localhost.test'}
with patch.object(bootstrap, 'STATE', state), patch.object(bootstrap, 'compose') as compose, \
patch.object(bootstrap, 'provision_mongo'), patch.object(bootstrap, 'provision_agents'):
with self.assertRaisesRegex(RuntimeError, 'Resend api_key'):
bootstrap.up(vault)
self.assertEqual(compose.call_args_list[-1].args[-1], 'nats')
email_seed = next(c for c in vault.ensure.call_args_list if c.args[0] == 'email').args[1]()
self.assertEqual(email_seed['driver'], 'resend')
self.assertEqual(email_seed['api_key'], '')
def test_initialized_vault_without_local_keys_fails(self):
with tempfile.TemporaryDirectory() as temporary:
with patch('vault.STATE', Path(temporary)), patch.object(Vault, 'call', return_value={'initialized': True}):
with self.assertRaisesRegex(RuntimeError, 'Missing .local'):
Vault().initialize()
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Host entry point: only POSIX shell and Docker are required.
set -eu
cd "$(dirname "$0")/.."
case "${1:-}" in
build)
docker pull docker:cli
ref=$(docker image inspect docker:cli --format '{{index .RepoDigests 0}}')
case "$ref" in docker@sha256:*) ;; *) echo 'Cannot pin stable Docker CLI image' >&2; exit 1;; esac
docker build --pull --build-arg "DOCKER_IMAGE=$ref" -f dev/images/tools.Dockerfile -t sxchnge-local/tools:dev dev/images
exit
;;
esac
docker image inspect sxchnge-local/tools:dev >/dev/null 2>&1 || { echo 'Run make build first' >&2; exit 1; }
endpoint=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}
case "$endpoint" in unix://*) socket=${endpoint#unix://};; *) echo 'Local tooling requires a local Unix Docker socket' >&2; exit 1;; esac
network_args=
if docker network inspect sxchnge-local_default >/dev/null 2>&1; then network_args='--network sxchnge-local_default'; fi
terminal_args=-i
if [ "${1:-}" = email ]; then terminal_args=-it; fi
# These two argument lists contain only fixed strings controlled above.
# shellcheck disable=SC2086
exec docker run --rm $terminal_args $network_args -e "LOCAL_UID=$(id -u)" -e "LOCAL_GID=$(id -g)" \
-v "$socket:/var/run/docker.sock" -v "$PWD:$PWD" -w "$PWD" \
sxchnge-local/tools:dev sh dev/dispatch.sh "$@"
-117
View File
@@ -1,117 +0,0 @@
"""Local Vault bootstrap API; response bodies with secrets never reach logs."""
import json
import os
from pathlib import Path
import time
import urllib.error
import urllib.request
STATE = Path(__file__).resolve().parents[1] / '.local' / 'dev'
URL = 'http://127.0.0.1:18200/v1/'
def write(path, value, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = path.with_name(path.name + '.tmp')
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
with os.fdopen(fd, 'w') as out:
out.write(value)
os.chmod(tmp, mode)
os.replace(tmp, path)
class Vault:
def __init__(self):
self.token = None
def call(self, path, data=None, method=None, missing=False):
headers = {'Content-Type': 'application/json'}
if self.token:
headers['X-Vault-Token'] = self.token
req = urllib.request.Request(URL + path, headers=headers,
data=json.dumps(data).encode() if data is not None else None, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as response:
body = response.read()
return json.loads(body) if body else {}
except urllib.error.HTTPError as error:
if error.code == 404 and missing:
return None
raise RuntimeError(f'Vault {path} returned HTTP {error.code}') from None
def initialize(self):
for attempt in range(90):
try:
initialized = self.call('sys/init')['initialized']
break
except (urllib.error.URLError, ConnectionError):
time.sleep(1)
else:
raise RuntimeError('Vault did not start within 90 seconds')
initfile = STATE / 'admin' / 'init.json'
if not initialized:
if initfile.exists():
raise RuntimeError('Vault data is missing but bootstrap material exists; restore the matching volume or explicitly reset both')
init = self.call('sys/init', {'secret_shares': 1, 'secret_threshold': 1})
write(initfile, json.dumps(init))
if not initfile.exists():
raise RuntimeError('Missing .local/dev/admin/init.json for initialized Vault; restore the matching bootstrap material')
init = json.loads(initfile.read_text())
self.token = init['root_token']
write(STATE / 'vault' / 'unseal', init['keys_base64'][0], 0o444)
for attempt in range(60):
if not self.call('sys/seal-status')['sealed']:
return
time.sleep(1)
raise RuntimeError('Vault did not unseal within 60 seconds')
def existing(self):
self.token = json.loads((STATE / 'admin' / 'init.json').read_text())['root_token']
def kv(self, path):
result = self.call('kv/data/' + path, missing=True)
return result['data']['data'] if result else None
def put(self, path, data):
self.call('kv/data/' + path, {'data': data})
def ensure(self, path, factory):
value = self.kv(path)
if value is None:
value = factory()
self.put(path, value)
return value
def random(self, length=32):
return self.call('sys/tools/random', {'bytes': length, 'format': 'hex'})['data']['random_bytes']
def mounts(self):
mounts = self.call('sys/mounts')['data']
for name, kind in [('kv', 'kv'), ('database', 'database'), ('pki', 'pki')]:
if name + '/' not in mounts:
settings = {'type': kind}
if kind == 'kv':
settings['options'] = {'version': '2'}
self.call('sys/mounts/' + name, settings)
auth = self.call('sys/auth')['data']
if 'approle/' not in auth:
self.call('sys/auth/approle', {'type': 'approle'})
def certificates(self):
self.call('sys/mounts/pki/tune', {'max_lease_ttl': '87600h'})
ca = self.kv('tls')
if ca is None:
result = self.call('pki/root/generate/internal', {'common_name': 'SXCHNGE local development CA', 'ttl': '87600h'})['data']
ca = {'ca': result['certificate']}
self.put('tls', ca)
self.call('pki/roles/local', {'allowed_domains': ['localhost', 'notifications'],
'allow_bare_domains': True, 'allow_ip_sans': True, 'max_ttl': '720h'})
for name, host in [('notifications-tls', 'notifications'), ('frontend-tls', 'localhost')]:
cert = self.kv(name)
if cert is None or cert['expiration'] < time.time() + 7 * 86400:
cert = self.call('pki/issue/local', {'common_name': host, 'ip_sans': '127.0.0.1', 'ttl': '720h'})['data']
self.put(name, cert)
if name == 'frontend-tls':
write(STATE / 'frontend' / 'server.crt', cert['certificate'])
write(STATE / 'frontend' / 'server.key', cert['private_key'])
write(STATE / 'ca.pem', ca['ca'], 0o644)
+35
View File
@@ -0,0 +1,35 @@
vault { address = "http://vault:8200" }
auto_auth {
method "approle" {
config = {
role_id_file_path = "/etc/agent/role-id"
secret_id_file_path = "/etc/agent/secret-id"
remove_secret_id_file_after_reading = false
}
}
}
template_config {
static_secret_render_interval = "10s"
exit_on_retry_failure = true
}
env_template "TLS_CA" {
contents = "{{ with secret \"kv/data/tls\" }}{{ .Data.data.ca }}{{ end }}"
error_on_missing_key = true
}
env_template "SXCHNGE_JWT_SECRET" {
contents = "{{ with secret \"kv/data/bff\" }}{{ .Data.data.jwt }}{{ end }}"
error_on_missing_key = true
}
env_template "SXCHNGE_NOTIFICATIONS_TOKEN" {
contents = "{{ with secret \"kv/data/caller\" }}{{ .Data.data.token }}{{ end }}"
error_on_missing_key = true
}
exec {
command = ["/usr/local/bin/start-service"]
restart_on_secret_changes = "always"
restart_stop_signal = "SIGTERM"
}
@@ -12,3 +12,14 @@ template_config {
static_secret_render_interval = "10s"
exit_on_retry_failure = true
}
env_template "NATS_TOKEN" {
contents = "{{ with secret \"kv/data/nats\" }}{{ .Data.data.token }}{{ end }}"
error_on_missing_key = true
}
exec {
command = ["/usr/local/bin/start-nats"]
restart_on_secret_changes = "always"
restart_stop_signal = "SIGTERM"
}
+55
View File
@@ -0,0 +1,55 @@
vault { address = "http://vault:8200" }
auto_auth {
method "approle" {
config = {
role_id_file_path = "/etc/agent/role-id"
secret_id_file_path = "/etc/agent/secret-id"
remove_secret_id_file_after_reading = false
}
}
}
template_config {
static_secret_render_interval = "10s"
exit_on_retry_failure = true
}
env_template "TLS_CA" {
contents = "{{ with secret \"kv/data/tls\" }}{{ .Data.data.ca }}{{ end }}"
error_on_missing_key = true
}
env_template "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" {
contents = "{{ with secret \"kv/data/caller\" }}{{ .Data.data.token }}{{ end }}"
error_on_missing_key = true
}
env_template "SXCHNGE_NATS_URL" {
contents = "{{ with secret \"kv/data/nats\" }}nats://{{ .Data.data.token }}@nats:4222{{ end }}"
error_on_missing_key = true
}
env_template "TLS_CERT" {
contents = "{{ with secret \"kv/data/notifications-tls\" }}{{ .Data.data.certificate }}{{ end }}"
error_on_missing_key = true
}
env_template "TLS_KEY" {
contents = "{{ with secret \"kv/data/notifications-tls\" }}{{ .Data.data.private_key }}{{ end }}"
error_on_missing_key = true
}
env_template "EMAIL_API_KEY" {
contents = "{{ with secret \"kv/data/email\" }}{{ .Data.data.api_key }}{{ end }}"
error_on_missing_key = true
}
env_template "EMAIL_CONFIG" {
contents = "{{ with secret \"kv/data/email\" }}email:\n driver: {{ .Data.data.driver | toJSON }}\n settings:\n api_key_env: EMAIL_API_KEY\n sender_address: {{ .Data.data.sender_address | toJSON }}\n sender_name: {{ .Data.data.sender_name | toJSON }}\n{{ end }}"
error_on_missing_key = true
}
exec {
command = ["/usr/local/bin/start-service"]
restart_on_secret_changes = "always"
restart_stop_signal = "SIGTERM"
}
-72
View File
@@ -1,72 +0,0 @@
"""Integration checks against this project's running stack; never print secrets."""
import json
import ssl
import urllib.request
from bootstrap import compose, mongo
from vault import STATE
def require(condition, message):
if not condition:
raise RuntimeError(message)
def database_access(vault):
for role, database, forbidden in [('bff', 'sxchnge', 'sxchnge_notifications'),
('notifications', 'sxchnge_notifications', 'sxchnge')]:
lease = vault.call('database/creds/' + role)
credentials = lease['data']
auth = 'db.getSiblingDB("admin").auth(' + json.dumps(credentials['username']) + ',' + json.dumps(credentials['password']) + ');'
try:
result = compose('exec', '-T', 'mongo', 'mongosh', '--quiet', '--file', '/dev/stdin', capture=True,
input=auth + 'const own=db.getSiblingDB(' + json.dumps(database) + '); own.local_smoke.insertOne({_id:"vault-check"}); own.local_smoke.deleteOne({_id:"vault-check"});\n'
'let denied=false; try { db.getSiblingDB(' + json.dumps(forbidden) + ').runCommand({find:"local_smoke",filter:{}}); } catch(e) { denied=e.code===13; } if(!denied) throw new Error("cross-database access allowed"); print("scope-ok");')
require('scope-ok' in result, 'Mongo service scope check failed')
renewed = vault.call('sys/leases/renew', {'lease_id': lease['lease_id'], 'increment': '1h'})
require(renewed['lease_duration'] > 0, 'Mongo lease renewal failed')
finally:
vault.call('sys/leases/revoke', {'lease_id': lease['lease_id']})
admin = vault.kv('bootstrap/mongo')['password']
result = mongo('print(admin.getUser(' + json.dumps(credentials['username']) + ')===null)', admin)
require('true' in result, 'Revoked Mongo user still exists')
print('PASS: database-plugin issuance, separate database permissions, lease renewal and revocation')
def policies(vault):
for service in ['bff', 'notifications', 'nats']:
root = STATE / 'agents' / service
login = vault.call('auth/approle/login', {'role_id': (root / 'role-id').read_text(),
'secret_id': (root / 'secret-id').read_text()})
token = login['auth']['client_token']
try:
result = vault.call('sys/capabilities', {'token': token, 'paths': ['kv/data/bootstrap/mongo', 'sys/init']})['data']
require(all(result[path] == ['deny'] for path in ['kv/data/bootstrap/mongo', 'sys/init']), 'Service can access bootstrap credentials')
finally:
vault.call('auth/token/revoke', {'token': token})
print('PASS: service identities cannot read bootstrap credentials')
def infrastructure(vault):
status = compose('ps', '--format', 'json', capture=True)
rows = [json.loads(line) for line in status.splitlines() if line.strip()]
infra = {r['Service']: r for r in rows if r['Service'] in {'vault', 'mongo', 'nats'}}
require(set(infra) == {'vault', 'mongo', 'nats'}, 'Expected Vault, Mongo and NATS')
require(all(r['Health'] == 'healthy' for r in infra.values()), 'Infrastructure is unhealthy')
print('PASS: Vault, Mongo replica set and NATS JetStream are healthy')
policies(vault)
database_access(vault)
def verify(vault):
status = compose('ps', '--format', 'json', capture=True)
rows = [json.loads(line) for line in status.splitlines() if line.strip()]
require({r['Service'] for r in rows} == {'vault', 'mongo', 'nats', 'notifications', 'bff', 'frontend'}, 'Expected six running services')
require(all(r['Health'] == 'healthy' for r in rows), 'Some services are unhealthy')
context = ssl.create_default_context(cafile=str(STATE / 'ca.pem'))
for path in ['/', '/api/v1/health']:
with urllib.request.urlopen('https://localhost:5126' + path, context=context, timeout=10) as response:
require(response.status == 200, 'Frontend or BFF health failed')
print('PASS: all six services healthy; frontend and proxied BFF use verified TLS')
infrastructure(vault)
print('Resend credentials are configured; no live email sent by this check')
+50
View File
@@ -0,0 +1,50 @@
policies() (
root_token=$VAULT_TOKEN
identity=''
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
for service in bff notifications nats; do
directory="$STATE/agents/$service"
login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST)
identity=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","sys/init"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials'
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '{}' | vault auth/token/revoke-self POST >/dev/null
VAULT_TOKEN=$root_token
identity=''
done
echo 'PASS: service identities cannot read bootstrap credentials'
)
database_access() (
for role in bff notifications; do
database=sxchnge; forbidden=sxchnge_notifications
if [ "$role" = notifications ]; then database=sxchnge_notifications; forbidden=sxchnge; fi
vault "database/creds/$role" > "$WORK/lease"
trap 'jq "{lease_id:.lease_id}" "$WORK/lease" | vault sys/leases/revoke POST >/dev/null' EXIT
{
jq -r '"db.getSiblingDB(\"admin\").auth("+(.data.username|tojson)+","+(.data.password|tojson)+");"' "$WORK/lease"
printf 'const database="%s", forbidden="%s";\n' "$database" "$forbidden"
cat dev/mongo-check.js
} | mongo > "$WORK/check" || fail 'Mongo access/transaction check failed'
grep -q scope-ok "$WORK/check" || fail 'Mongo check incomplete'
jq '{lease_id:.lease_id,increment:"1h"}' "$WORK/lease" | vault sys/leases/renew POST | jq -e '.lease_duration>0' >/dev/null || fail 'Mongo renewal failed'
jq '{lease_id:.lease_id}' "$WORK/lease" | vault sys/leases/revoke POST >/dev/null
trap - EXIT
{ admin_auth; jq -r '"print(admin.getUser("+(.data.username|tojson)+")===null);"' "$WORK/lease"; } | mongo > "$WORK/revoked" || fail 'Mongo revocation check failed'
grep -q true "$WORK/revoked" || fail 'Revoked Mongo user still exists'
done
echo 'PASS: Vault database issuance, isolated access, transaction commit/rollback, renewal and revocation'
)
infrastructure() {
compose ps --format json | jq -se '[.[] | select(.Service=="vault" or .Service=="mongo" or .Service=="nats")] | length==3 and all(.[];.Health=="healthy")' >/dev/null || fail 'Infrastructure is not healthy'
echo 'PASS: Vault, Mongo replica set and NATS JetStream are healthy'
policies
database_access
}
# Resolve through Docker DNS while verifying the certificate for localhost.
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
product() {
compose ps --format json | jq -se 'length==6 and all(.[];.Health=="healthy")' >/dev/null || fail 'Expected six healthy services'
https -f https://localhost:5126/ >/dev/null
https -f https://localhost:5126/api/v1/health >/dev/null
echo 'PASS: six healthy services, frontend and proxied BFF use verified TLS; no email sent'
}