diff --git a/.github/scripts/normalize-private-key.py b/.github/scripts/normalize-private-key.py new file mode 100644 index 0000000..baea94f --- /dev/null +++ b/.github/scripts/normalize-private-key.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import base64 +import os +import sys + + +def strip_outer_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1].strip() + return value + + +def raw_payload() -> str: + return strip_outer_quotes(os.environ["SINGLE_NODE_VPS_SSH_PRIVATE_KEY"].replace("\r", "").strip()) + + +def normalize() -> str: + raw = raw_payload() + candidates = [raw] + + if "\\n" in raw: + candidates.append(strip_outer_quotes(raw.replace("\\n", "\n").strip())) + + try: + decoded = base64.b64decode(raw, validate=True).decode("utf-8").replace("\r", "").strip() + except Exception: + decoded = "" + + if decoded: + candidates.append(strip_outer_quotes(decoded)) + + for candidate in candidates: + if "BEGIN " in candidate and "PRIVATE KEY" in candidate: + return candidate.rstrip("\n") + "\n" + + return raw.rstrip("\n") + "\n" + + +def main() -> None: + if len(sys.argv) != 2 or sys.argv[1] != "normalize": + raise SystemExit("usage: normalize-private-key.py normalize") + + sys.stdout.write(normalize()) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build-base-images.yml b/.github/workflows/build-base-images.yml deleted file mode 100644 index 81036e4..0000000 --- a/.github/workflows/build-base-images.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: Build Base Images - -on: - workflow_call: - inputs: - registry: - description: "Target registry" - type: string - required: true - - org: - description: "Target organization" - type: string - required: true - - push_images: - description: "Push images instead of building locally" - type: boolean - default: true - - dockerhub_namespace: - description: "Docker Hub namespace (user/org)" - type: string - default: "cloudneutral" - - workflow_dispatch: - inputs: - registry: - description: "Target registry" - type: string - default: "ghcr.io" - org: - description: "Target organization" - type: string - default: "cloud-neutral-toolkit" - push_images: - description: "Push images instead of building locally" - type: boolean - default: true - dockerhub_namespace: - description: "Docker Hub namespace (user/org)" - type: string - default: "cloudneutral" - - push: - paths: - - "deploy/base-images/**" - -permissions: - contents: read - packages: write - id-token: write - -env: - REGISTRY: ${{ inputs.registry || github.event.inputs.registry || 'ghcr.io' }} - ORG: ${{ inputs.org || github.event.inputs.org || 'cloud-neutral-toolkit' }} - - # Push control - PUSH_IMAGES: ${{ github.event_name == 'push' - || (github.event_name == 'workflow_call' && inputs.push_images) - || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true') }} - -jobs: - build: - strategy: - matrix: - image: - - { name: openresty-geoip, file: deploy/base-images/openresty-geoip.Dockerfile } - - { name: postgres-runtime, file: deploy/base-images/postgres-runtime-wth-extensions.Dockerfile } - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate Auto Tags - id: meta - uses: ./.github/actions/auto-tag - with: - image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.image.name }} - - - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - - - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 - id: build - with: - context: . - file: ${{ matrix.image.file }} - platforms: linux/amd64,linux/arm64 - push: ${{ (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && inputs.push_images || github.event_name == 'push' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - # ------------------------------------------------------------- - # Push to Docker Hub (optional) - # ------------------------------------------------------------- - - name: Login to Docker Hub - if: env.PUSH_IMAGES == 'true' - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 - with: - registry: docker.io - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # ------------------------------------------------------------- - # Re-tag & Push service image to Docker Hub - # ------------------------------------------------------------- - - name: Re-tag & Push Image (Docker Hub) - if: env.PUSH_IMAGES == 'true' - env: - TARGET_NS: ${{ inputs.dockerhub_namespace || github.event.inputs.dockerhub_namespace || 'cloudneutral' }} - run: | - set -euo pipefail - - SERVICE="${{ matrix.image.name }}" - ORIGIN_IMG="${{ env.REGISTRY }}/${{ env.ORG }}/${SERVICE}@${{ steps.build.outputs.digest }}" - TARGET_REPO="docker.io/${TARGET_NS}/${SERVICE}" - - TAG="latest" - docker pull "$ORIGIN_IMG" - docker tag "$ORIGIN_IMG" "$TARGET_REPO:$TAG" - docker push "$TARGET_REPO:$TAG" - - Security-service: - runs-on: ubuntu-latest - needs: build - - strategy: - matrix: - image: - - { name: openresty-geoip, file: deploy/base-images/openresty-geoip.Dockerfile } - - { name: postgres-runtime, file: deploy/base-images/postgres-runtime-wth-extensions.Dockerfile } - - steps: - # ------------------------------------------------------------- - # Checkout source - # ------------------------------------------------------------- - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Resolve short sha tag - id: vars - shell: bash - run: | - set -euo pipefail - echo "sha_short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - - - uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0 - with: - image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.image.name }}:${{ steps.vars.outputs.sha_short }} - output-file: sbom.spdx.json - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: sbom-${{ matrix.image.name }} - path: sbom.spdx.json - - # ------------------------------------------------------------- - # Trivy Vulnerability Scan - # ------------------------------------------------------------- - - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 - with: - image-ref: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.image.name }}:${{ steps.vars.outputs.sha_short }} - severity: HIGH,CRITICAL - exit-code: '1' - - - uses: sigstore/cosign-installer@2e2f661cd4be3a4b891a882064e49d0fed4b7b88 # v3.9.0 - with: - cosign-release: 'v2.4.1' - - - name: Sign Image - env: - COSIGN_EXPERIMENTAL: "true" - run: | - COSIGN_IMAGE=${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.image.name }}:${{ steps.vars.outputs.sha_short }} - cosign sign --yes "$COSIGN_IMAGE" diff --git a/.github/workflows/build-push-ghcr-image.yml b/.github/workflows/build-push-ghcr-image.yml deleted file mode 100644 index 0c07a0d..0000000 --- a/.github/workflows/build-push-ghcr-image.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Build And Push GHCR Image - -on: - workflow_call: - inputs: - image_tag: - description: Optional image tag. Defaults to the current commit SHA. - required: false - type: string - push_latest: - description: Also publish the `latest` tag. - required: false - default: false - type: boolean - workflow_dispatch: - inputs: - image_tag: - description: Optional image tag. Defaults to the current commit SHA. - required: false - type: string - push_latest: - description: Also publish the `latest` tag. - required: false - default: false - type: boolean - -permissions: - contents: read - packages: write - -concurrency: - group: build-push-ghcr-image-accounts-${{ github.ref_name }} - cancel-in-progress: false - -env: - REGISTRY: ghcr.io - IMAGE_REPO_OWNER: ${{ vars.IMAGE_REPO_OWNER || github.repository_owner }} - IMAGE_NAME: accounts - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - name: Check Out Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set Up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - - name: Log In To GHCR - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ vars.GHCR_USERNAME || github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN || github.token }} - - - name: Compute Image Tags - id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO_OWNER }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=${{ inputs.image_tag }},enable=${{ inputs.image_tag != '' }} - type=sha,format=short,enable=${{ inputs.image_tag == '' }} - type=raw,value=latest,enable=${{ inputs.push_latest || github.ref == 'refs/heads/main' }} - - - name: Build And Push Image - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 - with: - context: . - file: Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..04180f2 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,525 @@ +name: Pipeline + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + target_host: + description: Ansible inventory host or alias. + required: false + default: "jp-xhttp-contabo.svc.plus" + type: string + run_apply: + description: Apply deployment (false = dry-run). + required: true + default: true + type: boolean + image_tag: + description: Optional service image tag. Defaults to the current commit SHA. + required: false + default: "" + type: string + push_image: + description: Push the service image to GHCR. + required: true + default: true + type: boolean + push_latest: + description: Also publish the service image with the latest tag. + required: true + default: false + type: boolean + run_base_images: + description: Also run the base image workflow when base image sources exist. + required: true + default: false + type: boolean + push_base_images: + description: Push base images when the base image workflow runs. + required: true + default: true + type: boolean + base_image_registry: + description: Target registry for base images. + required: false + default: "ghcr.io" + type: string + base_image_org: + description: Target organization for base images. + required: false + default: "cloud-neutral-toolkit" + type: string + dockerhub_namespace: + description: Docker Hub namespace for base images. + required: false + default: "cloudneutral" + type: string + +permissions: + contents: read + packages: write + id-token: write + +concurrency: + group: pipeline-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus + +jobs: + prep: + name: Prep + runs-on: ubuntu-latest + outputs: + base_images_exists: ${{ steps.flags.outputs.base_images_exists }} + run_base_images: ${{ steps.flags.outputs.run_base_images }} + push_base_images: ${{ steps.flags.outputs.push_base_images }} + base_image_registry: ${{ steps.flags.outputs.base_image_registry }} + base_image_org: ${{ steps.flags.outputs.base_image_org }} + dockerhub_namespace: ${{ steps.flags.outputs.dockerhub_namespace }} + target_host: ${{ steps.flags.outputs.target_host }} + run_apply: ${{ steps.flags.outputs.run_apply }} + image_tag: ${{ steps.flags.outputs.image_tag }} + push_image: ${{ steps.flags.outputs.push_image }} + push_latest: ${{ steps.flags.outputs.push_latest }} + steps: + - name: Check Out Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Resolve Pipeline Flags + id: flags + run: | + set -euo pipefail + + base_images_exists=false + run_base_images=false + push_base_images=false + push_image=true + push_latest=false + image_tag="" + base_image_registry="ghcr.io" + base_image_org="${{ vars.IMAGE_REPO_OWNER || github.repository_owner }}" + dockerhub_namespace="${{ vars.DOCKERHUB_NAMESPACE || 'cloudneutral' }}" + target_host="${DEFAULT_TARGET_HOST}" + run_apply=true + + if [[ -d deploy/base-images ]] && find deploy/base-images -type f | grep -q .; then + base_images_exists=true + fi + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + target_host="${{ inputs.target_host }}" + [[ "${{ inputs.run_apply }}" == "true" ]] && run_apply=true || run_apply=false + image_tag="${{ inputs.image_tag }}" + [[ "${{ inputs.push_image }}" == "true" ]] && push_image=true || push_image=false + [[ "${{ inputs.push_latest }}" == "true" ]] && push_latest=true || push_latest=false + [[ "${{ inputs.run_base_images }}" == "true" ]] && run_base_images=true || run_base_images=false + [[ "${{ inputs.push_base_images }}" == "true" ]] && push_base_images=true || push_base_images=false + base_image_registry="${{ inputs.base_image_registry }}" + base_image_org="${{ inputs.base_image_org }}" + dockerhub_namespace="${{ inputs.dockerhub_namespace }}" + if [[ "${base_images_exists}" != "true" ]]; then + run_base_images=false + push_base_images=false + fi + else + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + push_image=false + fi + + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + push_latest=true + fi + + if [[ "${base_images_exists}" == "true" ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + base_ref="${{ github.event.pull_request.base.sha }}" + head_ref="${{ github.event.pull_request.head.sha }}" + else + base_ref="${{ github.event.before }}" + head_ref="${{ github.sha }}" + fi + + if [[ -n "${base_ref}" && "${base_ref}" != "0000000000000000000000000000000000000000" ]]; then + if git diff --name-only "${base_ref}" "${head_ref}" | grep -q '^deploy/base-images/'; then + run_base_images=true + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + push_base_images=true + fi + fi + fi + fi + fi + + { + echo "base_images_exists=${base_images_exists}" + echo "run_base_images=${run_base_images}" + echo "push_base_images=${push_base_images}" + echo "base_image_registry=${base_image_registry}" + echo "base_image_org=${base_image_org}" + echo "dockerhub_namespace=${dockerhub_namespace}" + echo "target_host=${target_host}" + echo "run_apply=${run_apply}" + echo "image_tag=${image_tag}" + echo "push_image=${push_image}" + echo "push_latest=${push_latest}" + } >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: prep + runs-on: ubuntu-latest + env: + BASE_REGISTRY: ${{ needs.prep.outputs.base_image_registry }} + BASE_ORG: ${{ needs.prep.outputs.base_image_org }} + PUSH_BASE_IMAGES: ${{ needs.prep.outputs.push_base_images }} + DOCKERHUB_NAMESPACE: ${{ needs.prep.outputs.dockerhub_namespace }} + SERVICE_REGISTRY: ghcr.io + SERVICE_IMAGE_REPO_OWNER: ${{ vars.IMAGE_REPO_OWNER || github.repository_owner }} + SERVICE_IMAGE_NAME: accounts + outputs: + artifact_name: ${{ steps.service_artifact.outputs.name }} + service_image_repo: ${{ steps.service_image.outputs.repo }} + base_images_pushed: ${{ needs.prep.outputs.push_base_images }} + openresty_tag: ${{ steps.openresty_preferred.outputs.tag }} + postgres_tag: ${{ steps.postgres_preferred.outputs.tag }} + steps: + - name: Check Out Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set Up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set Up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Log In To Base Image Registry + if: needs.prep.outputs.run_base_images == 'true' && env.PUSH_BASE_IMAGES == 'true' + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ${{ env.BASE_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate OpenResty Tags + if: needs.prep.outputs.run_base_images == 'true' + id: openresty_meta + uses: ./.github/actions/auto-tag + with: + image: ${{ env.BASE_REGISTRY }}/${{ env.BASE_ORG }}/openresty-geoip + + - name: Resolve OpenResty Preferred Tag + if: needs.prep.outputs.run_base_images == 'true' + id: openresty_preferred + run: | + set -euo pipefail + tag="$(bash .github/scripts/utils/preferred-tag.sh "${{ steps.openresty_meta.outputs.tags }}")" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + + - name: Build OpenResty Base Image + if: needs.prep.outputs.run_base_images == 'true' + id: openresty_build + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: . + file: deploy/base-images/openresty-geoip.Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ env.PUSH_BASE_IMAGES == 'true' }} + tags: ${{ steps.openresty_meta.outputs.tags }} + labels: ${{ steps.openresty_meta.outputs.labels }} + + - name: Generate Postgres Tags + if: needs.prep.outputs.run_base_images == 'true' + id: postgres_meta + uses: ./.github/actions/auto-tag + with: + image: ${{ env.BASE_REGISTRY }}/${{ env.BASE_ORG }}/postgres-runtime + + - name: Resolve Postgres Preferred Tag + if: needs.prep.outputs.run_base_images == 'true' + id: postgres_preferred + run: | + set -euo pipefail + tag="$(bash .github/scripts/utils/preferred-tag.sh "${{ steps.postgres_meta.outputs.tags }}")" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + + - name: Build Postgres Base Image + if: needs.prep.outputs.run_base_images == 'true' + id: postgres_build + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: . + file: deploy/base-images/postgres-runtime-wth-extensions.Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ env.PUSH_BASE_IMAGES == 'true' }} + tags: ${{ steps.postgres_meta.outputs.tags }} + labels: ${{ steps.postgres_meta.outputs.labels }} + + - name: Resolve Service Image Repository + id: service_image + run: echo "repo=${SERVICE_REGISTRY}/${SERVICE_IMAGE_REPO_OWNER}/${SERVICE_IMAGE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Compute Service Image Tags + id: service_meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ${{ steps.service_image.outputs.repo }} + tags: | + type=raw,value=${{ needs.prep.outputs.image_tag }},enable=${{ needs.prep.outputs.image_tag != '' }} + type=sha,format=short,enable=${{ needs.prep.outputs.image_tag == '' }} + type=raw,value=latest,enable=${{ needs.prep.outputs.push_latest == 'true' || github.ref == 'refs/heads/main' }} + + - name: Build Service Image Artifact + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: false + tags: ${{ steps.service_meta.outputs.tags }} + labels: ${{ steps.service_meta.outputs.labels }} + outputs: type=docker,dest=${{ runner.temp }}/accounts-image.tar + + - name: Prepare Service Artifact Bundle + id: service_artifact + run: | + set -euo pipefail + bundle_dir="${RUNNER_TEMP}/service-image-artifact" + mkdir -p "${bundle_dir}" + cp "${RUNNER_TEMP}/accounts-image.tar" "${bundle_dir}/accounts-image.tar" + printf '%s\n' "${{ steps.service_meta.outputs.tags }}" > "${bundle_dir}/tags.txt" + echo "name=accounts-image-artifact" >> "$GITHUB_OUTPUT" + + - name: Upload Service Artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.service_artifact.outputs.name }} + path: ${{ runner.temp }}/service-image-artifact + + deploy: + name: Deploy + needs: + - prep + - build + if: ${{ needs.prep.outputs.push_image == 'true' }} + runs-on: ubuntu-latest + outputs: + image: ${{ needs.build.outputs.service_image_repo }} + preferred_tag: ${{ steps.push.outputs.preferred_tag }} + run_apply: ${{ needs.prep.outputs.run_apply }} + pushed: "true" + steps: + - name: Check Out Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check Out Playbooks Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: x-evor/playbooks + token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }} + path: playbooks + + - name: Download Service Artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: ${{ runner.temp }}/service-image-artifact + + - name: Log In To GHCR + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ vars.GHCR_USERNAME || github.repository_owner }} + password: ${{ secrets.GHCR_TOKEN || github.token }} + + - name: Load And Push Service Image + id: push + run: | + set -euo pipefail + + docker load -i "${RUNNER_TEMP}/service-image-artifact/accounts-image.tar" + + preferred_tag="$(bash .github/scripts/utils/preferred-tag.sh "$(cat "${RUNNER_TEMP}/service-image-artifact/tags.txt")")" + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + docker push "${tag}" + done < "${RUNNER_TEMP}/service-image-artifact/tags.txt" + + echo "preferred_tag=${preferred_tag}" >> "$GITHUB_OUTPUT" + + - name: Resolve Deploy Image Tag + id: deploy_image_tag + run: | + set -euo pipefail + tag="${{ steps.push.outputs.preferred_tag }}" + echo "value=${tag##*:}" >> "$GITHUB_OUTPUT" + + - name: Set Up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.0.0 + with: + python-version: "3.11" + + - name: Install Ansible Runtime + run: | + python -m pip install --upgrade pip + python -m pip install "ansible-core==2.18.3" + + - name: Prepare Runner SSH Access + env: + SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} + run: | + bash ./scripts/github-actions/prepare-ssh.sh \ + "${{ needs.prep.outputs.target_host }}" \ + "${SSH_KNOWN_HOSTS}" + + - name: Run Accounts Deploy Playbook + env: + ACCOUNTS_IMAGE_REPO: ${{ needs.build.outputs.service_image_repo }} + ACCOUNTS_IMAGE_TAG: ${{ steps.deploy_image_tag.outputs.value }} + ACCOUNTS_PULL_IMAGE: "true" + run: | + bash ./scripts/github-actions/deploy-accounts.sh \ + "${{ needs.prep.outputs.target_host }}" \ + "${{ needs.prep.outputs.run_apply }}" \ + "${GITHUB_WORKSPACE}/playbooks" + + - name: Log In To Docker Hub + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push Base Images To Docker Hub + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + env: + BASE_REGISTRY: ${{ needs.prep.outputs.base_image_registry }} + BASE_ORG: ${{ needs.prep.outputs.base_image_org }} + DOCKERHUB_NAMESPACE: ${{ needs.prep.outputs.dockerhub_namespace }} + OPENRESTY_TAG: ${{ needs.build.outputs.openresty_tag }} + POSTGRES_TAG: ${{ needs.build.outputs.postgres_tag }} + run: | + set -euo pipefail + + docker pull "${OPENRESTY_TAG}" + docker tag "${OPENRESTY_TAG}" "docker.io/${DOCKERHUB_NAMESPACE}/openresty-geoip:latest" + docker push "docker.io/${DOCKERHUB_NAMESPACE}/openresty-geoip:latest" + + docker pull "${POSTGRES_TAG}" + docker tag "${POSTGRES_TAG}" "docker.io/${DOCKERHUB_NAMESPACE}/postgres-runtime:latest" + docker push "docker.io/${DOCKERHUB_NAMESPACE}/postgres-runtime:latest" + + validate: + name: Validate + needs: + - prep + - build + - deploy + if: ${{ always() && needs.deploy.result == 'success' && needs.deploy.outputs.pushed == 'true' && needs.deploy.outputs.run_apply == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Check Out Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate Deployed Endpoint + run: bash ./scripts/github-actions/validate-deploy.sh https://accounts.svc.plus + + - name: Generate Service SBOM + uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0 + with: + image: ${{ needs.deploy.outputs.preferred_tag }} + output-file: sbom-accounts.spdx.json + + - name: Upload Service SBOM + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-accounts + path: sbom-accounts.spdx.json + + - name: Scan Service Image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ needs.deploy.outputs.preferred_tag }} + severity: HIGH,CRITICAL + exit-code: '1' + + - name: Install Cosign + uses: sigstore/cosign-installer@2e2f661cd4be3a4b891a882064e49d0fed4b7b88 # v3.9.0 + with: + cosign-release: 'v2.4.1' + + - name: Sign Service Image + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes "${{ needs.deploy.outputs.preferred_tag }}" + + - name: Generate OpenResty SBOM + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0 + with: + image: ${{ needs.build.outputs.openresty_tag }} + output-file: sbom-openresty-geoip.spdx.json + + - name: Upload OpenResty SBOM + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-openresty-geoip + path: sbom-openresty-geoip.spdx.json + + - name: Scan OpenResty Image + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ needs.build.outputs.openresty_tag }} + severity: HIGH,CRITICAL + exit-code: '1' + + - name: Sign OpenResty Image + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes "${{ needs.build.outputs.openresty_tag }}" + + - name: Generate Postgres SBOM + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0 + with: + image: ${{ needs.build.outputs.postgres_tag }} + output-file: sbom-postgres-runtime.spdx.json + + - name: Upload Postgres SBOM + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-postgres-runtime + path: sbom-postgres-runtime.spdx.json + + - name: Scan Postgres Image + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ needs.build.outputs.postgres_tag }} + severity: HIGH,CRITICAL + exit-code: '1' + + - name: Sign Postgres Image + if: needs.prep.outputs.run_base_images == 'true' && needs.build.outputs.base_images_pushed == 'true' + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes "${{ needs.build.outputs.postgres_tag }}" diff --git a/scripts/github-actions/deploy-accounts.sh b/scripts/github-actions/deploy-accounts.sh new file mode 100644 index 0000000..0c15580 --- /dev/null +++ b/scripts/github-actions/deploy-accounts.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_HOST="${1:?target host is required}" +RUN_APPLY="${2:?run_apply flag is required}" +PLAYBOOK_DIR="${3:?playbook dir is required}" + +test -n "${ACCOUNTS_IMAGE_REPO:-}" +test -n "${ACCOUNTS_IMAGE_TAG:-}" + +cd "${PLAYBOOK_DIR}" + +args=( + ansible-playbook + -i inventory.ini + deploy_accounts_svc_plus.yml + -l "${TARGET_HOST}" +) + +if [[ "${RUN_APPLY}" != "true" ]]; then + args+=(-C) +fi + +ANSIBLE_CONFIG="${PWD}/ansible.cfg" \ +ACCOUNTS_PULL_IMAGE="${ACCOUNTS_PULL_IMAGE:-true}" \ +"${args[@]}" diff --git a/scripts/github-actions/prepare-ssh.sh b/scripts/github-actions/prepare-ssh.sh new file mode 100644 index 0000000..d48c9ec --- /dev/null +++ b/scripts/github-actions/prepare-ssh.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_HOST="${1:?target host is required}" +SSH_KNOWN_HOSTS_PAYLOAD="${2:-}" + +test -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}" + +mkdir -p "${HOME}/.ssh" +chmod 700 "${HOME}/.ssh" + +python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa" +chmod 600 "${HOME}/.ssh/id_rsa" +ssh-keygen -y -f "${HOME}/.ssh/id_rsa" >/dev/null + +touch "${HOME}/.ssh/known_hosts" +chmod 600 "${HOME}/.ssh/known_hosts" + +if [[ -n "${SSH_KNOWN_HOSTS_PAYLOAD}" ]]; then + printf '%s\n' "${SSH_KNOWN_HOSTS_PAYLOAD}" >> "${HOME}/.ssh/known_hosts" +fi + +ssh-keyscan -H "${TARGET_HOST}" >> "${HOME}/.ssh/known_hosts" 2>/dev/null || true diff --git a/scripts/github-actions/validate-deploy.sh b/scripts/github-actions/validate-deploy.sh new file mode 100644 index 0000000..e9a4e06 --- /dev/null +++ b/scripts/github-actions/validate-deploy.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-https://accounts.svc.plus}" + +curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --max-time 20 \ + "${BASE_URL}/healthz" | grep -q '"status":"ok"'