From e22826e5650d5fe5565f1d4ed00d2d3926c07211 Mon Sep 17 00:00:00 2001 From: mpabi Date: Sun, 12 Apr 2026 20:07:29 +0200 Subject: [PATCH] feat(sol): add public edge ingress and tls --- .gitea/workflows/deploy-sol-public-edge.yaml | 95 +++++++++++++++++++ bootstrap/gitea-actions/README.md | 4 +- .../scripts/sync-sol-ssh-org-secret.sh | 36 +++++++ environments/sol/public-edge/README.md | 46 +++++++++ .../public-edge/cert-manager-helmchart.yaml | 20 ++++ .../public-edge/cert-manager-namespace.yaml | 4 + .../sol/public-edge/kustomization.yaml | 8 ++ .../letsencrypt-prod-clusterissuer.yaml | 14 +++ .../scripts/check-sol-public-edge.sh | 49 ++++++++++ .../trade-frontend-public-ingress.yaml | 24 +++++ .../sol/public-edge/traefik-helmchart.yaml | 53 +++++++++++ 11 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy-sol-public-edge.yaml create mode 100755 bootstrap/gitea-actions/scripts/sync-sol-ssh-org-secret.sh create mode 100644 environments/sol/public-edge/README.md create mode 100644 environments/sol/public-edge/cert-manager-helmchart.yaml create mode 100644 environments/sol/public-edge/cert-manager-namespace.yaml create mode 100644 environments/sol/public-edge/kustomization.yaml create mode 100644 environments/sol/public-edge/letsencrypt-prod-clusterissuer.yaml create mode 100755 environments/sol/public-edge/scripts/check-sol-public-edge.sh create mode 100644 environments/sol/public-edge/trade-frontend-public-ingress.yaml create mode 100644 environments/sol/public-edge/traefik-helmchart.yaml diff --git a/.gitea/workflows/deploy-sol-public-edge.yaml b/.gitea/workflows/deploy-sol-public-edge.yaml new file mode 100644 index 0000000..1898343 --- /dev/null +++ b/.gitea/workflows/deploy-sol-public-edge.yaml @@ -0,0 +1,95 @@ +name: deploy-sol-public-edge + +on: + push: + branches: + - main + paths: + - environments/sol/public-edge/** + - .gitea/workflows/deploy-sol-public-edge.yaml + workflow_dispatch: + +jobs: + apply: + runs-on: k3s-deploy + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Materialize kubeconfig + env: + K3S_KUBECONFIG_B64: ${{ secrets.K3S_KUBECONFIG_B64 }} + run: | + test -n "$K3S_KUBECONFIG_B64" + printf '%s' "$K3S_KUBECONFIG_B64" | base64 -d >/tmp/kubeconfig + chmod 600 /tmp/kubeconfig + + - name: Install kubectl + run: | + curl -fsSL -o /tmp/kubectl https://dl.k8s.io/release/v1.34.6/bin/linux/amd64/kubectl + install -m 0755 /tmp/kubectl /usr/local/bin/kubectl + kubectl version --client + + - name: Apply public edge resources + env: + KUBECONFIG: /tmp/kubeconfig + run: | + kubectl apply -f environments/sol/public-edge/cert-manager-namespace.yaml + kubectl apply -f environments/sol/public-edge/cert-manager-helmchart.yaml + kubectl apply -f environments/sol/public-edge/traefik-helmchart.yaml + + - name: Wait for cert-manager + env: + KUBECONFIG: /tmp/kubeconfig + run: | + for attempt in $(seq 1 60); do + if kubectl -n cert-manager get deploy cert-manager cert-manager-cainjector cert-manager-webhook >/dev/null 2>&1; then + break + fi + sleep 5 + done + kubectl -n cert-manager rollout status deploy/cert-manager --timeout=300s + kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=300s + kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=300s + + - name: Wait for traefik + env: + KUBECONFIG: /tmp/kubeconfig + run: | + for attempt in $(seq 1 60); do + if kubectl -n kube-system get ds traefik >/dev/null 2>&1; then + break + fi + sleep 5 + done + kubectl -n kube-system rollout status ds/traefik --timeout=300s + + - name: Wait for issuer + env: + KUBECONFIG: /tmp/kubeconfig + run: | + kubectl wait --for=condition=Established crd/certificates.cert-manager.io --timeout=300s + kubectl apply -f environments/sol/public-edge/letsencrypt-prod-clusterissuer.yaml + kubectl apply -f environments/sol/public-edge/trade-frontend-public-ingress.yaml + kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True clusterissuer/letsencrypt-prod --timeout=300s + + - name: Verify HTTP ingress + env: + KUBECONFIG: /tmp/kubeconfig + run: | + kubectl -n trade-r001-canary get ingress trade-frontend-public -o wide + curl -fsS --resolve trade.mpabi.pl:80:149.50.96.162 http://trade.mpabi.pl/healthz + + - name: Report TLS state + env: + KUBECONFIG: /tmp/kubeconfig + run: | + resolved_ip="$(getent ahostsv4 trade.mpabi.pl | awk 'NR==1 {print $1}')" + echo "resolved_ip=${resolved_ip:-}" + kubectl -n trade-r001-canary get certificate,order,challenge || true + if [ "${resolved_ip:-}" = "149.50.96.162" ]; then + kubectl -n trade-r001-canary wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True certificate/trade-mpabi-pl-tls --timeout=600s + curl -fsS --resolve trade.mpabi.pl:443:149.50.96.162 https://trade.mpabi.pl/healthz + else + echo "DNS still does not point trade.mpabi.pl to 149.50.96.162; skipped TLS readiness gate." + fi diff --git a/bootstrap/gitea-actions/README.md b/bootstrap/gitea-actions/README.md index 17af3f7..9dcddf4 100644 --- a/bootstrap/gitea-actions/README.md +++ b/bootstrap/gitea-actions/README.md @@ -16,7 +16,8 @@ This module bootstraps a single organization-scoped Gitea Actions runner for `tr 1. Prepare the org registration token secret in `gitea-actions`. 2. Apply the kustomize module on `sol`. 3. Create or refresh the deployer kubeconfig and sync it to the `trade-next` org secrets. -4. Push a workflow to `trade-gitops` and let the runner execute deployment jobs. +4. Create or refresh the SSH key secret `SOL_SSH_PRIVATE_KEY_B64` for host-side automation. +5. Push workflows to `trade-next` repositories and let the runner execute deployment jobs. ## Bootstrap Commands @@ -24,6 +25,7 @@ From the repository root: ```bash ./bootstrap/gitea-actions/scripts/bootstrap-sol.sh +./bootstrap/gitea-actions/scripts/sync-sol-ssh-org-secret.sh ``` ## Notes diff --git a/bootstrap/gitea-actions/scripts/sync-sol-ssh-org-secret.sh b/bootstrap/gitea-actions/scripts/sync-sol-ssh-org-secret.sh new file mode 100755 index 0000000..5150143 --- /dev/null +++ b/bootstrap/gitea-actions/scripts/sync-sol-ssh-org-secret.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ORG="${ORG:-trade-next}" +SECRET_NAME="${SECRET_NAME:-SOL_SSH_PRIVATE_KEY_B64}" +GITEA_URL="${GITEA_URL:-https://gitea.mpabi.pl}" +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/home/user/dev/mcp/tools/tokens/gitea.token}" +SOL_SSH_KEY="${SOL_SSH_KEY:-/home/user/dev/mcp/keys/mpabi/mevnode_mcp}" + +gitea_token() { + cut -d: -f2- "$GITEA_TOKEN_FILE" | head -n1 | tr -d '[:space:]' +} + +API_TOKEN="$(gitea_token)" +if [ -z "$API_TOKEN" ]; then + echo "Gitea API token is empty" >&2 + exit 1 +fi + +if [ ! -f "$SOL_SSH_KEY" ]; then + echo "Missing SSH key: $SOL_SSH_KEY" >&2 + exit 1 +fi + +KEY_B64="$(base64 -w0 "$SOL_SSH_KEY")" +PAYLOAD="$(jq -nc --arg data "$KEY_B64" --arg description "base64-encoded SSH key for sol host automation" '{data:$data,description:$description}')" + +curl -fsS \ + -X PUT \ + -H "Authorization: token ${API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/orgs/${ORG}/actions/secrets/${SECRET_NAME}" \ + >/dev/null + +echo "Organization secret ${ORG}/${SECRET_NAME} updated" diff --git a/environments/sol/public-edge/README.md b/environments/sol/public-edge/README.md new file mode 100644 index 0000000..bb97511 --- /dev/null +++ b/environments/sol/public-edge/README.md @@ -0,0 +1,46 @@ +# sol public edge + +Public ingress and TLS edge for the reconstructed `R001` trade surface on `sol`. + +## Purpose + +- install an ingress controller on `sol` +- install `cert-manager` +- define `ClusterIssuer/letsencrypt-prod` +- publish `trade-r001-canary/trade-frontend` on `trade.mpabi.pl` + +## Design + +- ingress controller: `Traefik` +- controller exposure model: `hostPort` on `80` and `443` +- TLS automation: `cert-manager` with `Let's Encrypt` `HTTP-01` +- public host in this phase: `trade.mpabi.pl` +- application auth remains in `trade-frontend`; ingress only terminates traffic + +## Operator Flow + +1. Prepare the host firewall from `trade-next/trade-host-iac`: + +```bash +./sol/public-edge/scripts/prepare-sol-public-edge.sh +``` + +2. Push this repository to `main` and let `deploy-sol-public-edge` apply the cluster-side resources. + +The workflow bootstraps `cert-manager` and `Traefik` first, then applies `ClusterIssuer/letsencrypt-prod` only after the cert-manager CRDs are ready. + +3. Update the authoritative DNS record: + +- `trade.mpabi.pl A 149.50.96.162` + +4. Re-run the smoke checks: + +```bash +./environments/sol/public-edge/scripts/check-sol-public-edge.sh +./environments/sol/trade-r001-canary/scripts/check-sol-canary-smoke.sh +``` + +## Notes + +- Before DNS cutover, the HTTP ingress can still be verified using `curl --resolve`. +- `cert-manager` will not be able to complete the `HTTP-01` challenge until the public `A` record points at `149.50.96.162`. diff --git a/environments/sol/public-edge/cert-manager-helmchart.yaml b/environments/sol/public-edge/cert-manager-helmchart.yaml new file mode 100644 index 0000000..468d0a0 --- /dev/null +++ b/environments/sol/public-edge/cert-manager-helmchart.yaml @@ -0,0 +1,20 @@ +apiVersion: helm.cattle.io/v1 +kind: HelmChart +metadata: + name: cert-manager + namespace: kube-system +spec: + repo: https://charts.jetstack.io + chart: cert-manager + version: v1.20.1 + targetNamespace: cert-manager + createNamespace: true + failurePolicy: abort + valuesContent: |- + crds: + enabled: true + prometheus: + enabled: false + global: + leaderElection: + namespace: cert-manager diff --git a/environments/sol/public-edge/cert-manager-namespace.yaml b/environments/sol/public-edge/cert-manager-namespace.yaml new file mode 100644 index 0000000..c90416f --- /dev/null +++ b/environments/sol/public-edge/cert-manager-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cert-manager diff --git a/environments/sol/public-edge/kustomization.yaml b/environments/sol/public-edge/kustomization.yaml new file mode 100644 index 0000000..9206cdc --- /dev/null +++ b/environments/sol/public-edge/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - cert-manager-namespace.yaml + - cert-manager-helmchart.yaml + - traefik-helmchart.yaml + - letsencrypt-prod-clusterissuer.yaml + - trade-frontend-public-ingress.yaml diff --git a/environments/sol/public-edge/letsencrypt-prod-clusterissuer.yaml b/environments/sol/public-edge/letsencrypt-prod-clusterissuer.yaml new file mode 100644 index 0000000..c04cde9 --- /dev/null +++ b/environments/sol/public-edge/letsencrypt-prod-clusterissuer.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + email: u1@rv32i.pl + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - http01: + ingress: + ingressClassName: traefik diff --git a/environments/sol/public-edge/scripts/check-sol-public-edge.sh b/environments/sol/public-edge/scripts/check-sol-public-edge.sh new file mode 100755 index 0000000..9d21f98 --- /dev/null +++ b/environments/sol/public-edge/scripts/check-sol-public-edge.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_HOST="${TARGET_HOST:-mevnode}" +PUBLIC_IP="${PUBLIC_IP:-149.50.96.162}" +PUBLIC_HOST="${PUBLIC_HOST:-trade.mpabi.pl}" + +ssh_target() { + ssh -o StrictHostKeyChecking=no "$TARGET_HOST" "$@" +} + +echo "[1/5] Host listeners" +ssh_target "sudo ss -ltn '( sport = :80 or sport = :443 )'" + +echo +echo "[2/5] Controller state" +ssh_target ' + set -euo pipefail + sudo k3s kubectl -n kube-system get ds traefik -o wide + sudo k3s kubectl -n cert-manager get deploy -o wide +' + +echo +echo "[3/5] Issuer and ingress" +ssh_target ' + set -euo pipefail + sudo k3s kubectl get clusterissuer letsencrypt-prod -o wide + sudo k3s kubectl -n trade-r001-canary get ingress trade-frontend-public -o wide + sudo k3s kubectl -n trade-r001-canary get certificate,order,challenge 2>/dev/null || true +' + +echo +echo "[4/5] HTTP health via public IP" +curl -fsS --resolve "${PUBLIC_HOST}:80:${PUBLIC_IP}" "http://${PUBLIC_HOST}/healthz" +echo + +echo +echo "[5/5] DNS and TLS state" +resolved_ip="$(getent ahostsv4 "${PUBLIC_HOST}" | awk 'NR==1 {print $1}')" +echo "resolved_ip=${resolved_ip:-}" +if [ "${resolved_ip:-}" = "${PUBLIC_IP}" ]; then + curl -fsS --resolve "${PUBLIC_HOST}:443:${PUBLIC_IP}" "https://${PUBLIC_HOST}/healthz" + echo +else + echo "TLS check skipped until ${PUBLIC_HOST} resolves to ${PUBLIC_IP}" +fi + +echo +echo "Public edge check completed for ${PUBLIC_HOST}" diff --git a/environments/sol/public-edge/trade-frontend-public-ingress.yaml b/environments/sol/public-edge/trade-frontend-public-ingress.yaml new file mode 100644 index 0000000..475e2a2 --- /dev/null +++ b/environments/sol/public-edge/trade-frontend-public-ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: trade-frontend-public + namespace: trade-r001-canary + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - trade.mpabi.pl + secretName: trade-mpabi-pl-tls + rules: + - host: trade.mpabi.pl + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: trade-frontend + port: + number: 8081 diff --git a/environments/sol/public-edge/traefik-helmchart.yaml b/environments/sol/public-edge/traefik-helmchart.yaml new file mode 100644 index 0000000..1645f9e --- /dev/null +++ b/environments/sol/public-edge/traefik-helmchart.yaml @@ -0,0 +1,53 @@ +apiVersion: helm.cattle.io/v1 +kind: HelmChart +metadata: + name: traefik + namespace: kube-system +spec: + repo: https://traefik.github.io/charts + chart: traefik + version: 39.0.7 + targetNamespace: kube-system + createNamespace: false + failurePolicy: abort + valuesContent: |- + deployment: + kind: DaemonSet + gateway: + enabled: false + gatewayClass: + enabled: false + experimental: + kubernetesGateway: + enabled: false + api: + dashboard: false + ingressRoute: + dashboard: + enabled: false + healthcheck: + enabled: false + ports: + traefik: + expose: + default: false + web: + hostPort: 80 + allowACMEByPass: true + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + hostPort: 443 + service: + spec: + type: ClusterIP + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi