diff --git a/deploy-oci-site/action.yaml b/deploy-oci-site/action.yaml deleted file mode 100644 index 0ecf1ca..0000000 --- a/deploy-oci-site/action.yaml +++ /dev/null @@ -1,97 +0,0 @@ -name: Deploy OCI Site -description: Build a Nix flake OCI image, push to registry, and deploy via Nomad - -inputs: - domain: - description: 'Domain the site is served at (e.g. toph.so)' - required: true - - registry: - description: 'Container registry host' - required: false - default: 'registry.toph.so' - - s3-endpoint: - description: 'S3 endpoint for the Nix binary cache' - required: false - default: 'https://s3.toph.so' - - nomad-addr: - description: 'Nomad API address' - required: false - default: 'http://172.17.0.1:4646' - - smoke-test: - description: 'Run a smoke test against the domain after deploy' - required: false - default: 'true' - -runs: - using: composite - steps: - - name: Install tools - shell: bash - run: nix profile install nixpkgs#skopeo nixpkgs#nomad - - - name: Build OCI image - shell: bash - run: nix build .#ociImage --out-link result-image - - - name: Push image to registry - shell: bash - run: | - IMAGE_TAG=$(nix eval --raw .#packages.x86_64-linux.ociImage.imageTag) - skopeo copy \ - docker-archive:./result-image \ - docker://${{ inputs.registry }}/${{ inputs.domain }}:${IMAGE_TAG} - skopeo copy \ - docker-archive:./result-image \ - docker://${{ inputs.registry }}/${{ inputs.domain }}:latest - echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV - - - name: Sign and push Nix closure to cache - shell: bash - run: | - echo "${NIX_SIGNING_KEY}" > /tmp/nix-key - nix store sign -k /tmp/nix-key --recursive ./result-image - nix copy \ - --to "s3://nix-cache?endpoint=${{ inputs.s3-endpoint }}&access-key-id=${S3_ACCESS_KEY}&secret-access-key=${S3_SECRET_KEY}" \ - ./result-image - rm /tmp/nix-key - env: - NIX_SIGNING_KEY: ${{ env.NIX_SIGNING_KEY }} - S3_ACCESS_KEY: ${{ env.S3_ACCESS_KEY }} - S3_SECRET_KEY: ${{ env.S3_SECRET_KEY }} - - - name: Register Nomad job - shell: bash - run: | - nomad job run "${{ github.action_path }}/static-site.hcl" - env: - NOMAD_ADDR: ${{ inputs.nomad-addr }} - - - name: Dispatch deployment - shell: bash - run: | - nomad job dispatch \ - -meta "image_tag=${IMAGE_TAG}" \ - -meta "domain=${{ inputs.domain }}" \ - static-site - env: - NOMAD_ADDR: ${{ inputs.nomad-addr }} - - - name: Smoke test - if: inputs.smoke-test == 'true' - shell: bash - run: | - echo "Waiting for deployment..." - for i in $(seq 1 12); do - if curl -sf --max-time 5 "https://${{ inputs.domain }}/" > /dev/null; then - echo "OK — https://${{ inputs.domain }}/ is up" - exit 0 - fi - echo "Attempt ${i}/12 — retrying in 5s..." - sleep 5 - done - echo "Smoke test failed: https://${{ inputs.domain }}/ did not respond" - exit 1 diff --git a/deploy-oci-site/static-site.hcl b/deploy-oci-site/static-site.hcl deleted file mode 100644 index b87ad3e..0000000 --- a/deploy-oci-site/static-site.hcl +++ /dev/null @@ -1,50 +0,0 @@ -job "static-site" { - namespace = "static-sites" - type = "service" - - parameterized { - meta_required = ["image_tag", "domain"] - } - - group "site" { - count = 1 - - network { - port "http" { to = 8080 } - } - - service { - name = "static-site-${NOMAD_META_domain}" - port = "http" - provider = "nomad" - - tags = [ - "traefik.enable=true", - "traefik.http.routers.${NOMAD_META_domain}.rule=Host(`${NOMAD_META_domain}`)", - "traefik.http.routers.${NOMAD_META_domain}.entrypoints=websecure", - "traefik.http.routers.${NOMAD_META_domain}.tls.certresolver=letsencrypt", - ] - - check { - type = "http" - path = "/" - interval = "30s" - timeout = "5s" - } - } - - task "server" { - driver = "docker" - - config { - image = "registry.toph.so/${NOMAD_META_domain}:${NOMAD_META_image_tag}" - ports = ["http"] - } - - resources { - cpu = 50 - memory = 64 - } - } - } -} diff --git a/deploy-static-site/action.yaml b/deploy-static-site/action.yaml new file mode 100644 index 0000000..12ac14c --- /dev/null +++ b/deploy-static-site/action.yaml @@ -0,0 +1,98 @@ +name: Deploy Static Site +description: Build site with Nix, push tarball to S3, deploy via Nomad with shared static-server image + +inputs: + domain: + description: 'Domain the site is served at (e.g. toph.so)' + required: true + + nomad-addr: + description: 'Nomad API address' + required: false + default: 'http://172.17.0.1:4646' + + server-image: + description: 'OCI image for the static server' + required: false + default: 'registry.toph.so/static-server:latest' + + datacenter: + description: 'Nomad datacenter' + required: false + default: 'contabo' + + smoke-test: + description: 'Run a smoke test against the domain after deploy' + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Install tools + shell: bash + run: nix profile install nixpkgs#nomad nixpkgs#awscli2 nixpkgs#jq + + - name: Read Nomad vars + shell: bash + run: | + S3_VARS=$(nomad var get -out json static-sites/s3) + echo "AWS_ACCESS_KEY_ID=$(echo "$S3_VARS" | jq -r '.Items.access_key')" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$(echo "$S3_VARS" | jq -r '.Items.secret_key')" >> $GITHUB_ENV + echo "AWS_ENDPOINT_URL=$(echo "$S3_VARS" | jq -r '.Items.endpoint')" >> $GITHUB_ENV + echo "S3_BUCKET=$(echo "$S3_VARS" | jq -r '.Items.bucket')" >> $GITHUB_ENV + + NIX_VARS=$(nomad var get -out json static-sites/nix) + echo "NIX_SIGNING_KEY=$(echo "$NIX_VARS" | jq -r '.Items.signing_key')" >> $GITHUB_ENV + env: + NOMAD_ADDR: ${{ inputs.nomad-addr }} + NOMAD_TOKEN: ${{ env.NOMAD_TOKEN }} + + - name: Build site + shell: bash + run: nix build .#default --out-link result-site + + - name: Sign and push Nix closure to S3 cache + shell: bash + run: | + echo "${NIX_SIGNING_KEY}" > /tmp/nix-key + nix store sign -k /tmp/nix-key --recursive ./result-site + nix copy \ + --to "s3://${S3_BUCKET}?endpoint=${AWS_ENDPOINT_URL}&access-key-id=${AWS_ACCESS_KEY_ID}&secret-access-key=${AWS_SECRET_ACCESS_KEY}" \ + ./result-site + rm /tmp/nix-key + + - name: Upload site tarball to S3 + shell: bash + run: | + SITE_HASH=$(git rev-parse --short=12 HEAD) + echo "SITE_HASH=${SITE_HASH}" >> $GITHUB_ENV + tar czf /tmp/site.tar.gz -C result-site . + aws s3 cp /tmp/site.tar.gz "s3://${S3_BUCKET}/sites/${{ inputs.domain }}/${SITE_HASH}.tar.gz" + + - name: Deploy Nomad job + shell: bash + run: | + python3 "${{ github.action_path }}/generate-job.py" | nomad job run -json - + env: + NOMAD_ADDR: ${{ inputs.nomad-addr }} + NOMAD_TOKEN: ${{ env.NOMAD_TOKEN }} + DOMAIN: ${{ inputs.domain }} + SERVER_IMAGE: ${{ inputs.server-image }} + DATACENTER: ${{ inputs.datacenter }} + + - name: Smoke test + if: inputs.smoke-test == 'true' + shell: bash + run: | + echo "Waiting for deployment..." + for i in $(seq 1 12); do + if curl -sf --max-time 5 "https://${{ inputs.domain }}/" > /dev/null; then + echo "OK — https://${{ inputs.domain }}/ is up" + exit 0 + fi + echo "Attempt ${i}/12 — retrying in 5s..." + sleep 5 + done + echo "Smoke test failed: https://${{ inputs.domain }}/ did not respond" + exit 1 diff --git a/deploy-static-site/generate-job.py b/deploy-static-site/generate-job.py new file mode 100644 index 0000000..3317d76 --- /dev/null +++ b/deploy-static-site/generate-job.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Generate a Nomad job JSON for a static site deployment. +Reads from environment variables, prints JSON to stdout. +Pipe to: nomad job run -json - +""" + +import json +import os +import sys + +domain = os.environ["DOMAIN"] +site_hash = os.environ["SITE_HASH"] +server_image = os.environ.get("SERVER_IMAGE", "registry.toph.so/static-server:latest") +datacenter = os.environ.get("DATACENTER", "contabo") +s3_bucket = os.environ["S3_BUCKET"] + +job_id = "site-" + domain.replace(".", "-") + +startup_cmd = ( + f"mkdir -p /var/www && " + f"aws s3 cp s3://{s3_bucket}/sites/{domain}/{site_hash}.tar.gz - " + f"| tar xz -C /var/www/ && " + f"exec static-web-server --port 8080 --root /var/www" +) + +nomad_template_data = ( + '{{ with nomadVar "static-sites/s3" }}' + "AWS_ACCESS_KEY_ID={{ .access_key }}\n" + "AWS_SECRET_ACCESS_KEY={{ .secret_key }}\n" + "AWS_ENDPOINT_URL={{ .endpoint }}\n" + "{{ end }}" +) + +job = { + "Job": { + "ID": job_id, + "Name": job_id, + "Namespace": "static-sites", + "Type": "service", + "Datacenters": [datacenter], + "Update": { + "MinHealthyTime": 5000000000, # 5s in nanoseconds + "HealthyDeadline": 60000000000, # 60s in nanoseconds + "MaxParallel": 1, + }, + "TaskGroups": [ + { + "Name": "site", + "Count": 1, + "Networks": [ + { + "DynamicPorts": [ + {"Label": "http", "To": 8080} + ] + } + ], + "Services": [ + { + "Name": job_id, + "Provider": "nomad", + "PortLabel": "http", + "Tags": [ + "traefik.enable=true", + f"traefik.http.routers.{job_id}.rule=Host(`{domain}`)", + f"traefik.http.routers.{job_id}.entrypoints=websecure", + f"traefik.http.routers.{job_id}.tls.certresolver=letsencrypt", + ], + "Checks": [ + { + "Type": "http", + "Path": "/", + "Interval": 30000000000, # 30s + "Timeout": 5000000000, # 5s + } + ], + } + ], + "Tasks": [ + { + "Name": "server", + "Driver": "docker", + "Config": { + "image": server_image, + "command": "/bin/bash", + "args": ["-c", startup_cmd], + "ports": ["http"], + }, + "Templates": [ + { + "EmbeddedTmpl": nomad_template_data, + "DestPath": "secrets/s3.env", + "Envvars": True, + } + ], + "Resources": { + "CPU": 100, + "MemoryMB": 128, + }, + } + ], + } + ], + } +} + +json.dump(job, sys.stdout, indent=2) +print() diff --git a/images/.forgejo/workflows/build-images.yaml b/images/.forgejo/workflows/build-images.yaml new file mode 100644 index 0000000..6bc38f5 --- /dev/null +++ b/images/.forgejo/workflows/build-images.yaml @@ -0,0 +1,23 @@ +name: Build and Push Images + +on: + push: + branches: [main] + paths: + - 'images/flake.nix' + workflow_dispatch: + +jobs: + build-static-server: + runs-on: nix + steps: + - uses: actions/checkout@v4 + + - name: Build static-server image + run: nix build ./images#staticServer --out-link result-static-server + + - name: Push to registry + run: | + nix shell nixpkgs#skopeo -c skopeo copy \ + docker-archive:./result-static-server \ + docker://registry.toph.so/static-server:latest diff --git a/images/flake.nix b/images/flake.nix new file mode 100644 index 0000000..a025fec --- /dev/null +++ b/images/flake.nix @@ -0,0 +1,30 @@ +{ + description = "Shared infrastructure OCI images"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in + { + # Single image used by every static-site Nomad job. + # At container startup it downloads the site tarball from S3, then serves it. + # The Nomad job spec overrides Cmd with the domain- and hash-specific fetch+serve command. + packages.${system}.staticServer = pkgs.dockerTools.buildLayeredImage { + name = "static-server"; + tag = "latest"; + contents = with pkgs; [ + static-web-server + awscli2 + bash + coreutils + gnutar + gzip + cacert + ]; + config.ExposedPorts."8080/tcp" = { }; + }; + }; +} diff --git a/site-lib/flake.nix b/site-lib/flake.nix new file mode 100644 index 0000000..420f459 --- /dev/null +++ b/site-lib/flake.nix @@ -0,0 +1,35 @@ +{ + description = "Shared Nix library for static site flakes"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: { + lib.mkSite = + { self + , nixpkgs + , src + , system ? "x86_64-linux" + , buildPhase ? "true" + , installPhase ? '' + mkdir -p $out + cp -r dist/. $out/ + '' + , devPackages ? [] + }: + let + pkgs = nixpkgs.legacyPackages.${system}; + + site = pkgs.stdenv.mkDerivation { + name = "site"; + inherit src buildPhase installPhase; + }; + in + { + packages.${system}.default = site; + + devShells.${system}.default = pkgs.mkShell { + packages = devPackages; + }; + }; + }; +}