Mirrors the prod deploy-k3s/ setup but runs all services in-cluster on a single node: PostgreSQL (replaces Neon), MinIO S3-compatible storage (replaces B2), Redis, API, worker, and admin. Includes fully automated setup scripts (00-init through 04-verify), server hardening (SSH, fail2ban, ufw), Let's Encrypt TLS via Traefik, network policies, RBAC, and security contexts matching prod. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
6.4 KiB
Bash
Executable File
215 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Shared config helper — sourced by all deploy scripts.
|
|
# Provides cfg() to read values from config.yaml.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
CONFIG_FILE="${DEPLOY_DIR}/config.yaml"
|
|
|
|
if [[ ! -f "${CONFIG_FILE}" ]]; then
|
|
if [[ -f "${CONFIG_FILE}.example" ]]; then
|
|
echo "[error] config.yaml not found. Run: cp config.yaml.example config.yaml" >&2
|
|
else
|
|
echo "[error] config.yaml not found." >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
# cfg "dotted.key.path" — reads a value from config.yaml
|
|
# Examples: cfg database.host, cfg nodes.0.ip, cfg features.push_enabled
|
|
cfg() {
|
|
python3 -c "
|
|
import yaml, json, sys
|
|
with open(sys.argv[1]) as f:
|
|
c = yaml.safe_load(f)
|
|
keys = sys.argv[2].split('.')
|
|
v = c
|
|
for k in keys:
|
|
if isinstance(v, list):
|
|
v = v[int(k)]
|
|
else:
|
|
v = v[k]
|
|
if isinstance(v, bool):
|
|
print(str(v).lower())
|
|
elif isinstance(v, (dict, list)):
|
|
print(json.dumps(v))
|
|
else:
|
|
print('' if v is None else v)
|
|
" "${CONFIG_FILE}" "$1" 2>/dev/null
|
|
}
|
|
|
|
# cfg_require "key" "label" — reads value and dies if empty
|
|
cfg_require() {
|
|
local val
|
|
val="$(cfg "$1")"
|
|
if [[ -z "${val}" ]]; then
|
|
echo "[error] Missing required config: $1 ($2)" >&2
|
|
exit 1
|
|
fi
|
|
printf '%s' "${val}"
|
|
}
|
|
|
|
# node_count — returns number of nodes
|
|
node_count() {
|
|
python3 -c "
|
|
import yaml
|
|
with open('${CONFIG_FILE}') as f:
|
|
c = yaml.safe_load(f)
|
|
print(len(c.get('nodes', [])))
|
|
"
|
|
}
|
|
|
|
# nodes_with_role "role" — returns node names with a given role
|
|
nodes_with_role() {
|
|
python3 -c "
|
|
import yaml
|
|
with open('${CONFIG_FILE}') as f:
|
|
c = yaml.safe_load(f)
|
|
for n in c.get('nodes', []):
|
|
if '$1' in n.get('roles', []):
|
|
print(n['name'])
|
|
"
|
|
}
|
|
|
|
# generate_env — writes the flat env file the app expects to stdout
|
|
generate_env() {
|
|
python3 -c "
|
|
import yaml
|
|
|
|
with open('${CONFIG_FILE}') as f:
|
|
c = yaml.safe_load(f)
|
|
|
|
d = c['domains']
|
|
db = c['database']
|
|
em = c['email']
|
|
ps = c['push']
|
|
st = c['storage']
|
|
wk = c['worker']
|
|
ft = c['features']
|
|
aa = c.get('apple_auth', {})
|
|
ga = c.get('google_auth', {})
|
|
rd = c.get('redis', {})
|
|
|
|
def b(v):
|
|
return str(v).lower() if isinstance(v, bool) else str(v)
|
|
|
|
def val(v):
|
|
return '' if v is None else str(v)
|
|
|
|
lines = [
|
|
# API
|
|
'DEBUG=false',
|
|
f\"ALLOWED_HOSTS={d['api']},{d['base']}\",
|
|
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']}\",
|
|
'TIMEZONE=UTC',
|
|
f\"BASE_URL=https://{d['base']}\",
|
|
'PORT=8000',
|
|
# Admin
|
|
f\"NEXT_PUBLIC_API_URL=https://{d['api']}\",
|
|
f\"ADMIN_PANEL_URL=https://{d['admin']}\",
|
|
# Database
|
|
f\"DB_HOST={val(db['host'])}\",
|
|
f\"DB_PORT={db['port']}\",
|
|
f\"POSTGRES_USER={val(db['user'])}\",
|
|
f\"POSTGRES_DB={db['name']}\",
|
|
f\"DB_SSLMODE={db['sslmode']}\",
|
|
f\"DB_MAX_OPEN_CONNS={db['max_open_conns']}\",
|
|
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
|
|
f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
|
|
# Redis (K8s internal DNS — password injected if configured)
|
|
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis.honeydue.svc.cluster.local:6379/0\",
|
|
'REDIS_DB=0',
|
|
# Email
|
|
f\"EMAIL_HOST={em['host']}\",
|
|
f\"EMAIL_PORT={em['port']}\",
|
|
f\"EMAIL_USE_TLS={b(em['use_tls'])}\",
|
|
f\"EMAIL_HOST_USER={val(em['user'])}\",
|
|
f\"DEFAULT_FROM_EMAIL={val(em['from'])}\",
|
|
# Push
|
|
'APNS_AUTH_KEY_PATH=/secrets/apns/apns_auth_key.p8',
|
|
f\"APNS_AUTH_KEY_ID={val(ps['apns_key_id'])}\",
|
|
f\"APNS_TEAM_ID={val(ps['apns_team_id'])}\",
|
|
f\"APNS_TOPIC={ps['apns_topic']}\",
|
|
f\"APNS_USE_SANDBOX={b(ps['apns_use_sandbox'])}\",
|
|
f\"APNS_PRODUCTION={b(ps['apns_production'])}\",
|
|
# Worker
|
|
f\"TASK_REMINDER_HOUR={wk['task_reminder_hour']}\",
|
|
f\"OVERDUE_REMINDER_HOUR={wk['overdue_reminder_hour']}\",
|
|
f\"DAILY_DIGEST_HOUR={wk['daily_digest_hour']}\",
|
|
# B2 Storage
|
|
f\"B2_KEY_ID={val(st['b2_key_id'])}\",
|
|
f\"B2_APP_KEY={val(st['b2_app_key'])}\",
|
|
f\"B2_BUCKET_NAME={val(st['b2_bucket'])}\",
|
|
f\"B2_ENDPOINT={val(st['b2_endpoint'])}\",
|
|
f\"STORAGE_MAX_FILE_SIZE={st['max_file_size']}\",
|
|
f\"STORAGE_ALLOWED_TYPES={st['allowed_types']}\",
|
|
# Features
|
|
f\"FEATURE_PUSH_ENABLED={b(ft['push_enabled'])}\",
|
|
f\"FEATURE_EMAIL_ENABLED={b(ft['email_enabled'])}\",
|
|
f\"FEATURE_WEBHOOKS_ENABLED={b(ft['webhooks_enabled'])}\",
|
|
f\"FEATURE_ONBOARDING_EMAILS_ENABLED={b(ft['onboarding_emails_enabled'])}\",
|
|
f\"FEATURE_PDF_REPORTS_ENABLED={b(ft['pdf_reports_enabled'])}\",
|
|
f\"FEATURE_WORKER_ENABLED={b(ft['worker_enabled'])}\",
|
|
# Apple auth/IAP
|
|
f\"APPLE_CLIENT_ID={val(aa.get('client_id'))}\",
|
|
f\"APPLE_TEAM_ID={val(aa.get('team_id'))}\",
|
|
f\"APPLE_IAP_KEY_ID={val(aa.get('iap_key_id'))}\",
|
|
f\"APPLE_IAP_ISSUER_ID={val(aa.get('iap_issuer_id'))}\",
|
|
f\"APPLE_IAP_BUNDLE_ID={val(aa.get('iap_bundle_id'))}\",
|
|
f\"APPLE_IAP_KEY_PATH={val(aa.get('iap_key_path'))}\",
|
|
f\"APPLE_IAP_SANDBOX={b(aa.get('iap_sandbox', False))}\",
|
|
# Google auth/IAP
|
|
f\"GOOGLE_CLIENT_ID={val(ga.get('client_id'))}\",
|
|
f\"GOOGLE_ANDROID_CLIENT_ID={val(ga.get('android_client_id'))}\",
|
|
f\"GOOGLE_IOS_CLIENT_ID={val(ga.get('ios_client_id'))}\",
|
|
f\"GOOGLE_IAP_PACKAGE_NAME={val(ga.get('iap_package_name'))}\",
|
|
f\"GOOGLE_IAP_SERVICE_ACCOUNT_PATH={val(ga.get('iap_service_account_path'))}\",
|
|
]
|
|
|
|
print('\n'.join(lines))
|
|
"
|
|
}
|
|
|
|
# generate_cluster_config — writes hetzner-k3s YAML to stdout
|
|
generate_cluster_config() {
|
|
python3 -c "
|
|
import yaml
|
|
|
|
with open('${CONFIG_FILE}') as f:
|
|
c = yaml.safe_load(f)
|
|
|
|
cl = c['cluster']
|
|
|
|
config = {
|
|
'cluster_name': 'honeydue',
|
|
'kubeconfig_path': './kubeconfig',
|
|
'k3s_version': cl['k3s_version'],
|
|
'networking': {
|
|
'ssh': {
|
|
'port': 22,
|
|
'use_agent': False,
|
|
'public_key_path': cl['ssh_public_key'],
|
|
'private_key_path': cl['ssh_private_key'],
|
|
},
|
|
'allowed_networks': {
|
|
'ssh': ['0.0.0.0/0'],
|
|
'api': ['0.0.0.0/0'],
|
|
},
|
|
},
|
|
'api_server_hostname': '',
|
|
'schedule_workloads_on_masters': True,
|
|
'masters_pool': {
|
|
'instance_type': cl['instance_type'],
|
|
'instance_count': len(c.get('nodes', [])),
|
|
'location': cl['location'],
|
|
'image': 'ubuntu-24.04',
|
|
},
|
|
'additional_packages': ['open-iscsi'],
|
|
'post_create_commands': ['sudo systemctl enable --now iscsid'],
|
|
'k3s_config_file': 'secrets-encryption: true\n',
|
|
}
|
|
|
|
print(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
|
"
|
|
}
|