diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index d03a1fd..72c4f32 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -80,6 +80,36 @@ jobs: run: | kubectl apply -f "$WORKSPACE_DIR/deploy/k8s/base/namespace.yaml" + - name: Upsert runtime secrets + env: + OPERATOR_DASHBOARD_AUTH_PASSWORD: ${{ secrets.OPERATOR_DASHBOARD_AUTH_PASSWORD }} + run: | + if [ -z "$OPERATOR_DASHBOARD_AUTH_PASSWORD" ]; then + echo "missing required repo action secret OPERATOR_DASHBOARD_AUTH_PASSWORD" >&2 + exit 1 + fi + + patch_file="$(mktemp)" + cleanup() { + rm -f "$patch_file" + } + trap cleanup EXIT + + python3 - "$OPERATOR_DASHBOARD_AUTH_PASSWORD" >"$patch_file" <<'PY' + import json + import sys + + print(json.dumps({ + "stringData": { + "OPERATOR_DASHBOARD_AUTH_PASSWORD": sys.argv[1], + }, + })) + PY + + kubectl -n "$PROJECT_NAMESPACE" patch secret "${PROJECT_NAME}-secrets" \ + --type merge \ + --patch-file "$patch_file" + - name: Build and push image in-cluster env: REPO_TOKEN: ${{ github.token }} diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index 6a85e9b..07bab3e 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -89,7 +89,8 @@ data: OPS_SENTINEL_INVENTORY_STALE_MS: "30000" OPS_SENTINEL_FUNDING_CREDIT_PENDING_MS: "300000" OPS_SENTINEL_FUNDING_STUCK_MS: "3600000" - OPERATOR_DASHBOARD_AUTH_MODE: stub + OPERATOR_DASHBOARD_AUTH_MODE: basic + OPERATOR_DASHBOARD_AUTH_USERNAME: admin OPERATOR_DASHBOARD_QUOTE_LIMIT: "10" OPERATOR_DASHBOARD_TRADE_PAGE_SIZE: "20" OPERATOR_DASHBOARD_UPSTREAM_TIMEOUT_MS: "3000" @@ -244,6 +245,31 @@ spec: port: 8090 targetPort: 8090 --- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: operator-dashboard + namespace: unrip + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production +spec: + ingressClassName: traefik + tls: + - hosts: + - doran.133011.xyz + secretName: operator-dashboard-tls + rules: + - host: doran.133011.xyz + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: operator-dashboard + port: + number: 8090 +--- apiVersion: apps/v1 kind: Deployment metadata: diff --git a/scripts/deploy/bootstrap.sh b/scripts/deploy/bootstrap.sh index bf3eb5e..43f19bc 100755 --- a/scripts/deploy/bootstrap.sh +++ b/scripts/deploy/bootstrap.sh @@ -136,6 +136,7 @@ fi : "${POSTGRES_URL:=}" : "${NEAR_INTENTS_SIGNER_PRIVATE_KEY:=}" : "${NOTIFICATION_NTFY_TOKEN:=}" +: "${OPERATOR_DASHBOARD_AUTH_PASSWORD:=}" secret_value() { local key="$1" @@ -158,6 +159,10 @@ if [[ -z "$NOTIFICATION_NTFY_TOKEN" ]]; then NOTIFICATION_NTFY_TOKEN="$(secret_value NOTIFICATION_NTFY_TOKEN)" fi +if [[ -z "$OPERATOR_DASHBOARD_AUTH_PASSWORD" ]]; then + OPERATOR_DASHBOARD_AUTH_PASSWORD="$(secret_value OPERATOR_DASHBOARD_AUTH_PASSWORD)" +fi + if [[ -z "$POSTGRES_PASSWORD" ]]; then POSTGRES_PASSWORD="$(python3 - <<'PY' import secrets @@ -170,6 +175,8 @@ if [[ -z "$POSTGRES_URL" ]]; then POSTGRES_URL="postgresql://unrip:${POSTGRES_PASSWORD}@postgres:5432/unrip" fi +: "${OPERATOR_DASHBOARD_AUTH_PASSWORD:?set OPERATOR_DASHBOARD_AUTH_PASSWORD or pre-create OPERATOR_DASHBOARD_AUTH_PASSWORD in $APP_SECRET_NAME}" + echo "bootstrapping namespace $PROJECT_NAMESPACE" kubectl apply -f "$ROOT_DIR/deploy/k8s/base/namespace.yaml" @@ -178,6 +185,7 @@ secret_args=( --from-literal=NEAR_INTENTS_API_KEY="$NEAR_INTENTS_API_KEY" --from-literal=POSTGRES_PASSWORD="$POSTGRES_PASSWORD" --from-literal=POSTGRES_URL="$POSTGRES_URL" + --from-literal=OPERATOR_DASHBOARD_AUTH_PASSWORD="$OPERATOR_DASHBOARD_AUTH_PASSWORD" ) if [[ -n "$NEAR_INTENTS_SIGNER_PRIVATE_KEY" ]]; then secret_args+=(--from-literal=NEAR_INTENTS_SIGNER_PRIVATE_KEY="$NEAR_INTENTS_SIGNER_PRIVATE_KEY") @@ -224,6 +232,9 @@ fi if [[ -n "${FORGEJO_ADMIN_PASSWORD:-}" ]]; then forgejo_args+=(--admin-password "$FORGEJO_ADMIN_PASSWORD") fi +if [[ -n "${OPERATOR_DASHBOARD_AUTH_PASSWORD:-}" ]]; then + forgejo_args+=(--operator-dashboard-auth-password "$OPERATOR_DASHBOARD_AUTH_PASSWORD") +fi python3 "$ROOT_DIR/scripts/deploy/forgejo_repo_bootstrap.py" \ --forgejo-url "$FORGEJO_URL" \ diff --git a/scripts/deploy/forgejo_repo_bootstrap.py b/scripts/deploy/forgejo_repo_bootstrap.py index 944df0e..74f4014 100755 --- a/scripts/deploy/forgejo_repo_bootstrap.py +++ b/scripts/deploy/forgejo_repo_bootstrap.py @@ -107,6 +107,7 @@ def main(): parser.add_argument('--project-namespace', required=True) parser.add_argument('--project-deployments', required=True) parser.add_argument('--project-registry-secret-name', required=True) + parser.add_argument('--operator-dashboard-auth-password') args = parser.parse_args() client = ForgejoClient(args.forgejo_url, args.admin_username, args.admin_password, args.token) @@ -120,6 +121,14 @@ def main(): kubeconfig_b64 = base64.b64encode(Path(args.ci_kubeconfig).read_bytes()).decode() client.upsert_secret(args.repo_owner, args.repo_name, 'KUBECONFIG_B64', kubeconfig_b64) print('upserted repo action secret KUBECONFIG_B64') + if args.operator_dashboard_auth_password: + client.upsert_secret( + args.repo_owner, + args.repo_name, + 'OPERATOR_DASHBOARD_AUTH_PASSWORD', + args.operator_dashboard_auth_password, + ) + print('upserted repo action secret OPERATOR_DASHBOARD_AUTH_PASSWORD') client.upsert_variable(args.repo_owner, args.repo_name, 'REGISTRY_HOST', args.registry_host) client.upsert_variable(args.repo_owner, args.repo_name, 'PROJECT_NAME', args.project_name) diff --git a/test/bootstrap_script_static_test.py b/test/bootstrap_script_static_test.py index 68e5bc0..09f20fc 100644 --- a/test/bootstrap_script_static_test.py +++ b/test/bootstrap_script_static_test.py @@ -13,6 +13,13 @@ class BootstrapScriptStaticTest(unittest.TestCase): self.assertIn('ghcr.io/example/unrip:bootstrap', source) self.assertIn('kubectl kustomize "$ROOT_DIR/deploy/k8s/base"', source) + def test_bootstrap_preserves_operator_dashboard_password_secret(self): + source = (ROOT / 'scripts/deploy/bootstrap.sh').read_text() + self.assertIn('OPERATOR_DASHBOARD_AUTH_PASSWORD="$(secret_value OPERATOR_DASHBOARD_AUTH_PASSWORD)"', source) + self.assertIn('OPERATOR_DASHBOARD_AUTH_PASSWORD:?set OPERATOR_DASHBOARD_AUTH_PASSWORD', source) + self.assertIn('--from-literal=OPERATOR_DASHBOARD_AUTH_PASSWORD="$OPERATOR_DASHBOARD_AUTH_PASSWORD"', source) + self.assertIn('--operator-dashboard-auth-password "$OPERATOR_DASHBOARD_AUTH_PASSWORD"', source) + if __name__ == '__main__': unittest.main() diff --git a/test/deploy-workflow-static.test.mjs b/test/deploy-workflow-static.test.mjs new file mode 100644 index 0000000..bdd951c --- /dev/null +++ b/test/deploy-workflow-static.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const workflow = readFileSync(new URL('../.forgejo/workflows/deploy.yml', import.meta.url), 'utf8'); +const forgejoBootstrap = readFileSync(new URL('../scripts/deploy/forgejo_repo_bootstrap.py', import.meta.url), 'utf8'); + +test('deploy workflow upserts dashboard password before applying public dashboard manifest', () => { + assert.match(workflow, /name: Upsert runtime secrets/); + assert.match(workflow, /OPERATOR_DASHBOARD_AUTH_PASSWORD: \$\{\{ secrets\.OPERATOR_DASHBOARD_AUTH_PASSWORD \}\}/); + assert.match(workflow, /missing required repo action secret OPERATOR_DASHBOARD_AUTH_PASSWORD/); + assert.match(workflow, /patch secret "\$\{PROJECT_NAME\}-secrets"/); + assert.match(workflow, /--patch-file "\$patch_file"/); +}); + +test('Forgejo bootstrap can publish dashboard password as a repo action secret', () => { + assert.match(forgejoBootstrap, /--operator-dashboard-auth-password/); + assert.match(forgejoBootstrap, /OPERATOR_DASHBOARD_AUTH_PASSWORD/); + assert.match(forgejoBootstrap, /upserted repo action secret OPERATOR_DASHBOARD_AUTH_PASSWORD/); +}); diff --git a/test/operator-dashboard-public-ingress-static.test.mjs b/test/operator-dashboard-public-ingress-static.test.mjs new file mode 100644 index 0000000..c9da5df --- /dev/null +++ b/test/operator-dashboard-public-ingress-static.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const manifest = readFileSync(new URL('../deploy/k8s/base/unrip.yaml', import.meta.url), 'utf8'); + +test('operator dashboard production manifest uses basic auth with password from secret', () => { + assert.match(manifest, /OPERATOR_DASHBOARD_AUTH_MODE:\s+basic/); + assert.match(manifest, /OPERATOR_DASHBOARD_AUTH_USERNAME:\s+admin/); + assert.doesNotMatch(manifest, /OPERATOR_DASHBOARD_AUTH_PASSWORD:/); + assert.match(manifest, /secretRef:\s*\n\s+name: unrip-secrets/); +}); + +test('operator dashboard has a public Traefik ingress with TLS', () => { + assert.match(manifest, /kind: Ingress\s*\nmetadata:\s*\n\s+name: operator-dashboard/); + assert.match(manifest, /cert-manager\.io\/cluster-issuer: letsencrypt-production/); + assert.match(manifest, /ingressClassName: traefik/); + assert.match(manifest, /host: doran\.133011\.xyz/); + assert.match(manifest, /secretName: operator-dashboard-tls/); + assert.match(manifest, /service:\s*\n\s+name: operator-dashboard\s*\n\s+port:\s*\n\s+number: 8090/); +});