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
50 changes: 50 additions & 0 deletions .github/workflows/zarf-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Zarf Lint

on:
pull_request:
paths:
- "zarf.yaml"
- "helm/airgapped-demo/**"
push:
branches: [main]
paths:
- "zarf.yaml"
- "helm/airgapped-demo/**"

jobs:
lint:
name: Zarf dev lint
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Zarf
run: |
curl -sL "https://github.com/zarf-dev/zarf/releases/download/v0.78.0/zarf_v0.78.0_Linux_amd64" \
-o /usr/local/bin/zarf
chmod +x /usr/local/bin/zarf
zarf version

# ── Lint the package definition ──────────────────────────────────────────
# zarf dev lint validates:
# - zarf.yaml schema and required fields
# - chart paths exist and are readable
# - variables are properly declared
# - image references use valid template syntax
#
# It does NOT pull images or require registry credentials — safe to run on
# every PR with no secrets needed.
- name: Lint zarf.yaml
run: zarf dev lint .

- name: Summary
if: always()
run: |
echo "### Zarf lint result" >> $GITHUB_STEP_SUMMARY
echo "Validated \`zarf.yaml\` and \`helm/airgapped-demo\` chart." >> $GITHUB_STEP_SUMMARY
echo "No images were pulled — this check requires no registry credentials." >> $GITHUB_STEP_SUMMARY
126 changes: 126 additions & 0 deletions .github/workflows/zarf-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,129 @@ jobs:
echo "**Image tag:** \`${{ steps.tag.outputs.value }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Registry:** \`${{ steps.login-ecr.outputs.registry }}\`" >> $GITHUB_STEP_SUMMARY
ls -lh zarf-package-*.tar.zst >> $GITHUB_STEP_SUMMARY

# ── Deploy to airgapped cluster ───────────────────────────────────────────────
#
# TODO: In a real air-gap scenario this step cannot run directly from the CI
# runner — the cluster has no internet path back. Two recommended approaches:
#
# Option A — Object storage handoff:
# Upload the tarball to S3 (or equivalent), grant the target environment
# read-only access to that bucket (IAM role or signed URL), and pull from
# there. The cluster side never needs outbound internet; only the S3 endpoint
# needs to be reachable (or proxied) from within the air-gap.
#
# Option B — Offline USB transfer:
# Download the artifact from the GitHub Actions run, copy it to a USB drive
# together with the Zarf binary and the init package, then deploy from the
# drive on the target machine. See ZARF.md § "Air-Gap USB Deployment".
#
# For now this job deploys to the lab cluster directly (the runner can reach it
# via a self-hosted runner or VPN). Replace with Option A/B for production.
#
deploy:
name: Deploy to Airgapped Cluster
runs-on: ubuntu-latest
needs: package
# Guard: only deploy automatically on workflow_run (post-merge); skip on
# manual dispatch unless explicitly set. Remove this condition once Option A
# or B is implemented and the step is fully automated.
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' }}

permissions:
contents: read
id-token: write
actions: read # required to download artifacts from the package job

steps:
- name: Checkout
uses: actions/checkout@v4

# ── Recompute the tag (same logic as package job) ────────────────────────
- name: Compute image tag
id: tag
run: |
SHORT_SHA=$(echo "${{ github.event.workflow_run.head_sha }}" | cut -c1-7)
echo "value=sha-${SHORT_SHA}" >> $GITHUB_OUTPUT

# ── Download the tarball produced by the package job ─────────────────────
- name: Download Zarf package artifact
uses: actions/download-artifact@v4
with:
name: zarf-package-${{ steps.tag.outputs.value }}
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

# ── Install Zarf CLI ─────────────────────────────────────────────────────
- name: Install Zarf
run: |
curl -sL "https://github.com/zarf-dev/zarf/releases/download/v0.78.0/zarf_v0.78.0_Linux_amd64" \
-o /usr/local/bin/zarf
chmod +x /usr/local/bin/zarf
zarf version

# ── Snapshot: what is running before the deploy ──────────────────────────
- name: Pre-deploy state
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
run: |
echo "$KUBECONFIG_B64" | base64 -d > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
echo "### Pre-deploy state" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
kubectl -n airgapped-demo get deployments \
-o custom-columns='NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,READY:.status.readyReplicas' \
>> $GITHUB_STEP_SUMMARY 2>&1 || echo "(namespace not yet deployed)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

# ── Deploy ───────────────────────────────────────────────────────────────
- name: Deploy Zarf package
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
run: |
export KUBECONFIG=/tmp/kubeconfig
PACKAGE=$(ls zarf-package-*.tar.zst)
zarf package deploy "$PACKAGE" --confirm

# ── Post-deploy fixes: namespace label + registry secret + restart ───────
- name: Post-deploy fixes
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
APP_NS: airgapped-demo
run: |
export KUBECONFIG=/tmp/kubeconfig
kubectl label namespace "$APP_NS" zarf.dev/agent=mutate --overwrite
kubectl get secret private-registry -n zarf -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'private-registry','namespace':'$APP_NS'}; print(json.dumps(d))" \
| kubectl apply -f -
kubectl -n "$APP_NS" rollout restart deployment/airgapped-demo-backend deployment/airgapped-demo-frontend
kubectl -n "$APP_NS" rollout status deployment/airgapped-demo-backend --timeout=120s
kubectl -n "$APP_NS" rollout status deployment/airgapped-demo-frontend --timeout=120s

# ── Verify: confirm the running image matches what was just deployed ──────
- name: Post-deploy verification
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
EXPECTED_TAG: ${{ steps.tag.outputs.value }}
run: |
export KUBECONFIG=/tmp/kubeconfig
echo "### Post-deploy verification" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
kubectl -n airgapped-demo get deployments \
-o custom-columns='NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,READY:.status.readyReplicas' \
>> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

# Assert both deployments are running the expected tag
BACKEND_TAG=$(kubectl -n airgapped-demo get deployment airgapped-demo-backend \
-o jsonpath='{.spec.template.spec.containers[0].image}' | grep -o 'sha-[a-f0-9]*$')
FRONTEND_TAG=$(kubectl -n airgapped-demo get deployment airgapped-demo-frontend \
-o jsonpath='{.spec.template.spec.containers[0].image}' | grep -o 'sha-[a-f0-9]*$')

echo "Expected tag : $EXPECTED_TAG"
echo "Backend tag : $BACKEND_TAG"
echo "Frontend tag : $FRONTEND_TAG"

[ "$BACKEND_TAG" = "$EXPECTED_TAG" ] || { echo "FAIL: backend tag mismatch"; exit 1; }
[ "$FRONTEND_TAG" = "$EXPECTED_TAG" ] || { echo "FAIL: frontend tag mismatch"; exit 1; }
echo "✓ Both deployments are running $EXPECTED_TAG"
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# airgapped-demo

A full-stack demo application (React frontend + Express backend) designed to be packaged and deployed on air-gapped Kubernetes clusters using [Zarf](https://docs.zarf.dev).

The repository covers the complete lifecycle: local development, container image publishing to AWS ECR, Zarf packaging in CI, and deployment to a k3s cluster with no external connectivity required at deploy time.

---

## Project Structure

```
├── backend/ Express.js REST API
├── frontend/ React (Vite) single-page app
├── helm/airgapped-demo/ Helm chart (deployed by Zarf)
├── terraform/ AWS infrastructure (ECR, IAM, OIDC)
├── .github/workflows/ CI/CD pipelines
├── zarf.yaml Zarf package definition
├── Makefile Local development targets
├── ZARF.md Air-gap packaging & deployment guide
├── CI.md CI/CD pipeline reference
└── SETUP.md Initial infrastructure setup guide
```

---

## Quick Start

### Local (Docker Compose)

```bash
docker compose up
# Frontend: http://localhost:5173
# Backend: http://localhost:3001/api/health
```

### Local Air-Gap (k3s via Zarf)

```bash
make registry # start local Docker registry on localhost:5001
make package-local # build images → push → create Zarf tarball
make deploy # deploy to k3s cluster + post-deploy fixes
```

See [ZARF.md](ZARF.md) for the complete local and USB air-gap workflow.

---

## CI/CD Pipeline

| Workflow | Trigger | What it does |
|---|---|---|
| **Build & Push Images** | push to `main` | Builds frontend + backend images, pushes to ECR with tag `sha-<7char>` |
| **Zarf Package** | after Build & Push succeeds | Creates a `.tar.zst` bundle with images + Helm chart; uploads as a GitHub Actions artifact |
| **Deploy** | after Zarf Package succeeds | Downloads the bundle, deploys to the k3s cluster, verifies running tags match |

See [CI.md](CI.md) for secrets, permissions, and pipeline details.

---

## API Endpoints

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/health` | Health check — returns status, version, timestamp |
| `GET` | `/api/info` | Runtime info — app name, version, hostname, environment |
| `GET` | `/api/items` | Sample item list |

---

## AWS Infrastructure

Managed with Terraform in `terraform/`. Resources:

| Resource | Purpose |
|---|---|
| ECR repositories | `airgapped-demo/backend`, `airgapped-demo/frontend` |
| IAM OIDC provider | Federated identity for GitHub Actions |
| IAM roles | `cicd` (push images, no long-lived credentials) |

See [SETUP.md](SETUP.md) for provisioning steps.

---

## Air-Gap Delivery

The Zarf bundle (~66 MB) is self-contained — it embeds the container images and the Helm chart. The target cluster requires no internet access or ECR credentials at deploy time.

For offline delivery to a machine with no internet access, see [ZARF.md § Air-Gap USB Deployment](ZARF.md#air-gap-usb-deployment).
Loading