Run local infrastructure and product images with Vault-managed secrets #43

Merged
tech merged 12 commits from dev-env-42 into main 2026-09-13 08:35:02 +00:00
27 changed files with 620 additions and 627 deletions
Showing only changes of commit 1b8800ee73 - Show all commits

No files matched your search

+6
View File
@@ -22,6 +22,12 @@
generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code.
## Local development tooling
Use Make and shell for local development orchestration. Do not introduce Python
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
## Container versions
Use only the latest stable upstream releases for infrastructure, product base
+15 -7
View File
@@ -56,12 +56,13 @@ package-notifications-interface: generate-notifications
COMPOSE = docker compose --env-file .local/dev/images.env
.PHONY: build up down logs ps test-dev
build:
python3 dev/images.py
sh dev/tools.sh build
sh dev/tools.sh images
$(COMPOSE) pull vault mongo
$(COMPOSE) build --pull
up:
python3 dev/local.py up
sh dev/tools.sh up
down:
$(COMPOSE) down
@@ -73,14 +74,21 @@ ps:
$(COMPOSE) ps
test-dev:
python3 -m unittest discover -s dev -p 'test_*.py'
python3 dev/local.py verify
sh dev/tools.sh test
sh dev/tools.sh verify
.PHONY: test-dev-infra
test-dev-infra:
python3 -m unittest discover -s dev -p 'test_*.py'
python3 dev/local.py verify-infra
sh dev/tools.sh test
sh dev/tools.sh verify-infra
.PHONY: test-dev-rotation
test-dev-rotation:
python3 dev/rotation.py
sh dev/tools.sh rotation
.PHONY: email test-dev-tooling
email:
sh dev/tools.sh email
test-dev-tooling:
sh dev/tools.sh test
+2 -1
View File
@@ -71,7 +71,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
repository root:
```sh
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web
docker run --rm --pull always -p 127.0.0.1:5123:80 \
-v "$PWD/frontend/app/build/web:/usr/share/nginx/html:ro" nginx:stable-alpine
```
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
-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'
}
+29
View File
@@ -676,3 +676,32 @@ the supplementary failures are recorded and are not suppressed. The required
normal test and lint commands pass. No optional security scan or email delivery
was performed. Resend remains intentionally unconfigured; infrastructure stays up
and product startup awaits manual `kv/email` provisioning.
### Make and shell development tooling
The local development tooling now runs POSIX-style shell scripts in a Docker
image containing Docker CLI, curl, jq, Git, OpenSSL, flock and ShellCheck. All eight
Python scripts were removed. Make remains the entry point; `make email` replaces
the Python credential prompt. Host requirements are Make, shell and local Docker
with Compose/BuildKit. The tool image is built from a freshly pulled stable Docker
CLI digest; application/infrastructure stable resolution and atomic local pins
remain intact. Tooling mounts the local Docker socket and workspace at its host
path; remote Docker engines are unsupported.
Static Agent HCL sources replace configuration generation and contain only Vault
template expressions. Bootstrap preserves existing Vault state and credentials,
protects its private files, and fails when Resend credentials are absent. The shell
infrastructure check now explicitly validates real Mongo transaction commit and
rollback with each application's Vault-issued identity. The rotation command
retains its unchanged-process and expected-response checks and TTL restoration.
Validation of the shell replacement passed: `make build`, `make test-dev-tooling`,
`make test-dev-infra`, Compose configuration validation and `git diff --check`.
An isolated `sxchnge-shell-check` project passed first bootstrap, live transaction
and lease checks, and repeated bootstrap; its containers, volumes and temporary
state were removed. `make test-dev-rotation` passed three real Vault replacements
with unchanged BFF PID, 1,048 expected HTTP responses and zero failures; role TTLs
were restored and temporary BFF/frontend containers removed. `make up` exits at
the intentional missing-Resend check, with infrastructure healthy. Backend and
frontend code did not change in this revision; their earlier test/lint results
and supplementary legacy test limitations above still apply. No email was sent.
+9 -4
View File
@@ -5,9 +5,13 @@ Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
## Build and bootstrap
Requires Docker with Compose v2 and BuildKit, Python 3.10+ and Git. Docker builds
Go, protobuf bindings and Flutter web; host Go, protoc and Flutter installations
are not required for `make build && make up`. The first Flutter SDK download can
Requires Make, a POSIX shell and a local Docker engine with Compose v2 and
BuildKit. Docker builds Go, protobuf bindings and Flutter web; host Go, protoc
and Flutter installations are not required for `make build && make up`. The
tooling container includes Docker CLI, curl, jq, Git and OpenSSL. No host Python,
jq, curl or Git installation is needed for these Make targets. It mounts
the local Docker socket and workspace to orchestrate Compose; a remote Docker
daemon is not supported. The first Flutter SDK download can
take several minutes. Both ARM64 and AMD64 build from the same source definitions.
```sh
@@ -52,7 +56,7 @@ containers never mount it. You can also provision the same secret with a hidden
terminal prompt, without placing the API key in shell history:
```sh
python3 dev/local.py email
make email
make up
```
@@ -169,6 +173,7 @@ make ps
make logs
make down # preserves named volumes and local bootstrap material
make up # idempotent provisioning and readiness checks
make test-dev-tooling # offline shell and failure-path checks
make test-dev-infra # works before Resend provisioning
make test-dev-rotation # real Vault rotations under BFF HTTP traffic
make test-dev # requires all six services running