diff --git a/.github/workflows/zarf-lint.yml b/.github/workflows/zarf-lint.yml new file mode 100644 index 0000000..0b337ba --- /dev/null +++ b/.github/workflows/zarf-lint.yml @@ -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 diff --git a/.github/workflows/zarf-package.yml b/.github/workflows/zarf-package.yml index 3daf104..0d32d61 100644 --- a/.github/workflows/zarf-package.yml +++ b/.github/workflows/zarf-package.yml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..610666b --- /dev/null +++ b/README.md @@ -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).