diff --git a/.env.example b/.env.example index 086df43..753be2f 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ GITHUB_CLIENT_SECRET= # Cookie signing key (generate with: openssl rand -hex 32) COOKIE_SECRET= + +# Copy this file to .env for local dev, or .env.staging for staging. +# Load staging config with: cp .env.staging .env +# +# For staging (staging.remolt.dev), create a separate GitHub OAuth app +# with callback URL: https://staging.remolt.dev/auth/callback diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..341ec0d --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,16 @@ +# Staging: staging.remolt.dev +# Create a GitHub OAuth app with callback URL: https://staging.remolt.dev/auth/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Cookie signing key (generate with: openssl rand -hex 32) +COOKIE_SECRET= + +# Apply to cluster with: +# source .env.staging +# kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - +# kubectl -n remolt-staging create secret generic remolt-auth \ +# --from-literal=GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID \ +# --from-literal=GITHUB_CLIENT_SECRET=$GITHUB_CLIENT_SECRET \ +# --from-literal=COOKIE_SECRET=$COOKIE_SECRET \ +# --dry-run=client -o yaml | kubectl apply -f - diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 0000000..83d2153 --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,186 @@ +name: Staging Deploy + +on: + # pull_request_target runs workflow from main branch, not the PR branch. + # This prevents a malicious PR from modifying this workflow to exfiltrate secrets. + pull_request_target: + types: [labeled, unlabeled, closed] + +env: + REGISTRY: ghcr.io + NAMESPACE: nthh + +permissions: + contents: read + packages: write + pull-requests: write + +jobs: + # ---------- DEPLOY ---------- + deploy-staging: + if: >- + github.event.action == 'labeled' && + github.event.label.name == 'deploy' + runs-on: ubuntu-latest + # Uses repo-level KUBECONFIG_BASE64 secret; OAuth secrets are pre-provisioned in cluster + + steps: + # Ensure only one PR uses staging at a time + - name: Check for conflicting staging deploys + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEPLOY_PRS=$(gh pr list --repo ${{ github.repository }} --label deploy --state open --json number --jq 'length') + if [ "$DEPLOY_PRS" -gt 1 ]; then + echo "::error::Another PR already has the 'deploy' label. Remove it first." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + # Build server image from PR branch + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + build-args: | + COMMIT_SHA=${{ github.event.pull_request.head.sha }} + tags: | + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/remolt-server:staging-${{ github.event.pull_request.number }} + cache-from: type=gha,scope=staging-server + cache-to: type=gha,mode=max,scope=staging-server + + # Deploy to K8s + - uses: azure/setup-kubectl@v4 + + - run: | + echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > /tmp/kubeconfig + echo "KUBECONFIG=/tmp/kubeconfig" >> "$GITHUB_ENV" + + # Ensure namespace exists (secret remolt-auth is pre-provisioned in cluster) + - name: Create namespace + run: kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - + + # Apply staging kustomization (RBAC, server deployment, network policy) + - name: Apply staging manifests + run: kubectl kustomize --load-restrictor=LoadRestrictionsNone k8s/staging/ | kubectl apply -f - + + # Set the image to the one we just built + - name: Deploy server image + run: | + kubectl -n remolt-staging set image deployment/remolt-server \ + server=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/remolt-server:staging-${{ github.event.pull_request.number }} + kubectl -n remolt-staging rollout status deployment/remolt-server --timeout=300s + + # Add staging ingress rule to cloudflared config (same tunnel, new hostname) + - name: Configure tunnel routing + run: | + kubectl -n remolt get configmap cloudflared-config -o jsonpath='{.data.config\.yaml}' > /tmp/cf-config.yaml + + if ! grep -q 'staging.remolt.dev' /tmp/cf-config.yaml; then + python3 -c " + import yaml + with open('/tmp/cf-config.yaml') as f: + cfg = yaml.safe_load(f) + staging_rule = { + 'hostname': 'staging.remolt.dev', + 'service': 'http://remolt-server.remolt-staging.svc.cluster.local:8080' + } + # Insert before the catch-all rule (last entry) + cfg['ingress'].insert(-1, staging_rule) + with open('/tmp/cf-config.yaml', 'w') as f: + yaml.dump(cfg, f, default_flow_style=False) + " + kubectl -n remolt create configmap cloudflared-config \ + --from-file=config.yaml=/tmp/cf-config.yaml \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n remolt rollout restart deployment/cloudflared + kubectl -n remolt rollout status deployment/cloudflared --timeout=120s + fi + + # Post staging URL on the PR + - name: Comment staging URL + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: [ + '## Staging deployed', + '', + 'https://staging.remolt.dev', + '', + `Commit: \`${context.payload.pull_request.head.sha.slice(0, 7)}\``, + `Image: \`remolt-server:staging-${context.payload.pull_request.number}\``, + ].join('\n'), + }); + + # ---------- TEARDOWN ---------- + teardown-staging: + if: >- + (github.event.action == 'unlabeled' && github.event.label.name == 'deploy') || + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'deploy')) + runs-on: ubuntu-latest + # Uses repo-level KUBECONFIG_BASE64 secret; OAuth secrets are pre-provisioned in cluster + + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-kubectl@v4 + + - run: | + echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > /tmp/kubeconfig + echo "KUBECONFIG=/tmp/kubeconfig" >> "$GITHUB_ENV" + + # Delete sandbox pods first + - name: Clean up sandbox pods + run: | + kubectl -n remolt-staging delete pods -l remolt.managed=true --ignore-not-found=true + + # Delete staging resources (but not namespace or secrets) + - name: Delete staging deployment + run: | + kubectl kustomize --load-restrictor=LoadRestrictionsNone k8s/staging/ | kubectl delete --ignore-not-found=true -f - + + # Remove staging ingress rule from cloudflared config + - name: Remove tunnel routing + run: | + kubectl -n remolt get configmap cloudflared-config -o jsonpath='{.data.config\.yaml}' > /tmp/cf-config.yaml + + if grep -q 'staging.remolt.dev' /tmp/cf-config.yaml; then + python3 -c " + import yaml + with open('/tmp/cf-config.yaml') as f: + cfg = yaml.safe_load(f) + cfg['ingress'] = [r for r in cfg['ingress'] if r.get('hostname') != 'staging.remolt.dev'] + with open('/tmp/cf-config.yaml', 'w') as f: + yaml.dump(cfg, f, default_flow_style=False) + " + kubectl -n remolt create configmap cloudflared-config \ + --from-file=config.yaml=/tmp/cf-config.yaml \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n remolt rollout restart deployment/cloudflared + kubectl -n remolt rollout status deployment/cloudflared --timeout=120s + fi + + - name: Comment teardown + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: '## Staging torn down\n\nThe staging environment has been cleaned up.', + }); diff --git a/.gitignore b/.gitignore index 836ee9b..001d55a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ __pycache__/ # Secrets .env +.env.staging *.pem kubeconfig.yaml diff --git a/k8s/staging/kustomization.yaml b/k8s/staging/kustomization.yaml new file mode 100644 index 0000000..2ff5369 --- /dev/null +++ b/k8s/staging/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../rbac.yaml + - ../server.yaml + - ../network-policy.yaml + +namespace: remolt-staging + +patches: + - path: server-patch.yaml + target: + kind: Deployment + name: remolt-server diff --git a/k8s/staging/server-patch.yaml b/k8s/staging/server-patch.yaml new file mode 100644 index 0000000..1db9c64 --- /dev/null +++ b/k8s/staging/server-patch.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: remolt-server +spec: + template: + spec: + containers: + - name: server + env: + - name: REMOLT_SANDBOX_IMAGE + value: ghcr.io/nthh/remolt-sandbox:latest + - name: REMOLT_MAX_SESSIONS + value: "2" + - name: REMOLT_MAX_IDLE_SECONDS + value: "1800" + - name: REMOLT_WARM_POOL + value: "0" + - name: REMOLT_SANDBOX_BANDWIDTH + value: "100mbit" + - name: REMOLT_ALLOWED_ORIGINS + value: "https://staging.remolt.dev" + - name: REMOLT_NAMESPACE + value: "remolt-staging" + - name: REMOLT_MAX_USER_SESSIONS + value: "1"