Skip to content
Merged
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
13 changes: 10 additions & 3 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ encrypting the signing-secret column (we protect the backups instead), OpenAPI t
- **Firewalls:** one.com's network firewall + ufw. ufw allows 22, 80, 443 and the k3s networks
10.42.0.0/16 and 10.43.0.0/16. one.com's firewall allows 22, 80 and 443.
- **kubectl:** only on the server (`ssh relay`, then `kubectl …`). Port 6443 stays closed.
- **Deploy path:** GitHub Actions (environment `production`, main only) logs in as the Linux
user `deploy`, whose key can only start `/usr/local/bin/relay-deploy`. The script deploys a
commit only if it is on main, using the Kubernetes account `deployer` (namespace `relay`
only, no Secrets). Which folder is applied how: [`k8s/README.md`](../k8s/README.md).
- **Domain:** `myrelay.tech`, DNS at get.tech. A records for `api`, `cdn` (the Relay dashboard)
and `grafana` (the ops dashboard: metrics and logs) all point to the VPS. **No AAAA records**:
the cluster is IPv4-only, and Let's Encrypt prefers IPv6 when it exists.
Expand All @@ -148,9 +152,12 @@ Phase 0 (server foundation) is done: VPS hardened, k3s, cert-manager with Let's
issuers, and a temporary hello-world app on https://api.myrelay.tech with a trusted
certificate.

Phase 1 (walking skeleton) has started: HTTP → HTTPS redirect (1.1) and DNS for cdn and
grafana (1.2) are done. Next: 1.3, the API skeleton in `api/` (Bun 1.4.4 + Fastify) with
`/health/live`, `/health/ready`, `/version` and the first `bun test`.
Phase 1 (walking skeleton) is well under way. Done: HTTP → HTTPS redirect (1.1), DNS for cdn
and grafana (1.2), the API skeleton in `api/` with its Docker image (1.3–1.4, PR #11), the CI
pipeline that tests every PR and pushes the image from main (1.5, PR #12), and the deploy path
(1.6): a `deploy` user whose key can only run `relay-deploy`, and a `deployer` account limited
to namespace `relay`. Next: 1.7, the API manifests in `k8s/app/`, then 1.8, the first deploy
through the pipeline.

The plan in order: 1 walking skeleton · 2 database foundation · 3 core API · 4 worker ·
5 dashboard on cdn (Abdikhani, in parallel) · 6 backups + Terraform (Abdikhani, in parallel) ·
Expand Down
55 changes: 55 additions & 0 deletions infra/server/relay-deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# relay-deploy: the one program the deploy key may run (step 1.6c).
#
# GitHub Actions runs: ssh deploy@<vps> <commit-sha>
# SSH ignores that command and starts this script instead (the forced command in
# /home/deploy/.ssh/authorized_keys). What the client asked for arrives in
# $SSH_ORIGINAL_COMMAND, so for us it is the commit to deploy. Treat it as untrusted.
#
# Installed by root as /usr/local/bin/relay-deploy, owned by root, so the deploy user
# can run it but not change it. Everything it prints shows up in the GitHub Actions log.
set -euo pipefail

REPO=/home/deploy/relay # our repo, cloned by setup-deploy-user.sh
export KUBECONFIG=/home/deploy/.kube/config # the "deployer" badge

fail() { echo "relay-deploy: $*" >&2; exit 1; }

# 0. One deploy at a time: a second deploy waits here for up to 5 minutes.
# The lock is released by itself when the script ends, even if it crashes.
exec 9>/home/deploy/.relay-deploy.lock
flock --wait 300 9 || fail "another deploy is still running"

SHA="${SSH_ORIGINAL_COMMAND:-}"

# 1. Gate 1: the input must be a full commit SHA, exactly 40 characters of 0-9 and a-f.
# It runs first, because $SHA is used in git and sed below.
if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
fail "invalid commit SHA: $SHA"
fi

# 2. Gate 2: the commit must be on main, which means it was merged through a reviewed PR.
git -C "$REPO" fetch --quiet origin main
if ! git -C "$REPO" merge-base --is-ancestor "$SHA" "origin/main"; then
fail "commit $SHA is not part of origin/main's history"
fi

# 3. Move our copy of the repo to exactly that commit, so the manifests match the image.
# --force: this copy is only for deploys, so any stray local change is thrown away.
git -C "$REPO" checkout --quiet --force --detach "$SHA"
[ -d "$REPO/k8s/app" ] || fail "no k8s/app folder at commit $SHA"

# 4. Apply every manifest in k8s/app, with the placeholder __GIT_SHA__ replaced by the commit.
# The "---" line between files keeps them separate YAML documents.
echo "deploying $SHA"
for file in "$REPO"/k8s/app/*.yaml; do
printf '\n---\n'
sed "s/__GIT_SHA__/$SHA/g" "$file"
done | kubectl apply -f -

# 5. Wait until the new pods are ready. If they never are, go back to the previous version.
if ! kubectl rollout status deployment/relay-api --timeout=180s; then
kubectl rollout undo deployment/relay-api || echo "relay-deploy: nothing to roll back to"
fail "rollout of $SHA failed"
fi
echo "deployed $SHA"
73 changes: 73 additions & 0 deletions infra/server/setup-deploy-user.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# One-time setup of the "deploy" user that GitHub Actions logs in as (step 1.6b).
# Safe to run again: each part checks what already exists.
#
# Run it on the VPS as root, with the public half of the deploy key:
# sudo bash setup-deploy-user.sh "ssh-ed25519 AAAA... relay-github-deploy"
set -euo pipefail

PUBKEY="${1:?usage: setup-deploy-user.sh '<deploy public key>'}"
KUBECTL="kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml" # the admin kubeconfig, root only
HOME_DIR=/home/deploy

# 1. The user: its own home folder, no password (so it can't log in with one), no sudo.
if ! id deploy >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash deploy
fi

# 2. The key, locked to one program. "restrict" also turns off terminals and forwarding,
# and command= runs relay-deploy whatever the client asks for (installed in step 1.6c).
install -d -m 700 -o deploy -g deploy "$HOME_DIR/.ssh"
echo "restrict,command=\"/usr/local/bin/relay-deploy\" $PUBKEY" > "$HOME_DIR/.ssh/authorized_keys"
chown deploy:deploy "$HOME_DIR/.ssh/authorized_keys"
chmod 600 "$HOME_DIR/.ssh/authorized_keys"

# 3. A token for the "deployer" ServiceAccount (k8s/platform/deployer.yaml).
# Kubernetes fills in the token by itself; deleting this Secret revokes it.
$KUBECTL apply -f - <<'YAML'
apiVersion: v1
kind: Secret
metadata:
name: deployer-token
namespace: relay
annotations:
kubernetes.io/service-account.name: deployer
type: kubernetes.io/service-account-token
YAML
TOKEN=""
for _ in $(seq 1 20); do
TOKEN=$($KUBECTL -n relay get secret deployer-token -o jsonpath='{.data.token}' | base64 -d || true)
[ -n "$TOKEN" ] && break
sleep 1
done
[ -n "$TOKEN" ] || { echo "the deployer token never appeared" >&2; exit 1; }

# 4. The deploy script's kubeconfig: the "deployer" badge, nothing more.
CA=$($KUBECTL config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
install -d -m 700 -o deploy -g deploy "$HOME_DIR/.kube"
cat > "$HOME_DIR/.kube/config" <<YAML
apiVersion: v1
kind: Config
clusters:
- name: relay
cluster:
server: https://127.0.0.1:6443
certificate-authority-data: $CA
users:
- name: deployer
user:
token: $TOKEN
contexts:
- name: deployer
context: { cluster: relay, user: deployer, namespace: relay }
current-context: deployer
YAML
chown deploy:deploy "$HOME_DIR/.kube/config"
chmod 600 "$HOME_DIR/.kube/config"

# 5. A copy of our public repo. The deploy script reads the manifests from it at the exact commit.
if [ ! -d "$HOME_DIR/relay/.git" ]; then
sudo -u deploy git clone --quiet https://github.com/devinder-dev/relay.git "$HOME_DIR/relay"
fi

echo "deploy user ready"
71 changes: 71 additions & 0 deletions k8s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Kubernetes manifests

How each folder gets onto the cluster, and who may apply it.

| Folder | What's in it | Applied by |
|---|---|---|
| `k8s/platform/` | Cluster setup: Let's Encrypt issuers, Traefik settings, the `relay` namespace and the `deployer` account | One of us, by hand, after the PR is reviewed |
| `k8s/app/` (from step 1.7) | Our app in namespace `relay`: Deployments, Services, Ingresses, ConfigMaps | Only the pipeline, through `relay-deploy` |
| `k8s/hello/` | The temporary hello-world test app | By hand. Removed when the API takes over in step 1.7 |

## Rules

- **Platform files are never applied by the pipeline.** They decide who may do what, so a
person applies them after review. Otherwise the pipeline could widen its own rights.
- **`k8s/app/` holds only what the `deployer` account may change:** Deployments, Services,
Ingresses and ConfigMaps. A Namespace or a Secret there would make the deploy fail with
"forbidden".
- **Images in `k8s/app/` use the tag `__GIT_SHA__`.** `relay-deploy` replaces it with the
commit it deploys, so manifests and image always come from the same commit. Don't apply
these files by hand: the tag would be wrong.
- **No Secrets in git.** They are created on the server with `kubectl create secret`.
- **Git and the cluster must match.** Everything applied by hand is committed in the same PR.

## Applying a platform file

From the repo root on your laptop. Check first with a server-side dry run (nothing is saved),
then apply:

```bash
ssh relay 'kubectl --kubeconfig ~/.kube/config apply --dry-run=server -f -' < k8s/platform/deployer.yaml
ssh relay 'kubectl --kubeconfig ~/.kube/config apply -f -' < k8s/platform/deployer.yaml
```

## How `k8s/app/` gets applied: the deploy path

```
merge to main ─► GitHub Actions (environment "production", main only)
1. test, build, push ghcr.io/devinder-dev/relay-api:<sha>
2. ssh deploy@<vps> <sha>
│
VPS Linux user "deploy": its key may only start /usr/local/bin/relay-deploy
│
relay-deploy: <sha> must be 40 hex characters and part of main's history,
then check out that commit, apply k8s/app/ with the tag,
wait for the rollout, or undo it if it fails
│ every kubectl call uses the "deployer" account:
│ namespace relay only, no Secrets
▼
Deployment relay-api runs the new pods
```

The server-side parts live in `infra/server/` and are installed by hand, by an admin:

| File | What it does | Install or update |
|---|---|---|
| `setup-deploy-user.sh` | One time: the `deploy` user, its forced command, the `deployer` kubeconfig, a clone of the repo | `ssh relay 'cat > /tmp/setup.sh' < infra/server/setup-deploy-user.sh`, then `ssh -t relay "sudo bash /tmp/setup.sh '<deploy public key>'"` |
| `relay-deploy.sh` | The only program the deploy key may run | `ssh relay 'cat > /tmp/relay-deploy' < infra/server/relay-deploy.sh`, then `ssh -t relay 'sudo install -o root -g root -m 755 /tmp/relay-deploy /usr/local/bin/relay-deploy'` |

`relay-deploy` is owned by root, so the `deploy` user can run it but not change it. If the
script changes in a PR, install it again after the merge, so the server matches git.

**Deploying by hand** (admins only, for example to redeploy an older commit from main):

```bash
ssh -t relay 'sudo -u deploy env SSH_ORIGINAL_COMMAND=<sha> /usr/local/bin/relay-deploy'
```

**If the deploy key leaks:** delete the `DEPLOY_SSH_KEY` secret in GitHub, empty
`/home/deploy/.ssh/authorized_keys` on the server, then make a new key and run
`setup-deploy-user.sh` again. To cut off the `deployer` account itself, delete the Secret
`deployer-token` in namespace `relay`; its token stops working at once.
63 changes: 63 additions & 0 deletions k8s/platform/deployer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# The deploy path's Kubernetes side (step 1.6a).
# The pipeline's deploy script runs as the ServiceAccount "deployer". It may update the app
# inside the "relay" namespace and nothing else: no Secrets, no database, no other namespaces.
# Applied by hand by one of us (platform files are never applied by the pipeline).

# The folder our app lives in, apart from system parts like kube-system and cert-manager.
apiVersion: v1
kind: Namespace
metadata:
name: relay
---
# An identity for a program (the deploy script), not for a person.
apiVersion: v1
kind: ServiceAccount
metadata:
name: deployer
namespace: relay
---
# What "deployer" may do, only inside the "relay" namespace.
# Just what steps 1.7–1.8 need. Later phases add rules when they need them:
# jobs (migrations, phase 2), networkpolicies (phase 4), horizontalpodautoscalers (phase 8).
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer
namespace: relay
rules:
# Create and update the app's Deployments: kubectl apply needs get + create + patch.
# No "delete": a deploy replaces things, it never removes them.
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Read-only: "kubectl rollout status" and "rollout undo" look at the ReplicaSets
# (one per version) and the pods to follow and reverse a rollout.
- apiGroups: ["apps"]
resources: ["replicasets"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
# The stable name in front of the pods, and the app's non-secret settings.
- apiGroups: [""]
resources: ["services", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# The route from api.myrelay.tech to the Service.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
# Gives the Role above to the ServiceAccount.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deployer
namespace: relay
subjects:
- kind: ServiceAccount
name: deployer
namespace: relay
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: deployer