Replace Python development tooling with Make and containerized shell
This commit is contained in:
1 parent
23f7b78c3b
commit
1b8800ee73
27 files changed
+620
-627
No files matched your search
@@ -22,6 +22,12 @@
|
|||||||
generated outputs. Document the generation workflow so a clean checkout can
|
generated outputs. Document the generation workflow so a clean checkout can
|
||||||
build without committed generated code.
|
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
|
## Container versions
|
||||||
|
|
||||||
Use only the latest stable upstream releases for infrastructure, product base
|
Use only the latest stable upstream releases for infrastructure, product base
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ package-notifications-interface: generate-notifications
|
|||||||
COMPOSE = docker compose --env-file .local/dev/images.env
|
COMPOSE = docker compose --env-file .local/dev/images.env
|
||||||
.PHONY: build up down logs ps test-dev
|
.PHONY: build up down logs ps test-dev
|
||||||
build:
|
build:
|
||||||
python3 dev/images.py
|
sh dev/tools.sh build
|
||||||
|
sh dev/tools.sh images
|
||||||
$(COMPOSE) pull vault mongo
|
$(COMPOSE) pull vault mongo
|
||||||
$(COMPOSE) build --pull
|
$(COMPOSE) build --pull
|
||||||
|
|
||||||
up:
|
up:
|
||||||
python3 dev/local.py up
|
sh dev/tools.sh up
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down
|
$(COMPOSE) down
|
||||||
@@ -73,14 +74,21 @@ ps:
|
|||||||
$(COMPOSE) ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
test-dev:
|
test-dev:
|
||||||
python3 -m unittest discover -s dev -p 'test_*.py'
|
sh dev/tools.sh test
|
||||||
python3 dev/local.py verify
|
sh dev/tools.sh verify
|
||||||
|
|
||||||
.PHONY: test-dev-infra
|
.PHONY: test-dev-infra
|
||||||
test-dev-infra:
|
test-dev-infra:
|
||||||
python3 -m unittest discover -s dev -p 'test_*.py'
|
sh dev/tools.sh test
|
||||||
python3 dev/local.py verify-infra
|
sh dev/tools.sh verify-infra
|
||||||
|
|
||||||
.PHONY: test-dev-rotation
|
.PHONY: test-dev-rotation
|
||||||
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
|
||||||
@@ -71,7 +71,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
|
|||||||
repository root:
|
repository root:
|
||||||
|
|
||||||
```sh
|
```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
|
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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'])
|
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
@@ -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()
|
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ARG DOCKER_IMAGE
|
||||||
|
FROM ${DOCKER_IMAGE}
|
||||||
|
RUN apk add --no-cache curl jq git openssl util-linux shellcheck
|
||||||
@@ -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)
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
);
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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)
|
|
||||||
@@ -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"
|
static_secret_render_interval = "10s"
|
||||||
exit_on_retry_failure = true
|
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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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')
|
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
normal test and lint commands pass. No optional security scan or email delivery
|
||||||
was performed. Resend remains intentionally unconfigured; infrastructure stays up
|
was performed. Resend remains intentionally unconfigured; infrastructure stays up
|
||||||
and product startup awaits manual `kv/email` provisioning.
|
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.
|
||||||
@@ -5,9 +5,13 @@ Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
|||||||
|
|
||||||
## Build and bootstrap
|
## Build and bootstrap
|
||||||
|
|
||||||
Requires Docker with Compose v2 and BuildKit, Python 3.10+ and Git. Docker builds
|
Requires Make, a POSIX shell and a local Docker engine with Compose v2 and
|
||||||
Go, protobuf bindings and Flutter web; host Go, protoc and Flutter installations
|
BuildKit. Docker builds Go, protobuf bindings and Flutter web; host Go, protoc
|
||||||
are not required for `make build && make up`. The first Flutter SDK download can
|
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.
|
take several minutes. Both ARM64 and AMD64 build from the same source definitions.
|
||||||
|
|
||||||
```sh
|
```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:
|
terminal prompt, without placing the API key in shell history:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 dev/local.py email
|
make email
|
||||||
make up
|
make up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,6 +173,7 @@ make ps
|
|||||||
make logs
|
make logs
|
||||||
make down # preserves named volumes and local bootstrap material
|
make down # preserves named volumes and local bootstrap material
|
||||||
make up # idempotent provisioning and readiness checks
|
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-infra # works before Resend provisioning
|
||||||
make test-dev-rotation # real Vault rotations under BFF HTTP traffic
|
make test-dev-rotation # real Vault rotations under BFF HTTP traffic
|
||||||
make test-dev # requires all six services running
|
make test-dev # requires all six services running
|
||||||
|
|||||||
Reference in new issue
Block a user