feat: add control panel
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# Plan-13 local production-like smoke:
|
||||
# TLS self-signed + mock OIDC IdP + oauth2-proxy + nginx auth_request + CP API.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
|
||||
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
|
||||
BASE="https://localhost:18443"
|
||||
|
||||
mkdir -p "$TLS_DIR"
|
||||
if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$TLS_DIR/tls.key" \
|
||||
-out "$TLS_DIR/tls.crt" \
|
||||
-subj "/CN=localhost" \
|
||||
-days 1 >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "$1"
|
||||
docker compose -f "$COMPOSE" logs --tail=120 control-panel-api control-panel-ui oauth2-proxy idp || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker compose -f "$COMPOSE" up -d --build >/tmp/casan-cp-local-smoke-up.log 2>&1 || {
|
||||
cat /tmp/casan-cp-local-smoke-up.log
|
||||
exit 1
|
||||
}
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -k -s -I "$BASE/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
JAR="$(mktemp)"
|
||||
trap 'rm -f "$JAR"; cleanup' EXIT
|
||||
|
||||
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/" -o /tmp/casan-cp-local-index.html -w "%{http_code}" > /tmp/casan-cp-local-index.code
|
||||
INDEX_CODE="$(cat /tmp/casan-cp-local-index.code)"
|
||||
if [[ "$INDEX_CODE" != "200" ]] || ! grep -q "CASAN Ops Console" /tmp/casan-cp-local-index.html; then
|
||||
echo "CP_LOCAL_SMOKE_FAIL ui_http=$INDEX_CODE"
|
||||
tail -100 /tmp/casan-cp-local-index.html
|
||||
fail "CP_LOCAL_SMOKE_FAIL ui_unexpected"
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/api/v1/settings" -o /tmp/casan-cp-local-settings.json -w "%{http_code}" > /tmp/casan-cp-local-settings.code
|
||||
SETTINGS_CODE="$(cat /tmp/casan-cp-local-settings.code)"
|
||||
[[ "$SETTINGS_CODE" == "200" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
SETTINGS_CODE="$(cat /tmp/casan-cp-local-settings.code)"
|
||||
if [[ "$SETTINGS_CODE" != "200" ]]; then
|
||||
echo "CP_LOCAL_SMOKE_FAIL settings_http=$SETTINGS_CODE"
|
||||
cat /tmp/casan-cp-local-settings.json
|
||||
fail "CP_LOCAL_SMOKE_FAIL settings_unexpected"
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/api/v1/command" -o /tmp/casan-cp-local-command.json -w "%{http_code}" > /tmp/casan-cp-local-command.code
|
||||
COMMAND_CODE="$(cat /tmp/casan-cp-local-command.code)"
|
||||
[[ "$COMMAND_CODE" == "200" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
COMMAND_CODE="$(cat /tmp/casan-cp-local-command.code)"
|
||||
if [[ "$COMMAND_CODE" != "200" ]]; then
|
||||
echo "CP_LOCAL_SMOKE_FAIL command_http=$COMMAND_CODE"
|
||||
cat /tmp/casan-cp-local-command.json
|
||||
fail "CP_LOCAL_SMOKE_FAIL command_unexpected"
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
data = json.load(open('/tmp/casan-cp-local-settings.json'))
|
||||
actor = data.get('data', {}).get('actor', {})
|
||||
assert actor.get('actor') == 'oidc-ops', actor
|
||||
assert actor.get('role') == 'org-admin', actor
|
||||
assert data.get('success') is True
|
||||
command = json.load(open('/tmp/casan-cp-local-command.json'))
|
||||
widgets = command.get('data', {}).get('widgets', {})
|
||||
assert command.get('success') is True
|
||||
assert len(widgets) == 8, widgets.keys()
|
||||
for widget in widgets.values():
|
||||
envelope = widget.get('envelope', {})
|
||||
assert {'source', 'artifact_path', 'commit', 'run_at', 'verified', 'status'} <= set(envelope), envelope
|
||||
print('CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin')
|
||||
PY
|
||||
|
||||
CASAN_CP_BASE_URL="$BASE" \
|
||||
CASAN_CP_ALLOW_INSECURE=1 \
|
||||
CASAN_CP_COOKIE_JAR="$JAR" \
|
||||
bash "$ROOT/packages/casan-control-panel/scripts/managed-prod-smoke.sh" >/tmp/casan-cp-local-managed-smoke.out
|
||||
cat /tmp/casan-cp-local-managed-smoke.out
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Smoke a deployed managed Control Panel endpoint.
|
||||
#
|
||||
# Required:
|
||||
# CASAN_CP_BASE_URL=https://control-panel.example.com
|
||||
#
|
||||
# Optional:
|
||||
# CASAN_CP_COOKIE_JAR=/path/to/cookies.txt # authenticated browser/session cookie jar
|
||||
# CASAN_CP_ALLOW_INSECURE=1 # only for local labs/self-signed certs
|
||||
#
|
||||
# Without a cookie jar this proves auth is enforced. With a cookie jar it also proves
|
||||
# the API identity mapping and Command Center contract behind the auth proxy.
|
||||
|
||||
BASE="${CASAN_CP_BASE_URL:-}"
|
||||
COOKIE_JAR="${CASAN_CP_COOKIE_JAR:-}"
|
||||
|
||||
fail() {
|
||||
echo "CP_MANAGED_SMOKE_FAIL $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -n "$BASE" ]] || fail "missing_base_url env=CASAN_CP_BASE_URL"
|
||||
BASE="${BASE%/}"
|
||||
if [[ "$BASE" != https://* && "${CASAN_CP_ALLOW_INSECURE:-0}" != "1" ]]; then
|
||||
fail "base_url_must_be_https"
|
||||
fi
|
||||
|
||||
CURL=(curl -sS)
|
||||
if [[ "${CASAN_CP_ALLOW_INSECURE:-0}" == "1" ]]; then
|
||||
CURL+=(-k)
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
root_code="$("${CURL[@]}" -o "$tmp/root.html" -w "%{http_code}" "$BASE/" || true)"
|
||||
if [[ "$root_code" == "200" ]]; then
|
||||
fail "unauth_root_returned_200"
|
||||
fi
|
||||
|
||||
spoof_code="$("${CURL[@]}" \
|
||||
-H 'X-CASAN-Actor: spoofed-admin' \
|
||||
-H 'X-CASAN-Role: org-admin' \
|
||||
-H 'X-CASAN-Groups: casan-org-admin' \
|
||||
-o "$tmp/spoof.json" \
|
||||
-w "%{http_code}" \
|
||||
"$BASE/api/v1/settings" || true)"
|
||||
if [[ "$spoof_code" == "200" ]]; then
|
||||
fail "spoofed_identity_bypass"
|
||||
fi
|
||||
|
||||
if [[ -z "$COOKIE_JAR" ]]; then
|
||||
echo "CP_MANAGED_SMOKE_PARTIAL unauth_protected=true spoof_blocked=true authenticated=false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -f "$COOKIE_JAR" ]] || fail "missing_cookie_jar path=$COOKIE_JAR"
|
||||
|
||||
settings_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-o "$tmp/settings.json" -w "%{http_code}" "$BASE/api/v1/settings" || true)"
|
||||
if [[ "$settings_code" != "200" ]]; then
|
||||
cat "$tmp/settings.json" || true
|
||||
fail "settings_http=$settings_code"
|
||||
fi
|
||||
|
||||
command_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-o "$tmp/command.json" -w "%{http_code}" "$BASE/api/v1/command" || true)"
|
||||
if [[ "$command_code" != "200" ]]; then
|
||||
cat "$tmp/command.json" || true
|
||||
fail "command_http=$command_code"
|
||||
fi
|
||||
|
||||
python3 - "$tmp/settings.json" "$tmp/command.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
settings = json.load(open(sys.argv[1]))
|
||||
assert settings.get("success") is True, settings
|
||||
actor = settings.get("data", {}).get("actor", {})
|
||||
assert actor.get("actor") and actor.get("actor") != "anonymous", actor
|
||||
assert actor.get("role") and actor.get("role") != "unknown", actor
|
||||
|
||||
command = json.load(open(sys.argv[2]))
|
||||
assert command.get("success") is True, command
|
||||
widgets = command.get("data", {}).get("widgets", {})
|
||||
assert len(widgets) == 8, widgets.keys()
|
||||
for widget in widgets.values():
|
||||
envelope = widget.get("envelope", {})
|
||||
required = {"source", "artifact_path", "commit", "run_at", "verified", "status"}
|
||||
assert required <= set(envelope), envelope
|
||||
print(f"CP_MANAGED_SMOKE_PASS actor={actor.get('actor')} role={actor.get('role')} widgets={len(widgets)}")
|
||||
PY
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Validate the managed-production Control Panel handoff without printing secrets.
|
||||
# This does not contact the enterprise IdP; it proves the host has the required
|
||||
# TLS/OIDC files and that values are not still local/mock placeholders.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
COMPOSE="${CASAN_CP_COMPOSE:-$ROOT/docker-compose.control-panel.yml}"
|
||||
TLS_DIR="${CASAN_CP_TLS_DIR:-/opt/casan-control-panel/tls}"
|
||||
OAUTH_ENV="${CASAN_CP_OAUTH_ENV:-/opt/casan-control-panel/oauth2-proxy.env}"
|
||||
|
||||
fail() {
|
||||
echo "CP_PROD_READINESS_FAIL $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo "PASS: $1"
|
||||
}
|
||||
|
||||
value_of() {
|
||||
local key="$1"
|
||||
sed -n -E "s/^${key}=//p" "$OAUTH_ENV" | tail -1
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local path="$1"
|
||||
[[ -f "$path" ]] || fail "missing_file path=$path"
|
||||
[[ -s "$path" ]] || fail "empty_file path=$path"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value
|
||||
value="$(value_of "$key")"
|
||||
[[ -n "$value" ]] || fail "missing_env key=$key file=$OAUTH_ENV"
|
||||
case "$value" in
|
||||
*replace-with*|*example.com*|*localhost*|*127.0.0.1*|*idp:8080*)
|
||||
fail "placeholder_env key=$key"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
docker compose -f "$COMPOSE" config >/tmp/casan-cp-prod-compose-config.txt
|
||||
pass "docker compose config"
|
||||
|
||||
require_file "$TLS_DIR/tls.crt"
|
||||
require_file "$TLS_DIR/tls.key"
|
||||
openssl x509 -in "$TLS_DIR/tls.crt" -noout >/dev/null
|
||||
pass "tls certificate/key present"
|
||||
|
||||
require_file "$OAUTH_ENV"
|
||||
for key in \
|
||||
OAUTH2_PROXY_PROVIDER \
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL \
|
||||
OAUTH2_PROXY_CLIENT_ID \
|
||||
OAUTH2_PROXY_CLIENT_SECRET \
|
||||
OAUTH2_PROXY_COOKIE_SECRET \
|
||||
OAUTH2_PROXY_REDIRECT_URL \
|
||||
OAUTH2_PROXY_OIDC_GROUPS_CLAIM
|
||||
do
|
||||
require_env "$key"
|
||||
done
|
||||
|
||||
[[ "$(value_of OAUTH2_PROXY_PROVIDER)" == "oidc" ]] || fail "provider_must_be_oidc"
|
||||
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == "true" ]] || fail "cookie_secure_must_be_true"
|
||||
[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == "true" ]] || fail "xauthrequest_must_be_true"
|
||||
[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == "false" ]] || fail "pass_access_token_must_be_false"
|
||||
[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == "false" ]] || fail "pass_authorization_header_must_be_false"
|
||||
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "issuer_must_be_https"
|
||||
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == https://*"/oauth2/callback" ]] || fail "redirect_url_must_be_https_callback"
|
||||
[[ "$(value_of OAUTH2_PROXY_OIDC_GROUPS_CLAIM)" == "groups" ]] || fail "groups_claim_must_be_groups"
|
||||
pass "oauth2-proxy env"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
cp "$TLS_DIR/tls.crt" "$tmp/tls.crt"
|
||||
cp "$TLS_DIR/tls.key" "$tmp/tls.key"
|
||||
docker run --rm \
|
||||
--add-host oauth2-proxy:127.0.0.1 \
|
||||
--add-host control-panel-api:127.0.0.1 \
|
||||
-v "$ROOT/nginx/control-panel.conf:/etc/nginx/conf.d/default.conf:ro" \
|
||||
-v "$tmp:/etc/nginx/tls:ro" \
|
||||
nginx:1.27-alpine nginx -t >/tmp/casan-cp-prod-nginx-test.log 2>&1 || {
|
||||
cat /tmp/casan-cp-prod-nginx-test.log
|
||||
rm -rf "$tmp"
|
||||
fail "nginx_config"
|
||||
}
|
||||
rm -rf "$tmp"
|
||||
pass "nginx config"
|
||||
|
||||
echo "CP_PROD_READINESS_PASS compose=true tls=true oidc=true nginx=true"
|
||||
Reference in New Issue
Block a user