diff --git a/.dockerignore b/.dockerignore index e264907..565ffce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,42 +1,51 @@ -# Git +# VCS .git .gitignore +.github # Python -__pycache__ +__pycache__/ *.py[cod] *$py.class *.so .Python -.venv -venv -ENV -env +.venv/ +venv/ +ENV/ +env/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ -# IDE -.idea -.vscode +# Editor/IDE +.idea/ +.vscode/ *.swp *.swo *~ -# Project directories (generated/runtime) -data/ -out/ - -# Configuration (use environment variables in Docker) +# Local env/config +.env +.env.* .envrc -.direnv +.direnv/ meshcore.conf -# Documentation +# Generated/runtime data +.data/ +data/ +out/ +.buildx-cache/ + +# Docs/tests (not needed in image) docs/ +tests/ *.md !README.md +!LICENSE -# Development files -.codex/ -*.log - -# macOS +# Misc .DS_Store +Thumbs.db +*.log +.codex/ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 5ec61e7..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,276 +0,0 @@ -# Build and publish Docker images to GitHub Container Registry -# -# Triggers: -# - On release: Build with version tags (X.Y.Z, X.Y, latest) -# - On schedule: Rebuild all tags with fresh base image (OS patches) -# - Manual: For testing, optional push -# -# Security: -# - All actions pinned by SHA -# - Vulnerability scanning with Trivy -# - SBOM and provenance attestation - -name: Docker Build and Publish - -on: - release: - types: [published] - - schedule: - # Daily at 4 AM UTC - rebuild with fresh base image - - cron: "0 4 * * *" - - pull_request: - paths: - - Dockerfile - - .dockerignore - - docker/** - - pyproject.toml - - uv.lock - - src/** - - scripts/** - - .github/workflows/docker-publish.yml - - workflow_dispatch: - inputs: - push: - description: "Push image to registry" - required: false - default: false - type: boolean - -permissions: - contents: read - packages: write - id-token: write - attestations: write - artifact-metadata: write - -concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: true - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - # For nightly builds, get the latest release version - - name: Get latest release version - id: get-version - if: github.event_name == 'schedule' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get latest release tag - LATEST_TAG=$(gh release view --json tagName -q '.tagName' 2>/dev/null || echo "") - if [ -z "$LATEST_TAG" ]; then - echo "No releases found, skipping nightly build" - echo "skip=true" >> $GITHUB_OUTPUT - exit 0 - fi - # Strip 'v' prefix if present - VERSION=$(echo "$LATEST_TAG" | sed 's/^v//') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "skip=false" >> $GITHUB_OUTPUT - - - name: Skip if no releases - if: github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true' - run: | - echo "No releases found, skipping nightly build" - exit 0 - - - name: Set up QEMU - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - - - name: Set up Docker Buildx - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Log in to Container Registry - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Generate tags based on event type - - name: Extract metadata (release) - id: meta-release - if: github.event_name == 'release' - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - # X.Y.Z - type=semver,pattern={{version}} - # X.Y - type=semver,pattern={{major}}.{{minor}} - # latest - type=raw,value=latest - - - name: Extract metadata (nightly) - id: meta-nightly - if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true' - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - # Rebuild version tags with OS patches - type=raw,value=${{ steps.get-version.outputs.version }} - # Nightly tags - type=raw,value=nightly - type=raw,value=nightly-{{date 'YYYYMMDD'}} - # Also update latest with security patches - type=raw,value=latest - - - name: Extract metadata (manual) - id: meta-manual - if: github.event_name == 'workflow_dispatch' - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix=sha- - - # Build image (release - with cache) - - name: Build and push (release) - id: build-release - if: github.event_name == 'release' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - tags: ${{ steps.meta-release.outputs.tags }} - labels: ${{ steps.meta-release.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: true - sbom: true - - # Build image (nightly - no cache, fresh base) - - name: Build and push (nightly) - id: build-nightly - if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - tags: ${{ steps.meta-nightly.outputs.tags }} - labels: ${{ steps.meta-nightly.outputs.labels }} - pull: true - no-cache: true - provenance: true - sbom: true - - # Build image (manual) - - name: Build and push (manual) - id: build-manual - if: github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: ${{ inputs.push }} - tags: ${{ steps.meta-manual.outputs.tags }} - labels: ${{ steps.meta-manual.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: true - sbom: true - - # Determine image tag for scanning and testing - - name: Determine image tag - id: image-tag - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - run: | - if [ "${{ github.event_name }}" = "release" ]; then - # Strip 'v' prefix to match semver tag format from metadata-action - echo "tag=$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')" >> $GITHUB_OUTPUT - elif [ "${{ github.event_name }}" = "schedule" ]; then - echo "tag=nightly" >> $GITHUB_OUTPUT - else - echo "tag=sha-${{ github.sha }}" >> $GITHUB_OUTPUT - fi - - # Vulnerability scanning - - name: Run Trivy vulnerability scanner - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 - with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }} - format: "sarif" - output: "trivy-results.sarif" - severity: "CRITICAL,HIGH" - continue-on-error: true - - - name: Upload Trivy scan results - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')" - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - with: - sarif_file: "trivy-results.sarif" - continue-on-error: true - - # Smoke test - verify image runs correctly - # Skip for manual runs when push is disabled (image not available to pull) - - name: Smoke test - if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true') && !(github.event_name == 'workflow_dispatch' && inputs.push == false)" - run: | - IMAGE_TAG="${{ steps.image-tag.outputs.tag }}" - - docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} - - # Test that Python and key modules are available - docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} \ - python -c "from meshmon.db import init_db; from meshmon.env import get_config; print('Smoke test passed')" - - # Attestation (releases only) - - name: Generate attestation - if: github.event_name == 'release' - uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.build-release.outputs.digest }} - push-to-registry: true - - build-pr: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Build image (PR) - id: build-pr - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - platforms: linux/amd64 - load: true - push: false - tags: meshcore-stats:pr-${{ github.event.pull_request.number }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Smoke test (PR) - run: | - docker run --rm meshcore-stats:pr-${{ github.event.pull_request.number }} \ - python -c "from meshmon.db import init_db; from meshmon.env import get_config; print('Smoke test passed')" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..463c057 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,97 @@ +name: Nightly Container + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + +concurrency: + group: nightly + cancel-in-progress: true + +jobs: + nightly: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + IMAGE_NAME: ghcr.io/jorijn/meshcore-stats + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Build metadata + id: vars + run: | + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "version=nightly" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push nightly image + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + env: + BUILDKIT_PROGRESS: plain + CACHE_FROM: type=gha + CACHE_TO: type=gha,mode=max + PLATFORMS: linux/amd64,linux/arm64 + VERSION: ${{ steps.vars.outputs.version }} + VCS_REF: ${{ github.sha }} + BUILD_DATE: ${{ steps.vars.outputs.build_date }} + with: + files: docker-bake.hcl + targets: nightly + provenance: true + sbom: true + + - name: Verify multi-arch manifest + run: | + docker buildx imagetools inspect "${IMAGE_NAME}:nightly" | tee /tmp/imagetools.txt + grep -q "linux/amd64" /tmp/imagetools.txt + grep -q "linux/arm64" /tmp/imagetools.txt + + - name: Resolve manifest digest + id: digest + run: | + DIGEST=$(docker buildx imagetools inspect "${IMAGE_NAME}:nightly" --format '{{.Digest}}') + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + - name: Trivy image scan (report-only) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.IMAGE_NAME }}:nightly + format: sarif + output: trivy-nightly.sarif + exit-code: '0' + vuln-type: os,library + ignore-unfixed: true + + - name: Upload Trivy report + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: trivy-nightly + path: trivy-nightly.sarif + if-no-files-found: warn + retention-days: 14 + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Cosign sign image (keyless) + run: | + cosign sign --yes "${IMAGE_NAME}@${{ steps.digest.outputs.digest }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..40ce22a --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,137 @@ +name: PR Container Checks + +on: + pull_request: + paths: + - Dockerfile + - .dockerignore + - docker-bake.hcl + - docker/** + - .github/workflows/** + - pyproject.toml + - uv.lock + - src/** + - scripts/** + +concurrency: + group: pr-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Lint workflows (actionlint) + uses: rhysd/actionlint@0933c147c9d6587653d45fdcb4c497c57a65f9af # v1.7.10 + + - name: Lint Dockerfile (hadolint) + uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 + with: + dockerfile: Dockerfile + + build: + runs-on: ubuntu-latest + timeout-minutes: 45 + needs: [lint] + env: + IMAGE_NAME: ghcr.io/jorijn/meshcore-stats + PLATFORMS: linux/amd64,linux/arm64 + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Build metadata + id: vars + run: | + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "version=pr-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build multi-arch (amd64+arm64) + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + env: + BUILDKIT_PROGRESS: plain + CACHE_FROM: type=gha + CACHE_TO: type=gha,mode=max + PLATFORMS: ${{ env.PLATFORMS }} + VERSION: ${{ steps.vars.outputs.version }} + VCS_REF: ${{ github.sha }} + BUILD_DATE: ${{ steps.vars.outputs.build_date }} + with: + files: docker-bake.hcl + targets: pr-multiarch + + - name: Build native image (load) + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + env: + BUILDKIT_PROGRESS: plain + CACHE_FROM: type=gha + CACHE_TO: type=gha,mode=max + LOCAL_PLATFORM: linux/amd64 + VERSION: ${{ steps.vars.outputs.version }} + VCS_REF: ${{ github.sha }} + BUILD_DATE: ${{ steps.vars.outputs.build_date }} + with: + files: docker-bake.hcl + targets: pr-native + + scan: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [lint] + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build metadata + id: scan-vars + run: | + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "version=pr-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Build native image for scan (load) + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + env: + BUILDKIT_PROGRESS: plain + CACHE_FROM: type=gha + CACHE_TO: type=gha,mode=max + LOCAL_PLATFORM: linux/amd64 + VERSION: ${{ steps.scan-vars.outputs.version }} + VCS_REF: ${{ github.sha }} + BUILD_DATE: ${{ steps.scan-vars.outputs.build_date }} + with: + files: docker-bake.hcl + targets: pr-native + + - name: Trivy image scan (report-only) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: meshcore-stats:pr + format: sarif + output: trivy-report.sarif + exit-code: '0' + vuln-type: os,library + ignore-unfixed: true + + - name: Upload Trivy report + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: trivy-report-pr + path: trivy-report.sarif + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2164ff3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release Container + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + packages: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + IMAGE_NAME: ghcr.io/jorijn/meshcore-stats + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Validate tag and set metadata + id: vars + shell: bash + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Tag must match vMAJOR.MINOR.PATCH (e.g., v1.2.3)." >&2 + exit 1 + fi + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_tags=${IMAGE_NAME}:${MAJOR},${IMAGE_NAME}:${MAJOR}.${MINOR},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push release image + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + env: + BUILDKIT_PROGRESS: plain + CACHE_FROM: type=gha + CACHE_TO: type=gha,mode=max + PLATFORMS: linux/amd64,linux/arm64 + VERSION: ${{ steps.vars.outputs.version }} + VCS_REF: ${{ github.sha }} + BUILD_DATE: ${{ steps.vars.outputs.build_date }} + RELEASE_TAGS: ${{ steps.vars.outputs.release_tags }} + with: + files: docker-bake.hcl + targets: release + provenance: true + sbom: true + + - name: Verify multi-arch manifest + run: | + docker buildx imagetools inspect "${IMAGE_NAME}:${{ steps.vars.outputs.version }}" | tee /tmp/imagetools.txt + grep -q "linux/amd64" /tmp/imagetools.txt + grep -q "linux/arm64" /tmp/imagetools.txt + + - name: Resolve manifest digest + id: digest + run: | + DIGEST=$(docker buildx imagetools inspect "${IMAGE_NAME}:${{ steps.vars.outputs.version }}" --format '{{.Digest}}') + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + - name: Trivy image scan (report-only) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.version }} + format: sarif + output: trivy-release.sarif + exit-code: '0' + vuln-type: os,library + ignore-unfixed: true + + - name: Upload Trivy report + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: trivy-release + path: trivy-release.sarif + if-no-files-found: warn + retention-days: 14 + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Cosign sign image (keyless) + run: | + cosign sign --yes "${IMAGE_NAME}@${{ steps.digest.outputs.digest }}" diff --git a/.gitignore b/.gitignore index 96a2286..d00f654 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ meshcore.conf # Docker local overrides docker-compose.override.yml +.buildx-cache/ # Data directories (keep structure, ignore content) data/snapshots/companion/**/*.json @@ -48,4 +49,4 @@ out/* .DS_Store Thumbs.db -.direnv \ No newline at end of file +.direnv diff --git a/AGENTS.md b/AGENTS.md index b403a5a..3bd4393 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -359,16 +359,11 @@ Jobs configured in `docker/ofelia.ini`: ### GitHub Actions Workflow -`.github/workflows/docker-publish.yml` builds and publishes Docker images for `linux/amd64`, `linux/arm64`, and `linux/arm/v7`: +Container workflows (amd64 + arm64 only): -| Trigger | Tags Created | -|---------|--------------| -| Release | `X.Y.Z`, `X.Y`, `latest` | -| Nightly (4 AM UTC) | Rebuilds all version tags + `nightly`, `nightly-YYYYMMDD` | -| Manual | `sha-xxxxxx` | -| Pull request | Builds image (linux/amd64) without pushing and runs a smoke test | - -**Nightly rebuilds** ensure version tags always include the latest OS security patches. This is a common pattern used by official Docker images (nginx, postgres, node). Users needing reproducibility should pin by SHA digest or use dated nightly tags. +- `pr.yml`: pull_request lint (actionlint/hadolint), container-relevant path filter, multi-arch build (no push), native image build for report-only Trivy scan. +- `release.yml`: tag push `vMAJOR.MINOR.PATCH` → push `MAJOR`, `MAJOR.MINOR`, `MAJOR.MINOR.PATCH`, `latest` with SBOM + provenance attestations, Trivy report artifact, cosign keyless signing, and manifest platform verification. +- `nightly.yml`: scheduled + workflow_dispatch → push `nightly` with SBOM + provenance attestations, Trivy report artifact, cosign keyless signing, and manifest platform verification. GitHub Actions use version tags in workflows, and Renovate is configured in `renovate.json` to pin action digests, maintain lockfiles, and auto-merge patch + digest updates once required checks pass (with automatic rebases when behind `main`). diff --git a/Dockerfile b/Dockerfile index e7d46fc..5949bc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,155 +1,100 @@ -# ============================================================================= -# Stage 0: Ofelia binary -# ============================================================================= -FROM golang:1.25-bookworm@sha256:2c7c65601b020ee79db4c1a32ebee0bf3d6b298969ec683e24fcbea29305f10e AS ofelia-builder +# syntax=docker/dockerfile:1.7 -# Ofelia version (built from source for multi-arch support) +FROM ghcr.io/astral-sh/uv:0.9.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 AS uv + +FROM golang:1.25-bookworm@sha256:2c7c65601b020ee79db4c1a32ebee0bf3d6b298969ec683e24fcbea29305f10e AS ofelia-builder ARG OFELIA_VERSION=0.3.12 ARG TARGETARCH -ARG TARGETVARIANT RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ ca-certificates \ + git \ && rm -rf /var/lib/apt/lists/* WORKDIR /src/ofelia -RUN git clone --depth 1 --branch "v${OFELIA_VERSION}" https://github.com/mcuadros/ofelia.git /src/ofelia +RUN git clone --depth 1 --branch "v${OFELIA_VERSION}" https://github.com/mcuadros/ofelia.git . -RUN set -ex; \ - if [ "$TARGETARCH" = "amd64" ]; then \ - GOARCH="amd64"; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - GOARCH="arm64"; \ - elif [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then \ - GOARCH="arm"; \ - GOARM="7"; \ - else \ - echo "Unsupported architecture: $TARGETARCH${TARGETVARIANT:+/$TARGETVARIANT}" && exit 1; \ - fi; \ - if [ -n "${GOARM:-}" ]; then \ - export GOARM; \ - fi; \ - CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" go build -o /usr/local/bin/ofelia . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + set -eux; \ + case "$TARGETARCH" in \ + amd64|arm64) GOARCH="$TARGETARCH" ;; \ + *) echo "Unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" \ + go build -trimpath -ldflags "-s -w" -o /usr/local/bin/ofelia . -# ============================================================================= -# Stage 1: Build dependencies -# ============================================================================= FROM python:3.14-slim-bookworm@sha256:3be2c910db2dacfb3e576f94c7ffc07c10b115cbcd3de99d49bfb0b4ccfd75e7 AS builder -# uv version and checksums (verified from GitHub releases) -ARG UV_VERSION=0.9.24 -ARG UV_SHA256_AMD64=fb13ad85106da6b21dd16613afca910994446fe94a78ee0b5bed9c75cd066078 -ARG UV_SHA256_ARM64=9b291a1a4f2fefc430e4fc49c00cb93eb448d41c5c79edf45211ceffedde3334 -ARG UV_SHA256_ARMV7=8d05b55fe2108ecab3995c2b656679a72c543fd9dc72eeb3a525106a709cfdcb -ARG TARGETARCH -ARG TARGETVARIANT - -# Install build dependencies for Python packages RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libfreetype6-dev \ libpng-dev \ - curl \ + pkg-config \ && rm -rf /var/lib/apt/lists/* -# Download and verify uv binary in builder stage -RUN set -ex; \ - if [ "$TARGETARCH" = "amd64" ]; then \ - UV_ARCH="x86_64"; \ - UV_SHA256="$UV_SHA256_AMD64"; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - UV_ARCH="aarch64"; \ - UV_SHA256="$UV_SHA256_ARM64"; \ - elif [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then \ - UV_ARCH="armv7"; \ - UV_SHA256="$UV_SHA256_ARMV7"; \ - else \ - echo "Unsupported architecture: $TARGETARCH${TARGETVARIANT:+/$TARGETVARIANT}" && exit 1; \ - fi; \ - curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_ARCH}-unknown-linux-gnu.tar.gz" \ - -o /tmp/uv.tar.gz \ - && echo "${UV_SHA256} /tmp/uv.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/uv.tar.gz -C /usr/local/bin --strip-components=1 --wildcards "*/uv" \ - && rm /tmp/uv.tar.gz \ - && chmod +x /usr/local/bin/uv +COPY --from=uv /uv /uvx /usr/local/bin/ -# Create virtual environment RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" \ UV_PROJECT_ENVIRONMENT=/opt/venv -# Install Python dependencies COPY pyproject.toml uv.lock ./ -RUN pip install --no-cache-dir --upgrade pip && \ +RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev --no-install-project -# ============================================================================= -# Stage 2: Runtime -# ============================================================================= FROM python:3.14-slim-bookworm@sha256:3be2c910db2dacfb3e576f94c7ffc07c10b115cbcd3de99d49bfb0b4ccfd75e7 -# OCI Labels -LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats" -LABEL org.opencontainers.image.description="MeshCore Stats - LoRa mesh network monitoring" -LABEL org.opencontainers.image.licenses="MIT" +ARG BUILD_DATE=1970-01-01T00:00:00Z +ARG VCS_REF=unknown +ARG VERSION=dev + +LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats" \ + org.opencontainers.image.description="MeshCore Stats - LoRa mesh network monitoring" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.created=$BUILD_DATE \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.version=$VERSION -# Install runtime dependencies -# - tini: init system for proper signal handling -# - libfreetype6, libpng16-16: matplotlib runtime libraries -# - fontconfig, fonts-dejavu-core: fonts for chart text rendering RUN apt-get update && apt-get install -y --no-install-recommends \ - tini \ - libfreetype6 \ - libpng16-16 \ + ca-certificates \ fontconfig \ fonts-dejavu-core \ + libfreetype6 \ + libpng16-16 \ + tini \ && rm -rf /var/lib/apt/lists/* \ - # Build font cache for matplotlib && fc-cache -f \ - # Remove setuid/setgid binaries for security - && find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true + && find /usr /bin /sbin /usr/local /usr/lib -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true -# Create non-root user with dialout group for serial access -RUN groupadd -g 1000 meshmon \ - && useradd -u 1000 -g meshmon -G dialout -s /sbin/nologin meshmon \ - && mkdir -p /data/state /out /tmp/matplotlib \ - && chown -R meshmon:meshmon /data /out /tmp/matplotlib +RUN set -eux; \ + if ! getent group dialout >/dev/null; then groupadd -r dialout; fi; \ + groupadd -g 1000 meshmon; \ + useradd -u 1000 -g 1000 -G dialout -m -s /usr/sbin/nologin meshmon; \ + mkdir -p /app /data/state /out /tmp/matplotlib; \ + chown -R meshmon:meshmon /app /data /out /tmp/matplotlib -# Copy Ofelia binary from builder (keeps curl out of runtime image) COPY --from=ofelia-builder /usr/local/bin/ofelia /usr/local/bin/ofelia - -# Copy virtual environment from builder COPY --from=builder /opt/venv /opt/venv -# Copy application code COPY --chown=meshmon:meshmon src/ /app/src/ COPY --chown=meshmon:meshmon scripts/ /app/scripts/ COPY --chown=meshmon:meshmon docker/ofelia.ini /app/ofelia.ini -# Environment configuration -# - PATH: Include venv so Ofelia can run Python -# - PYTHONPATH: Allow imports from src/meshmon -# - PYTHONUNBUFFERED: Ensure logs are output immediately -# - PYTHONDONTWRITEBYTECODE: Don't create .pyc files -# - MPLCONFIGDIR: Matplotlib font cache directory ENV PATH="/opt/venv/bin:$PATH" \ PYTHONPATH=/app/src \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ - MPLCONFIGDIR=/tmp/matplotlib + MPLCONFIGDIR=/tmp/matplotlib \ + XDG_CACHE_HOME=/tmp \ + HOME=/tmp WORKDIR /app -# Run as non-root user -USER meshmon +USER 1000:1000 -# Use tini as init system for proper signal handling ENTRYPOINT ["/usr/bin/tini", "--"] - -# Run Ofelia scheduler CMD ["ofelia", "daemon", "--config=/app/ofelia.ini"] -# Health check - verify database is accessible HEALTHCHECK --interval=5m --timeout=30s --start-period=60s --retries=3 \ CMD python -c "import sqlite3; sqlite3.connect('/data/state/metrics.db').execute('SELECT 1')" || exit 1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd92d6b --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +SHELL := /bin/sh + +IMAGE_NAME ?= ghcr.io/jorijn/meshcore-stats +PLATFORMS ?= linux/amd64,linux/arm64 +LOCAL_PLATFORM ?= $(shell (docker info -f '{{.OSType}}/{{.Architecture}}' 2>/dev/null || echo linux/amd64) | tr -d '[:space:]') +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +VCS_REF ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +VERSION ?= dev +CACHE_FROM ?= type=local,src=.buildx-cache +CACHE_TO ?= type=local,dest=.buildx-cache,mode=max + +.PHONY: docker-build docker-buildx-all + +docker-build: + LOCAL_PLATFORM=$(LOCAL_PLATFORM) docker buildx bake pr-native \ + --set *.args.BUILD_DATE=$(BUILD_DATE) \ + --set *.args.VCS_REF=$(VCS_REF) \ + --set *.args.VERSION=$(VERSION) \ + --set *.cache-from=$(CACHE_FROM) \ + --set *.cache-to=$(CACHE_TO) + +docker-buildx-all: + PLATFORMS=$(PLATFORMS) docker buildx bake pr-multiarch \ + --set *.args.BUILD_DATE=$(BUILD_DATE) \ + --set *.args.VCS_REF=$(VCS_REF) \ + --set *.args.VERSION=$(VERSION) \ + --set *.cache-from=$(CACHE_FROM) \ + --set *.cache-to=$(CACHE_TO) diff --git a/README.md b/README.md index 49b7432..317a4e0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ docker compose logs meshcore-stats | head -20 - Remote repeater node reachable via LoRa from the companion **Resource requirements:** ~100MB memory, ~100MB disk per year of data. -**Container architectures:** `linux/amd64`, `linux/arm64`, and `linux/arm/v7` (32-bit). +**Container architectures:** `linux/amd64`, `linux/arm64`. ## Installation @@ -157,6 +157,46 @@ cp data/state/metrics.db data/state/metrics.db.backup > **Note**: `docker compose down` preserves your data. Use `docker compose down -v` only if you want to delete everything. +## Container Images & CI + +### Tags and Platforms + +- **Release tags** (on `vMAJOR.MINOR.PATCH`): `MAJOR`, `MAJOR.MINOR`, `MAJOR.MINOR.PATCH`, `latest` +- **Nightly tag**: `nightly` +- **Platforms**: `linux/amd64`, `linux/arm64` + +### Security Artifacts + +- **SBOM and provenance** are attached to the pushed images in GHCR (view in the image details/attestations UI). +- **Vulnerability reports** are uploaded as GitHub Actions artifacts for each release/nightly build. + +Example cosign verification (release tag): + +```bash +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp 'https://github.com/jorijn/meshcore-stats/.github/workflows/release.yml@refs/tags/v1.2.3' \ + ghcr.io/jorijn/meshcore-stats:1.2.3 +``` + +### Local Workflow Testing + +Prerequisites: +- Docker Desktop (or Docker Engine) with Buildx enabled +- QEMU/binfmt installed for multi-arch builds + +Commands: + +```bash +# Native-arch build (loads into local Docker) +make docker-build + +# Multi-arch build validation (amd64 + arm64, no push) +make docker-buildx-all +``` + +Use `make docker-buildx-all` for full multi-arch validation. + ### Manual Installation (Alternative) For environments where Docker is not available. diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..dfca398 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,72 @@ +variable "IMAGE_NAME" { + default = "ghcr.io/jorijn/meshcore-stats" +} + +variable "PLATFORMS" { + default = "linux/amd64,linux/arm64" +} + +variable "LOCAL_PLATFORM" { + default = "linux/amd64" +} + +variable "CACHE_FROM" { + default = "type=local,src=.buildx-cache" +} + +variable "CACHE_TO" { + default = "type=local,dest=.buildx-cache,mode=max" +} + +variable "VERSION" { + default = "dev" +} + +variable "VCS_REF" { + default = "unknown" +} + +variable "BUILD_DATE" { + default = "1970-01-01T00:00:00Z" +} + +variable "RELEASE_TAGS" { + default = "" +} + +target "base" { + context = "." + dockerfile = "Dockerfile" + platforms = split(",", PLATFORMS) + args = { + VERSION = VERSION + VCS_REF = VCS_REF + BUILD_DATE = BUILD_DATE + } + cache-from = [CACHE_FROM] + cache-to = [CACHE_TO] +} + +target "pr-multiarch" { + inherits = ["base"] + output = ["type=cacheonly"] +} + +target "pr-native" { + inherits = ["base"] + platforms = [LOCAL_PLATFORM] + tags = ["meshcore-stats:pr"] + output = ["type=docker"] +} + +target "release" { + inherits = ["base"] + tags = RELEASE_TAGS != "" ? split(",", RELEASE_TAGS) : [] + push = true +} + +target "nightly" { + inherits = ["base"] + tags = ["${IMAGE_NAME}:nightly"] + push = true +} diff --git a/renovate.json b/renovate.json index e6d24ba..64070e0 100644 --- a/renovate.json +++ b/renovate.json @@ -17,6 +17,12 @@ ], "pinDigests": true }, + { + "matchManagers": [ + "dockerfile" + ], + "pinDigests": true + }, { "matchManagers": [ "docker-compose"