Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions .env.staging.example
Original file line number Diff line number Diff line change
@@ -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 -
186 changes: 186 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
@@ -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.',
});
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ __pycache__/

# Secrets
.env
.env.staging
*.pem
kubeconfig.yaml
15 changes: 15 additions & 0 deletions k8s/staging/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions k8s/staging/server-patch.yaml
Original file line number Diff line number Diff line change
@@ -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"