From a8e65473ac761373a6e469ee01386b4e705c7ce6 Mon Sep 17 00:00:00 2001 From: croit release bot Date: Wed, 26 Aug 2026 09:32:58 +0300 Subject: [PATCH 1/2] release: publish v2608.1 source Publish the reviewed public source tree after release hardening. Changes: - public export: update source, workflows, packaging, documentation, and tests. Tests: 172 passed; format, Clippy, release build, export, and Gitleaks passed. --- .github/SECURITY.md | 10 + .github/dependabot.yml | 39 ++ .github/workflows/ci.yml | 154 ++++--- .github/workflows/release.yml | 181 ++++---- .gitignore | 4 +- docs/architecture.md => ARCHITECTURE.md | 10 +- CONTRIBUTING.md | 6 +- Cargo.lock | 29 +- Cargo.toml | 4 +- Dockerfile | 49 ++- README.md | 411 ++++++++++++++---- THIRD_PARTY_LICENSES.md | 11 +- config.example.yaml | 24 +- deploy/systemd/keepafloatd@.service | 2 +- ...pose.5node.yml => docker-compose.5node.yml | 4 +- .../docker-compose.yml => docker-compose.yml | 4 +- docs/README.md | 8 +- .../blog/keepafloatd-ceph-rgw-multi-active.md | 213 +++++++++ ...eepafloatd-haproxy-active-active-active.md | 203 +++++++++ docs/development.md | 16 +- docs/operations.md | 28 +- scripts/ci/build/source-tarball.sh | 6 +- scripts/public/export.sh | 54 +++ scripts/public/files.txt | 106 +++++ scripts/public/verify.sh | 70 +++ scripts/shared/buildx-setup.sh | 27 -- src/bind_policy.rs | 8 +- src/cluster_test.rs | 18 +- src/config.rs | 20 +- src/health.rs | 2 +- src/main.rs | 6 +- src/raft/mod.rs | 17 +- src/raft/network.rs | 8 +- src/raft/probe.rs | 2 +- src/raft/store/apply_tests.rs | 81 +++- src/raft/store/log.rs | 8 +- src/raft/store/mod.rs | 2 +- src/raft/store/state.rs | 12 +- src/raft/store/state_machine.rs | 34 +- src/raft/store/vip_logic.rs | 28 +- src/raft/types.rs | 11 +- src/submit.rs | 11 +- src/vip.rs | 14 +- tests/e2e/fixtures/Dockerfile | 2 +- tests/e2e/runner/Dockerfile | 2 +- tests/e2e/scenarios/06_sigint_graceful.sh | 2 +- .../08_full_outage_majority_recovers.sh | 4 +- .../e2e/scenarios/09_concurrent_cold_start.sh | 2 +- .../10_returning_nodes_join_survivor.sh | 2 +- .../11_survivor_rejoin_after_leader_change.sh | 2 +- tests/e2e/scenarios/12_sticky_vip.sh | 9 +- ...13_stale_survivor_rejected_after_reform.sh | 2 +- .../12_minimal_movement_rebalance.sh | 4 +- tests/e2e/scripts/lib.sh | 4 +- tests/e2e/scripts/report.sh | 8 +- tests/e2e/scripts/run5.sh | 2 +- tests/haproxy-e2e/docker-compose.yml | 4 +- tests/haproxy-e2e/node.Dockerfile | 5 - 58 files changed, 1513 insertions(+), 496 deletions(-) create mode 100644 .github/SECURITY.md create mode 100644 .github/dependabot.yml rename docs/architecture.md => ARCHITECTURE.md (97%) rename tests/e2e/docker-compose.5node.yml => docker-compose.5node.yml (97%) rename tests/e2e/docker-compose.yml => docker-compose.yml (96%) create mode 100644 docs/blog/keepafloatd-ceph-rgw-multi-active.md create mode 100644 docs/blog/keepafloatd-haproxy-active-active-active.md create mode 100755 scripts/public/export.sh create mode 100644 scripts/public/files.txt create mode 100755 scripts/public/verify.sh delete mode 100755 scripts/shared/buildx-setup.sh diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..47ebdbf --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,10 @@ +# Security policy + +Please do not report security vulnerabilities in a public issue. + +Use [GitHub private vulnerability reporting](https://github.com/croit/keepAfloatD/security/advisories/new) +to send the maintainers a confidential report. Include the affected version, impact, reproduction +steps and any suggested mitigation. + +The maintainers will acknowledge the report, assess affected releases and coordinate a fix before +public disclosure. General support requests can be sent to support@croit.io. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3c925bc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,39 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + rust-dependencies: + patterns: + - "*" + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /tests/e2e/fixtures + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /tests/e2e/runner + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /tests/haproxy-e2e + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eb124d..d6b2300 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,7 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "0" RUST_VERSION: 1.91.1 - # scripts/ci/* default their workdir to GitLab's /app; point them at the - # Actions checkout instead. + # Point the shared CI scripts at the Actions checkout. CI_PROJECT_DIR: ${{ github.workspace }} permissions: @@ -25,12 +24,14 @@ jobs: name: lint / fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} components: rustfmt - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo fmt --check build-binary: @@ -40,12 +41,16 @@ jobs: fail-fast: false matrix: arch: [amd64, arm64] + env: + TARGET_ARCH: ${{ matrix.arch }} steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install native build dependencies run: | sudo apt-get update -qq @@ -68,7 +73,7 @@ jobs: mkdir -p dist # Static musl build: artifact has no glibc/GLIBC_* dependency, so it # installs/runs on any libc version (e.g. el9 glibc 2.34). - if [ "${{ matrix.arch }}" = "arm64" ]; then + if [ "${TARGET_ARCH}" = "arm64" ]; then TARGET=aarch64-unknown-linux-musl export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc else @@ -76,17 +81,17 @@ jobs: fi rustup target add "${TARGET}" cargo build --release --locked --target "${TARGET}" - cp "target/${TARGET}/release/keepafloatd" "dist/keepafloatd-linux-${{ matrix.arch }}" + cp "target/${TARGET}/release/keepafloatd" "dist/keepafloatd-linux-${TARGET_ARCH}" chmod +x dist/keepafloatd-linux-* - name: Verify binary is statically linked run: | - if file "dist/keepafloatd-linux-${{ matrix.arch }}" | grep -q "dynamically linked"; then + if file "dist/keepafloatd-linux-${TARGET_ARCH}" | grep -q "dynamically linked"; then echo "ERROR: binary is dynamically linked; expected a static musl build" >&2 - file "dist/keepafloatd-linux-${{ matrix.arch }}" >&2 + file "dist/keepafloatd-linux-${TARGET_ARCH}" >&2 exit 1 fi - file "dist/keepafloatd-linux-${{ matrix.arch }}" - - uses: actions/upload-artifact@v5 + file "dist/keepafloatd-linux-${TARGET_ARCH}" + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: keepafloatd-binary-${{ matrix.arch }} path: dist/keepafloatd-linux-${{ matrix.arch }} @@ -98,12 +103,16 @@ jobs: fail-fast: false matrix: arch: [amd64, arm64] + env: + TARGET_ARCH: ${{ matrix.arch }} steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install Debian packaging dependencies run: | sudo apt-get update -qq @@ -131,7 +140,7 @@ jobs: mkdir -p dist # Package the static musl binary so the .deb's $auto depends carries # no libc6/GLIBC requirement. - if [ "${{ matrix.arch }}" = "arm64" ]; then + if [ "${TARGET_ARCH}" = "arm64" ]; then TARGET=aarch64-unknown-linux-musl export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc else @@ -140,8 +149,8 @@ jobs: rustup target add "${TARGET}" cargo build --release --locked --target "${TARGET}" cargo deb --no-build --no-strip --target "${TARGET}" - find target -type f -name "*_${{ matrix.arch }}.deb" -exec cp {} dist/ \; - - uses: actions/upload-artifact@v5 + find target -type f -name "*_${TARGET_ARCH}.deb" -exec cp {} dist/ \; + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: keepafloatd-deb-${{ matrix.arch }} path: dist/*_${{ matrix.arch }}.deb @@ -150,16 +159,18 @@ jobs: name: build / source tarball runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build vendored source tarball run: VERSION=0.0.0 ./scripts/ci/build/source-tarball.sh - name: Verify it builds fully offline run: ./scripts/ci/test/source-build.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: keepafloatd-source path: dist/keepafloatd-*.tar.gz @@ -168,10 +179,20 @@ jobs: name: build / images runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build runtime image run: docker build --target runtime -t keepafloatd:runtime-ci . + - name: Scan runtime image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 + with: + exit-code: "1" + ignore-unfixed: true + image-ref: keepafloatd:runtime-ci + severity: CRITICAL,HIGH + vuln-type: os - name: Build dev image run: docker build --target dev -t keepafloatd:dev-ci . @@ -179,34 +200,40 @@ jobs: name: test / unit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: ./scripts/ci/test/unit.sh test-clippy: name: test / clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: ./scripts/ci/test/clippy.sh test-licenses: name: test / licenses runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: ./scripts/ci/test/licenses-before.sh - run: ./scripts/ci/test/licenses.sh @@ -214,13 +241,15 @@ jobs: name: test / doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: ./scripts/ci/test/doc.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: keepafloatd-doc path: target/doc/ @@ -229,11 +258,13 @@ jobs: name: test / coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install coverage dependencies run: | sudo apt-get update -qq @@ -242,7 +273,7 @@ jobs: pkg-config cargo install cargo-tarpaulin --locked - run: ./scripts/ci/test/coverage.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: keepafloatd-coverage path: | @@ -253,12 +284,14 @@ jobs: name: test / e2e runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false - name: Build E2E image run: docker build -t keepafloatd:ci . - name: Run Docker Compose E2E run: KEEPAFLOATD_IMAGE=keepafloatd:ci ./scripts/ci/test/e2e.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: always() with: name: keepafloatd-e2e-artifacts @@ -268,19 +301,19 @@ jobs: name: test / haproxy article runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build runtime base image run: docker build --target runtime -t keepafloatd:runtime-local . - - name: Pre-pull pinned service images - # the compose file runs hermetic (pull_policy: never), so fetch its - # third-party images explicitly - run: docker pull -q python:3.12-alpine + - name: Preload fixture image + run: docker pull python:3.12-alpine@sha256:d09d15e60962ca365d1cd544a48773bac9d33f2fb1b00f2aa0deec78ade7dc31 - run: bash tests/haproxy-e2e/run.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: always() with: name: keepafloatd-haproxy-e2e-artifacts @@ -292,10 +325,21 @@ jobs: needs: - build-deb steps: - - uses: actions/checkout@v5 - - uses: actions/download-artifact@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: keepafloatd-deb-amd64 path: dist/ - name: Install and validate Debian package run: sudo env CI_PROJECT_DIR="${PWD}" bash ./scripts/ci/test/deb-install.sh + + test-public-export: + name: test / public export + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - run: ./scripts/public/verify.sh HEAD diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f275a5..892e684 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,15 +30,12 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "0" RUST_VERSION: 1.91.1 - # scripts/ci/* default their workdir to GitLab's /app; point them at the - # Actions checkout instead. + # Point the shared CI scripts at the Actions checkout. CI_PROJECT_DIR: ${{ github.workspace }} GHCR_IMAGE: ghcr.io/croit/keepafloatd - GHCR_DEV_IMAGE: ghcr.io/croit/keepafloatd/dev permissions: - contents: write - packages: write + contents: read jobs: prepare: @@ -50,23 +47,29 @@ jobs: prev_tag: ${{ steps.prepare.outputs.prev_tag }} dry_run: ${{ steps.prepare.outputs.dry_run }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 + persist-credentials: false - id: prepare shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + INPUT_PUBLISH_REF: ${{ inputs.publish_ref }} + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail - if [ "${{ github.event_name }}" = "push" ]; then + if [ "${EVENT_NAME}" = "push" ]; then VERSION="${GITHUB_REF_NAME}" COMMIT="${GITHUB_SHA}" DRY_RUN="false" git checkout --quiet "${COMMIT}" else - REF="${{ inputs.publish_ref }}" - VERSION_INPUT="${{ inputs.version }}" - DRY_RUN="${{ inputs.dry_run }}" + REF="${INPUT_PUBLISH_REF}" + VERSION_INPUT="${INPUT_VERSION}" + DRY_RUN="${INPUT_DRY_RUN}" git checkout --quiet "${REF}" COMMIT="$(git rev-parse HEAD)" @@ -96,14 +99,17 @@ jobs: fail-fast: false matrix: arch: [amd64, arm64] + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + TARGET_ARCH: ${{ matrix.arch }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 - name: Install build dependencies run: | sudo apt-get update -qq @@ -123,13 +129,13 @@ jobs: gcc-aarch64-linux-gnu \ libc6-dev-arm64-cross - name: Stamp release version - run: VERSION=${{ needs.prepare.outputs.version }} ./scripts/ci/build/set-version.sh + run: VERSION="${RELEASE_VERSION}" ./scripts/ci/build/set-version.sh # Static musl builds so the artifacts carry no glibc/GLIBC_* dependency and - # run on any distro (e.g. el9's glibc 2.34) — same approach as the GitLab CI. + # run on any distro, including el9 with glibc 2.34. - name: Build release binary run: | mkdir -p dist - if [ "${{ matrix.arch }}" = "arm64" ]; then + if [ "${TARGET_ARCH}" = "arm64" ]; then TARGET=aarch64-unknown-linux-musl export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc else @@ -137,14 +143,14 @@ jobs: fi rustup target add "${TARGET}" cargo build --release --locked --target "${TARGET}" - cp "target/${TARGET}/release/keepafloatd" dist/keepafloatd-linux-${{ matrix.arch }} + cp "target/${TARGET}/release/keepafloatd" "dist/keepafloatd-linux-${TARGET_ARCH}" chmod +x dist/keepafloatd-linux-* - if file dist/keepafloatd-linux-${{ matrix.arch }} | grep -q "dynamically linked"; then + if file "dist/keepafloatd-linux-${TARGET_ARCH}" | grep -q "dynamically linked"; then echo "ERROR: expected a static musl build" >&2 - file dist/keepafloatd-linux-${{ matrix.arch }} >&2 + file "dist/keepafloatd-linux-${TARGET_ARCH}" >&2 exit 1 fi - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release-binary-${{ matrix.arch }} path: dist/keepafloatd-linux-${{ matrix.arch }} @@ -157,14 +163,17 @@ jobs: fail-fast: false matrix: arch: [amd64, arm64] + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + TARGET_ARCH: ${{ matrix.arch }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 - name: Install Debian packaging dependencies run: | sudo apt-get update -qq @@ -189,13 +198,13 @@ jobs: - name: Install cargo-deb run: cargo install cargo-deb --locked - name: Stamp release version - run: VERSION=${{ needs.prepare.outputs.version }} ./scripts/ci/build/set-version.sh + run: VERSION="${RELEASE_VERSION}" ./scripts/ci/build/set-version.sh # Static musl build so the .deb's $auto depends carries no libc6 (>= X) - # constraint and installs on older glibc distros — same as the GitLab CI. + # constraint and installs on older glibc distributions. - name: Build Debian package run: | mkdir -p dist - if [ "${{ matrix.arch }}" = "arm64" ]; then + if [ "${TARGET_ARCH}" = "arm64" ]; then TARGET=aarch64-unknown-linux-musl export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc else @@ -208,8 +217,8 @@ jobs: exit 1 fi cargo deb --no-build --no-strip --target "${TARGET}" - find target -type f -name "*_${{ matrix.arch }}.deb" -exec cp {} dist/ \; - - uses: actions/upload-artifact@v5 + find target -type f -name "*_${TARGET_ARCH}.deb" -exec cp {} dist/ \; + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release-deb-${{ matrix.arch }} path: dist/*_${{ matrix.arch }}.deb @@ -222,14 +231,17 @@ jobs: fail-fast: false matrix: arch: [amd64, arm64] + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + TARGET_ARCH: ${{ matrix.arch }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 - name: Install packaging dependencies run: | sudo apt-get update -qq @@ -251,13 +263,13 @@ jobs: - name: Install cargo-generate-rpm run: cargo install cargo-generate-rpm --locked - name: Stamp release version - run: VERSION=${{ needs.prepare.outputs.version }} ./scripts/ci/build/set-version.sh + run: VERSION="${RELEASE_VERSION}" ./scripts/ci/build/set-version.sh # Static musl build so the rpm carries no glibc dependency (installs on - # el9's glibc 2.34 and anything newer) — same as the GitLab CI. + # el9's glibc 2.34 and anything newer). - name: Build RPM package run: | mkdir -p dist - if [ "${{ matrix.arch }}" = "arm64" ]; then + if [ "${TARGET_ARCH}" = "arm64" ]; then TARGET=aarch64-unknown-linux-musl export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc else @@ -271,7 +283,7 @@ jobs: fi cargo generate-rpm --target "${TARGET}" find target -type f -name '*.rpm' -exec cp {} dist/ \; - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release-rpm-${{ matrix.arch }} path: dist/*.rpm @@ -280,19 +292,21 @@ jobs: name: release source tarball runs-on: ubuntu-latest needs: prepare + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ env.RUST_VERSION }} - - uses: Swatinem/rust-cache@v2 - name: Build vendored source tarball - run: VERSION=${{ needs.prepare.outputs.version }} ./scripts/ci/build/source-tarball.sh + run: VERSION="${RELEASE_VERSION}" ./scripts/ci/build/source-tarball.sh - name: Verify it builds fully offline run: ./scripts/ci/test/source-build.sh - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release-source path: dist/keepafloatd-*.tar.gz @@ -301,59 +315,49 @@ jobs: name: release images runs-on: ubuntu-latest # Depend on the per-arch binary builds so the runtime image copies those pre-cross-compiled - # binaries instead of recompiling under QEMU emulation — the old multi-arch in-image compile was + # binaries instead of recompiling under QEMU emulation - the old multi-arch in-image compile was # the dominant release cost. needs: [prepare, build-binary] + permissions: + contents: read + packages: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - name: Fetch pre-built release binaries - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: release-binary-* merge-multiple: true path: dist - name: Restore binary permissions run: chmod 0755 dist/keepafloatd-linux-* - - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 with: platforms: arm64 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR if: needs.prepare.outputs.dry_run != 'true' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build runtime image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . target: runtime-dist platforms: linux/amd64,linux/arm64 push: ${{ needs.prepare.outputs.dry_run != 'true' }} - provenance: false + provenance: mode=max + sbom: true tags: | ${{ env.GHCR_IMAGE }}:${{ needs.prepare.outputs.version }} ${{ env.GHCR_IMAGE }}:sha-${{ needs.prepare.outputs.commit }} ${{ env.GHCR_IMAGE }}:latest - - name: Build dev image - uses: docker/build-push-action@v6 - with: - context: . - target: dev - # amd64 only: the dev image bundles the full Rust toolchain + a from-source cargo-tarpaulin - # build; doing that for arm64 under QEMU emulation dominated release time for a - # rarely-used local-development convenience image. - platforms: linux/amd64 - push: ${{ needs.prepare.outputs.dry_run != 'true' }} - provenance: false - tags: | - ${{ env.GHCR_DEV_IMAGE }}:${{ needs.prepare.outputs.version }} - ${{ env.GHCR_DEV_IMAGE }}:sha-${{ needs.prepare.outputs.commit }} - release: name: publish release runs-on: ubuntu-latest @@ -365,46 +369,60 @@ jobs: - build-source-tarball - build-images if: needs.prepare.outputs.dry_run != 'true' + permissions: + attestations: write + contents: write + id-token: write + env: + PREVIOUS_TAG: ${{ needs.prepare.outputs.prev_tag }} + RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + REPOSITORY: ${{ github.repository }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 + persist-credentials: false ref: ${{ needs.prepare.outputs.commit }} - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: release-binary-* path: release-assets/ merge-multiple: true - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: release-deb-* path: release-assets/ merge-multiple: true - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: release-rpm-* path: release-assets/ merge-multiple: true - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: release-source path: release-assets/ - name: Generate checksums run: | cd release-assets - sha256sum * > SHA256SUMS + sha256sum -- * > SHA256SUMS cat SHA256SUMS + - name: Attest release assets + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: release-assets/* - name: Generate release notes shell: bash run: | set -euo pipefail NOTES_FILE="release-notes.md" - if [ -n "${{ needs.prepare.outputs.prev_tag }}" ]; then - git log "${{ needs.prepare.outputs.prev_tag }}..${{ needs.prepare.outputs.commit }}" \ - --pretty=format:'* %s (%h)' | head -50 > "${NOTES_FILE}" + if [ -n "${PREVIOUS_TAG}" ]; then + git log "${PREVIOUS_TAG}..${RELEASE_COMMIT}" \ + --max-count=50 --pretty=format:'* %s (%h)' > "${NOTES_FILE}" else - git log "${{ needs.prepare.outputs.commit }}" \ - --pretty=format:'* %s (%h)' | head -50 > "${NOTES_FILE}" + git log "${RELEASE_COMMIT}" \ + --max-count=50 --pretty=format:'* %s (%h)' > "${NOTES_FILE}" fi if [ ! -s "${NOTES_FILE}" ]; then @@ -417,12 +435,7 @@ jobs: Runtime: \`\`\` - docker pull ${{ env.GHCR_IMAGE }}:${{ needs.prepare.outputs.version }} - \`\`\` - - Dev: - \`\`\` - docker pull ${{ env.GHCR_DEV_IMAGE }}:${{ needs.prepare.outputs.version }} + docker pull ${GHCR_IMAGE}:${RELEASE_VERSION} \`\`\` ## Verify downloads @@ -435,9 +448,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "${{ needs.prepare.outputs.version }}" \ + gh release create "${RELEASE_VERSION}" \ release-assets/* \ - --repo "${{ github.repository }}" \ - --target "${{ needs.prepare.outputs.commit }}" \ - --title "${{ needs.prepare.outputs.version }}" \ + --repo "${REPOSITORY}" \ + --target "${RELEASE_COMMIT}" \ + --title "${RELEASE_VERSION}" \ --notes-file release-notes.md diff --git a/.gitignore b/.gitignore index eab8410..5a49af8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ /version.env /config.yaml -# Internal real-cluster test harness: hardcodes private cluster IPs, the croit jump host, SSH key -# paths and a test cluster secret. Kept local-only (this repo is public); do not commit. +# Local real-cluster test harnesses may contain private addresses, credentials and secrets. +# Keep them outside version control. /tests/realcluster/ diff --git a/docs/architecture.md b/ARCHITECTURE.md similarity index 97% rename from docs/architecture.md rename to ARCHITECTURE.md index ba4d894..75c2f09 100644 --- a/docs/architecture.md +++ b/ARCHITECTURE.md @@ -333,19 +333,19 @@ Cluster formation (`auto_form_cluster` in `src/raft/mod.rs`, `src/raft/probe.rs` cluster-wide identical membership (built from `peers`). OpenRaft documents concurrent `initialize` with the same config as safe (only *different* configs cause split brain, and the shared `peers` roster already rules that out). Raft then elects a single leader among the - reachable majority. Because no node is special, **any majority can form — or recover — the + reachable majority. Because no node is special, **any majority can form - or recover - the cluster even if the lowest-id node is permanently gone.** This matters for diskless/PXE nodes that keep no state across reboots: after a full outage, whichever majority comes back reforms the cluster on its own. - **Quorum gate + existing-cluster check.** A node initializes only after a majority of peers (including itself) respond *uninitialized*, so a network partition yields at most one side with a leader, never two. If any peer reports an existing cluster (`initialized` or a known leader), - the node declines and joins as a follower via replication — so a blank-rebooted node **rejoins** + the node declines and joins as a follower via replication - so a blank-rebooted node **rejoins** rather than re-forming. - **Cluster incarnation fence.** The two facts above protect the common cases but leave one gap: if a minority is partitioned away and the majority then *loses its state and reforms* while the minority is still gone, the returning minority would hold stale, possibly higher-term state that - Raft's log-recency rule could let win — overwriting the legitimate majority (the in-memory store + Raft's log-recency rule could let win - overwriting the legitimate majority (the in-memory store violates Raft's durable-storage assumption). To close this, the first leader of a freshly formed cluster commits a random `ClusterFormed { cluster_id }` *incarnation*, which every member carries in the transport handshake. A node holding a *different* concrete incarnation has its Raft RPCs @@ -390,7 +390,7 @@ Model: - `node_id` - `raft_listen` - `client_submit_listen` -- Cluster formation is automatic — see "Cluster formation" above. +- Cluster formation is automatic - see "Cluster formation" above. Defaults and normalization at load time: @@ -490,4 +490,4 @@ Explicit non-goals: - cluster formation does not depend on durable state: any reachable majority reforms the cluster automatically (see "Cluster formation"), and a restart-safe reclaim path exists for VIPs -When an AI agent proposes features outside this list, the default answer should be to push back unless the project's stated scope has explicitly changed. +Features outside this list require an explicit project-scope decision before implementation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a42b9e7..871c647 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,14 +13,12 @@ following for the submitted work: - a signed Contributor License Agreement (`CLA`), or - a copyright assignment accepted by croit GmbH -Submitting a merge request does not by itself replace these contributor terms. If your contribution +Submitting a pull request does not by itself replace these contributor terms. If your contribution is accepted for review, the maintainers will tell you which path applies and how to complete it. ## Practical Workflow -- open or reference the relevant GitLab issue +- open or reference the relevant GitHub issue - work on a dedicated branch - keep changes scoped to the issue - run the relevant validation commands before requesting review - -For repository-specific branch, push, and MR expectations, see `AGENTS.md`. diff --git a/Cargo.lock b/Cargo.lock index 24acd48..885e712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -727,9 +727,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openraft" -version = "0.10.0-alpha.25" +version = "0.10.0-alpha.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a66d99dfb16ed37410298bcd5e5de85b2578471fa2fe6b630123a9fe1435960" +checksum = "094676c5b08a2fdd82dfcff60a128efbe5bb4792dfb96dac05ad231a123fa942" dependencies = [ "anyerror", "backoff-series", @@ -756,22 +756,22 @@ dependencies = [ [[package]] name = "openraft-macros" -version = "0.10.0-alpha.25" +version = "0.10.0-alpha.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c842098c7cc8fb053b94daa2eb4417de160fce2bc1bc53c1164357f00b73cb6" +checksum = "f5c57259539e016f70d05ea7e264573cc5ddabd651a127a13374e47b165b7664" dependencies = [ "chrono", "proc-macro2", "quote", "semver", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "openraft-rt" -version = "0.10.0-alpha.25" +version = "0.10.0-alpha.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2e39f9dbbbe5eb38d4db51e6e10efdf585ffb85bbe3e6cd466214d7562f80f" +checksum = "02379ddf29c426905a6e91c5df522003ee2a9a9811b3b8be01e90ab1106ecef6" dependencies = [ "futures-channel", "futures-util", @@ -782,9 +782,9 @@ dependencies = [ [[package]] name = "openraft-rt-tokio" -version = "0.10.0-alpha.25" +version = "0.10.0-alpha.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465a3a25c12eef62dfa8ff2f732993fe73efa163dc5a523ed3aec20bbb89a723" +checksum = "cfeb8c656d8b0e01319aafc70f77b3b312cac56d07adf157d80b49677cc2fcec" dependencies = [ "futures-util", "openraft-rt", @@ -1296,6 +1296,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index ded6a73..eaeb2f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } # Pinned exactly: 0.10 is an alpha; the storage/network trait surface can churn between alphas. -# The I/O-progress redesign in 0.10 is what fixes the diskless restart-past-snapshot crash (bug 6). -openraft = { version = "=0.10.0-alpha.25", features = ["serde", "type-alias"] } +# The I/O-progress redesign in 0.10 fixes the diskless restart-past-snapshot crash. +openraft = { version = "=0.10.0-alpha.32", features = ["serde", "type-alias"] } futures = "0.3" diff --git a/Dockerfile b/Dockerfile index 9779e43..336bbe6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# Base-image registry. Defaults to `docker.io` so a local `docker build` works unchanged; override -# with `--build-arg DOCKER_HUB_MIRROR=` to pull base images through a registry mirror and -# avoid Docker Hub's unauthenticated pull rate limit (HTTP 429). +# Base-image registry. Defaults to `docker.io`; override it with +# `--build-arg DOCKER_HUB_MIRROR=` when a pull-through mirror is available. ARG DOCKER_HUB_MIRROR=docker.io -FROM ${DOCKER_HUB_MIRROR}/library/rust:1.91.1-slim-trixie AS builder +FROM ${DOCKER_HUB_MIRROR}/library/rust:1.91.1-slim-trixie@sha256:f75071363e7f4771769d4cf81b1b7b290e607f4d4459e8731f6abdcee9982dc8 AS rust-base RUN apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ binutils \ ca-certificates \ @@ -13,6 +13,24 @@ RUN apt-get update \ WORKDIR /app +# Keep CI tooling independent of application sources so ordinary code changes can reuse this layer. +FROM rust-base AS dev + +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + git \ + libssl-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* \ + && rustup component add rustfmt clippy \ + && cargo install cargo-tarpaulin --locked \ + && rm -rf "${CARGO_HOME}/registry" "${CARGO_HOME}/git" + +CMD ["cargo", "test", "--all-targets", "--locked"] + +FROM rust-base AS builder + COPY Cargo.toml Cargo.lock ./ RUN mkdir src \ @@ -25,22 +43,9 @@ COPY src ./src RUN cargo build --release --locked \ && strip target/release/keepafloatd -FROM builder AS dev - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - git \ - libssl-dev \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* \ - && rustup component add rustfmt clippy \ - && cargo install cargo-tarpaulin --locked - -CMD ["cargo", "test", "--all-targets", "--locked"] - # Re-declare the global ARG so this stage's FROM can reference it (Docker scopes pre-FROM ARGs). ARG DOCKER_HUB_MIRROR=docker.io -FROM ${DOCKER_HUB_MIRROR}/library/debian:trixie-slim AS runtime-base +FROM ${DOCKER_HUB_MIRROR}/library/debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 AS runtime-base LABEL org.opencontainers.image.title="keepafloatd" \ org.opencontainers.image.description="Raft-based VIP failover daemon" \ @@ -54,6 +59,7 @@ LABEL org.opencontainers.image.title="keepafloatd" \ io.croit.keepafloatd.readonly-rootfs="recommended" RUN apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ ca-certificates \ iproute2 \ @@ -78,17 +84,14 @@ CMD ["-c", "/etc/keepafloatd/config.yaml"] # Release runtime: built from the pre-cross-compiled binary in ./dist instead of recompiling in the # image, so an arm64 image does not run the whole Rust build under QEMU emulation. `docker buildx` # sets TARGETARCH per target platform, selecting the matching binary the release build already made. -# The release job selects this explicitly with `--target runtime-dist`. FROM runtime-base AS runtime-dist ARG TARGETARCH COPY dist/keepafloatd-linux-${TARGETARCH} /usr/local/bin/keepafloatd RUN chmod 0755 /usr/local/bin/keepafloatd USER 10001:10001 -# CI / e2e runtime: binary compiled in-image from the `builder` stage (these builds have no -# pre-built binary to hand in). USER follows the copy so the root-owned binary lands correctly. -# Kept LAST so a bare `docker build .` (no --target, e.g. the e2e job) defaults to this -# self-contained stage rather than runtime-dist, which requires ./dist. +# CI / e2e runtime: binary compiled in-image from the `builder` stage. Keep this last so a bare +# `docker build .` does not select the release-only stage, which requires pre-built binaries. FROM runtime-base AS runtime COPY --from=builder /app/target/release/keepafloatd /usr/local/bin/keepafloatd USER 10001:10001 diff --git a/README.md b/README.md index 0716aaf..6a04823 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,356 @@ # keepAfloatD -**Highly available virtual IPs for Linux — multi-active, quorum-backed, no split brain.** +**Standalone Rust daemon** for Keepalived-like virtual IP failover using **OpenRaft** instead of +VRRP. One process reads **one** YAML file: **one** Raft cluster, **one** health-check definition, +**one** shared VIP list. -[![CI](https://github.com/croit/keepAfloatD/actions/workflows/ci.yml/badge.svg)](https://github.com/croit/keepAfloatD/actions/workflows/ci.yml) -[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) +**Scope (v1):** a single config must not describe multiple independent failover groups. If you +need isolation, run multiple daemon instances with separate configs, ports and Raft clusters. +Multiple VIPs inside one cluster are supported and are distributed round-robin across healthy +members. -keepAfloatD keeps your service reachable through a floating virtual IP (VIP) even when a node -fails. Unlike classic active/passive failover, it distributes multiple VIPs across all healthy -nodes at once and uses Raft consensus to decide ownership — so a VIP is never held by two nodes, -and any majority of the cluster can keep serving or recover on its own. +**Documentation:** [docs/operations.md](docs/operations.md) for running and troubleshooting a +cluster (operators), [docs/development.md](docs/development.md) for building, testing and the e2e +harness (contributors), [ARCHITECTURE.md](ARCHITECTURE.md) for the design. -## Features +## Build -- **Multi-active** — many VIPs spread evenly across every healthy node, not one hot node and cold standbys. -- **No split brain by design** — ownership is a committed Raft decision; without a quorum a node refuses to hold a VIP. -- **Health-checked failover & failback** — you supply a script/command; VIPs move off unhealthy nodes and (optionally) fail back when they recover. -- **Self-forming cluster** — nodes discover each other and elect a leader automatically; any majority can form or recover the cluster, in any start order. -- **Runs anywhere** — a single static binary, plus `.deb`, `.rpm` and multi-arch (amd64/arm64) container images. -- **IPv4 & IPv6**, optional 802.1Q VLAN tags, gratuitous ARP on takeover. -- **One small YAML config** per node; ships with a `systemd` unit. +```bash +cargo build --release +``` + +Binary: `target/release/keepafloatd` (default config path `config.yaml`; override with +`--config` / `-c`). + +The container image keeps the binary default unchanged and sets its own default config path with +`-c /etc/keepafloatd/config.yaml`. The packaged `systemd` template uses +`/etc/keepafloatd/config-%i.yaml`; the shipped `/etc/keepafloatd/config.yaml` is a sample config +you can copy to an instance-specific name such as `/etc/keepafloatd/config-node1.yaml` and start +with `systemctl enable --now keepafloatd@node1`. + +## Architecture + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ Raft log │ +│ - HealthUpdate { node_id, healthy } │ +│ - VipReleased { node_id, vip, generation } │ +├──────────────────────────────────────────────────────────────────────────┤ +│ Committed state machine │ +│ - per-node health + committed probe rounds │ +│ - vip -> VipAssignment { holder, generation, previous_holder, ... } │ +│ - deterministic multi-VIP rebalancing │ +└──────────────────────────────────────────────────────────────────────────┘ + ▲ │ + │ submit_request / forward-to-leader │ apply + │ ▼ +┌────────────────────┐ ┌──────────────────────┐ +│ Health script │ │ VIP bind / unbind │ +│ local async check │ │ ip addr, arping │ +└────────────────────┘ └──────────────────────┘ +``` + +**Layers:** Raft replicates health observations plus old-holder release acknowledgements. The +state machine records the committed owner of every VIP and fences ownership changes with a per-VIP +generation. Locally, the daemon runs a Keepalived-style command on an interval and binds Linux +addresses only when both the local gates and the committed handoff fence allow it. + +### When is a VIP bound on this host? + +All of these must be true: + +1. The cluster has a **current leader**. +2. The local **health check** succeeds. +3. The node is still **consensus-fresh**: its most recent submit to Raft succeeded. +4. Committed state maps this `node_id` as the VIP's **holder**. +5. The VIP's activation fence has opened: + the previous holder either committed `VipReleased` for the current generation or is already + ineligible/stale. + +If any one of these becomes false, the daemon must not keep the VIP and will unbind it on the next +reconcile tick. + +### Multi-VIP distribution + +VIPs are sorted by address. Eligible nodes are voter members whose last-reported health is `true` +and whose most recent committed probe round is within the configured stale window of the cluster's +latest committed probe round. Eligible nodes are sorted by id and VIPs are assigned round-robin in +that order. When health or membership changes, the committed holder map rebalances deterministically +on every node. + +## Failover behavior + +Three independent mechanisms force this node off a VIP: + +- **Local health fails, times out or cannot execute:** `local_healthy` flips to `false`; the next + reconcile tick unbinds every VIP currently held here. The same `HealthUpdate { healthy: false }` + is submitted to Raft so ownership can move. +- **Consensus freshness is lost:** if a health or release submit cannot be committed, the local + `consensus_fresh` gate flips to `false` immediately. This node will unbind all VIPs even if it + still has a stale local idea of the leader. +- **Ownership changes:** if the previous holder is still eligible, the replacement waits for a + committed `VipReleased` acknowledgement from that previous holder before binding. If the previous + holder is already unhealthy/stale, the replacement waits one extra committed probe round before + activating. + +### Silent holder death / partition + +If a holder dies or is partitioned before it can publish `healthy: false`, survivors keep +committing probe rounds while that node's committed round stops advancing. Once the lag exceeds the +configured stale window, the old holder is removed from eligibility and the VIP is reassigned. +Because the isolated node also loses `consensus_fresh` as soon as submits fail, it self-fences and +releases the VIP instead of keeping a stale bind alive indefinitely. + +### Crash and stop symmetry + +- On startup, [`vip::LocalVip::startup_cleanup`] removes every configured VIP from its interface + (best effort). This reclaims an address left behind by a previous crashed instance before the new + process rejoins Raft. +- On `SIGINT` or `SIGTERM`, the daemon stops the reconcile loop, unbinds every VIP it still owns, + then shuts down submit/Raft. The sample `systemd` unit uses the normal `SIGTERM` stop path. + +## Requirements -## Quick start +- Linux (`ip`; optional `arping`). +- Privileges for `ip addr add|del`: typically root or `CAP_NET_ADMIN`. The sample + `deploy/systemd/keepafloatd.service` also grants `CAP_NET_RAW` for some `arping` + implementations. +- Rust edition 2024 toolchain (1.87+). -**1. Install.** Grab a binary, package or image from the [latest release](https://github.com/croit/keepAfloatD/releases/latest): +## Tests ```bash -# Debian/Ubuntu -sudo dpkg -i keepafloatd_*_amd64.deb +cargo test +``` + +Unit tests cover: + +- YAML normalization and validation. +- Eligibility / staleness filtering from committed probe rounds. +- Round-robin multi-VIP assignment. +- Assignment generation / previous-holder fencing. +- Cluster-secret validation on both transports. +- Divergent applied-index scenarios proving the release gate keeps the active binder count at `<= 1` + under the tested failure cases. + +`cargo test` does not spin up multiple real processes on real interfaces, but the core ownership, +handoff and exclusivity rules are exercised directly in unit tests. + +For CI smoke coverage, the repository also ships a dry-run multi-node harness: + +```bash +cargo build --release +bash scripts/ci/e2e-dry-run.sh +``` + +It starts three local daemon processes with temporary configs, validates deterministic VIP +distribution, forces a health-driven rebalance, and verifies graceful `SIGTERM` takeover without +touching real host addresses. + +## Running E2E tests -# RHEL/el9 -sudo rpm -i keepafloatd-*.x86_64.rpm +The repository also ships a Docker Compose harness for real end-to-end failover on a private +bridge network. It starts three `keepafloatd` containers plus an `e2e-runner` probe container on +`10.50.0.0/24`, with VIPs `10.50.0.100` through `10.50.0.102` claimed inside that bridge only. -# or the static binary -curl -fsSL -o keepafloatd https://github.com/croit/keepAfloatD/releases/latest/download/keepafloatd-linux-amd64 -chmod +x keepafloatd && sudo mv keepafloatd /usr/local/bin/ +Build the image locally, then point Compose at it: -# or a container -docker pull ghcr.io/croit/keepafloatd:latest +```bash +docker build -t keepafloatd:dev . +KEEPAFLOATD_IMAGE=keepafloatd:dev docker compose up -d +``` + +Run the scenario suite: + +```bash +bash tests/e2e/scripts/run.sh ``` -**2. Configure** each node — same cluster, one shared secret, one VIP list. Minimal `config.yaml`: +Tear everything down cleanly: + +```bash +docker compose down -v +``` + +The E2E fixtures live under `tests/e2e/`: + +- `configs/` contains the 3 static node configs consumed by the Compose harness +- `scripts/health.sh` is the toggleable local probe used to flip one node unhealthy +- `scenarios/` contains the 13 failover scenarios (steady state, holder death, leader death, local + unhealthy, minority partition, graceful SIGINT, restart/rejoin, full-outage majority recovery, + concurrent cold start, returning nodes joining a survivor, and survivor rejoin after a leadership + change, sticky VIP placement, and stale-survivor rejection after cluster reform) + +`tests/e2e/scripts/run.sh` resets the Compose stack between scenarios, waits for steady state, and +runs every scenario (continuing past failures). It captures per-scenario logs under +`e2e-artifacts/compose/` (including each run's `scenario.out`) and writes an aggregated +`e2e-artifacts/report.md` summarizing every scenario's PASS/FAIL plus a short failure excerpt, so a +failed run can be triaged from one file. It uses only `KEEPAFLOATD_IMAGE` for local and CI parity. + +## CI/CD + +GitHub Actions runs formatting, unit, documentation, license, coverage, package, container and E2E +checks. The release workflow builds static amd64 and arm64 binaries, DEB and RPM packages, a +vendored source archive and multi-architecture container images. + +Published releases are available from [GitHub Releases](https://github.com/croit/keepAfloatD/releases), +with runtime images at `ghcr.io/croit/keepafloatd`. + +## Container image + +Build the host-architecture image locally: + +```bash +docker build -t keepafloatd:dev . +``` + +Run it with a mounted config and the capabilities needed for VIP management: + +```bash +docker run --rm \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ + -v "$(pwd)/config.example.yaml:/etc/keepafloatd/config.yaml:ro" \ + keepafloatd:dev +``` + +Notes: + +- The runtime image runs as uid/gid `10001` (`keepafloatd`). +- `CAP_NET_ADMIN` is required for `ip addr add|del`; `CAP_NET_RAW` is needed when `arping` is + used. +- No ports are exposed in the image; publish the configured Raft/submit ports explicitly. +- A read-only root filesystem is recommended. `/var/lib/keepafloatd` is reserved for future + writable state. +- The runtime image is intentionally minimal and does not ship `bash`; use `/bin/sh`, simple + binaries already in the image, or mount an external health-check script if needed. + +## Manual multi-node (same host) + +Use `examples/node1.yaml`, `examples/node2.yaml`, `examples/node3.yaml`. Only `node_id` and the +listen addresses differ. + +```bash +./target/release/keepafloatd -c examples/node1.yaml +./target/release/keepafloatd -c examples/node2.yaml +./target/release/keepafloatd -c examples/node3.yaml +``` + +The cluster forms automatically regardless of start order and without any special node: each node +probes its peers and, once a majority is reachable and no cluster yet exists, every node calls +`Raft::initialize` with the identical roster (safe per OpenRaft) and Raft elects one leader. Any +majority can form - or, after a full outage, recover - the cluster, even if the lowest-id node is +down. + +Examples use `dry_run: true` and `interface: lo` so you can exercise Raft without touching real +addresses. + +## Configuration + +See `config.example.yaml`. + +| Field | Meaning | +|---|---| +| `node_id` | Stable id for this process; must appear in `peers`. No node is special - any majority forms the cluster. | +| `raft_listen` | Address this node listens on for Raft RPC. Must match `peers[node_id].raft_address`. | +| `client_submit_listen` | Address where the leader accepts forwarded `HealthUpdate` and `VipReleased` requests. Must match `peers[node_id].client_submit_address`. | +| `peers` | Peer list (`id`, `raft_address`, `client_submit_address`). Must be identical on every member. | +| `vips` | VIP list (`address`, `interface`). `address` accepts an optional CIDR suffix (`10.0.0.101/24`); without one the VIP is bound as a host route (`/32` for IPv4, `/128` for IPv6). Order is normalized by sorting addresses; duplicates (by IP) are deduped. | +| `health.command` | Executable + args (`execv` style), e.g. `["/bin/sh","-c","curl -sf http://127.0.0.1/"]`. | +| `health.interval_ms` / `timeout_ms` | Probe period and per-run wall timeout (kill on expiry -> unhealthy). | +| `health.stale_secs` | Maximum time a node may stop contributing committed probe rounds before it becomes ineligible. Must be `>= ceil(interval_ms / 1000)`. Defaults to `max(3, ceil(interval_ms / 1000) * 3)`. | +| `cluster_secret` | Optional shared secret for authenticating both Raft handshakes and submit envelopes. | +| `max_frame_bytes` | TCP frame size cap (defaults to 4 MiB; minimum 64 KiB). | +| `submit_timeout_ms` | Wall-clock cap on one submit attempt, including local leader writes and follower->leader forwards (defaults to 2000 ms). | +| `raft` | Optional OpenRaft timing knobs (`election_timeout_{min,max}_ms`, `heartbeat_interval_ms`). | +| `dry_run` | Log intended `ip` operations without mutating the host. | + +All nodes must use the **same** `peers`, `vips`, `health.interval_ms`, `health.stale_secs`, +`cluster_secret` and `max_frame_bytes`. + +## Health checks + +Exit code `0` means healthy. Non-zero exit, timeout or spawn failure means unhealthy. ```yaml -node_id: 1 -raft_listen: "10.0.0.1:7000" -client_submit_listen: "10.0.0.1:7001" -cluster_secret: "change-me-to-a-long-random-token" # required; shared by every node -peers: - - { id: 1, raft_address: "10.0.0.1:7000", client_submit_address: "10.0.0.1:7001" } - - { id: 2, raft_address: "10.0.0.2:7000", client_submit_address: "10.0.0.2:7001" } - - { id: 3, raft_address: "10.0.0.3:7000", client_submit_address: "10.0.0.3:7001" } -vips: - - { address: "10.0.0.100", interface: "eth0" } health: command: ["/bin/sh", "-c", "curl -sf http://127.0.0.1:8080/health >/dev/null"] interval_ms: 2000 timeout_ms: 3000 + stale_secs: 10 ``` -Node 2 and 3 use the same file with their own `node_id` and listen addresses. See -[`config.example.yaml`](config.example.yaml) for every option. +```yaml +health: + command: ["/bin/sh", "-c", "pgrep -x myservice >/dev/null"] + interval_ms: 1000 + timeout_ms: 2000 + stale_secs: 6 +``` + +## Operations + +- **Logs:** `RUST_LOG=info` or `RUST_LOG=debug` (for example `RUST_LOG=keepafloatd=debug`). +- **Stop:** `Ctrl+C`, `kill -TERM`, and `systemctl stop` all drive the same graceful shutdown path. +- **Restart safety:** the next start always runs `startup_cleanup` before rejoining Raft. + +## Security -**3. Run** (needs `CAP_NET_ADMIN` for `ip addr`, typically root): +This daemon has two TCP attack surfaces (Raft RPC and the leader submit listener). v1 hardens them +as follows: + +- **Bind addresses:** bind only to the concrete peer-reachable address advertised for this node in + `peers`; wildcard binds like `0.0.0.0` do not pass config validation. +- **Cluster secret:** when `cluster_secret` is set, every Raft handshake and every submit envelope + must carry the same secret or the request is dropped. +- **Frame-size cap:** `max_frame_bytes` rejects oversized frames before allocating. +- **RPC / submit timeouts:** half-open peers cannot block heartbeats or submit forwarding forever. +- **Per-peer locking:** one slow peer cannot serialize heartbeats to every other peer. + +For networks outside one trusted host or one trusted segment, layer VPN/IPsec/mTLS around this v1 +transport. + +## Limitations (v1) + +- **In-memory Raft storage:** cluster state is not persisted to disk. +- **No dynamic membership API:** `peers` are static config. +- **No mTLS:** `cluster_secret` improves baseline safety but does not replace real transport + security. + +## Rustdoc ```bash -sudo systemctl enable --now keepafloatd@node1 # packaged -# or -sudo keepafloatd -c /etc/keepafloatd/config.yaml # foreground +cargo doc --no-deps --open ``` -The cluster forms itself, elects a leader, and binds the VIPs on healthy nodes. Kill a node and -its VIPs move to a survivor within seconds. - -Want to try it without touching real addresses? The [`examples/`](examples/) configs use -`dry_run: true` and `interface: lo`, so three local processes exercise the full Raft + failover -flow safely. - -## How it works - -Each node runs a health probe and participates in a Raft cluster. Raft replicates health and -VIP-release acknowledgements; the committed state machine decides which node owns each VIP and -fences every handoff with a per-VIP generation, so at most one node ever binds an address. - -```mermaid -flowchart TB - subgraph consensus["Replicated consensus (Raft)"] - log["Raft log
HealthUpdate, VipReleased"] - sm["Committed state machine
per-VIP holder + fencing
deterministic rebalancing"] - log --> sm - end - health["Health probe
your script/command"] - vip["VIP bind / unbind
ip addr, arping"] - health -->|submit| consensus - consensus -->|apply| vip -``` +Public and non-trivial internal items are documented with `//!` / `///`, including config, Raft +ownership logic, health execution, release fencing, and VIP lifecycle behavior. -For the full model — eligibility rules, fencing, failover paths and crash/restart symmetry — see -[the architecture guide](docs/architecture.md). +## Related -## keepAfloatD vs keepalived +- **Keepalived:** VRRP-based; this tool keeps script-style health checks but elects ownership via + Raft. -Both give you floating VIPs with script-based health checks. keepalived elects ownership with -**VRRP** (active/passive per VIP); keepAfloatD elects it with **Raft consensus**, which lets it run -**multiple VIPs active across many nodes at once** and gives quorum-based protection against split -brain. If you want simple two-node VRRP, keepalived is great; if you want multi-active VIPs with -consensus, that is what keepAfloatD is for. +## License -## Documentation +`keepafloatd` uses: -- **[Operations guide](docs/operations.md)** — running, observing, upgrading, securing and - troubleshooting a cluster. For operators / system administrators. -- **[Development guide](docs/development.md)** — building, testing, the end-to-end scenario harness - and code layout. For contributors. -- **[Architecture guide](docs/architecture.md)** — the consensus model, VIP fencing and failover design. +- `GNU AGPL v3` for open-source/community usage +- a commercial license available from `croit.io` -## Building from source +Contributor policy: -Requires a Rust 2024 toolchain (1.87+): +- external contributions require either a signed Contributor License Agreement (`CLA`) or a + copyright assignment accepted by croit GmbH before merge -```bash -cargo build --release # target/release/keepafloatd -cargo test -``` +See `LICENSE`, `LICENSES/AGPL-3.0.txt`, `LICENSES/COMMERCIAL.md`, and `CONTRIBUTING.md`. + +## Third-Party Licenses -## Contributing & license +The current `Cargo.lock` advertises only permissive third-party license families: +`MIT`, `Apache-2.0`, `BSD-2-Clause`, `BSL-1.0`, `Unicode-3.0`, `Unlicense`, and `Zlib` +(including mixed expressions such as `MIT OR Apache-2.0` and `Apache-2.0 WITH LLVM-exception`). -keepAfloatD is dual-licensed: **GNU AGPL v3** for open-source/community use, and a **commercial -license** available from [croit.io](https://croit.io). External contributions require a signed CLA -or copyright assignment — see [CONTRIBUTING.md](CONTRIBUTING.md), [LICENSE](LICENSE) and -[`LICENSES/`](LICENSES/). +The checked-in inventory lives in `THIRD_PARTY_LICENSES.md`, and CI enforces the dependency +license allowlist with `cargo deny check licenses`. That inventory covers the Rust/Cargo dependency +graph and does not attempt to enumerate Debian base-image packages. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 09666d7..7dc2ac3 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -7,7 +7,7 @@ It does not attempt to enumerate Debian or other OS packages pulled in by contai installation paths; its scope is the Rust/Cargo dependency graph for this repository. - Scope: `cargo metadata --format-version 1 --locked` -- Non-root crates observed: `182` +- Non-root crates observed: `183` - Distinct third-party license expressions observed: `13` Every third-party crate in the current lockfile is usable under a permissive license. The only @@ -180,10 +180,10 @@ with a commercial licensing path available from `croit.io`. - `num-traits` `0.2.19` - `once_cell` `1.21.4` - `once_cell_polyfill` `1.70.2` -- `openraft` `0.10.0-alpha.25` -- `openraft-macros` `0.10.0-alpha.25` -- `openraft-rt` `0.10.0-alpha.25` -- `openraft-rt-tokio` `0.10.0-alpha.25` +- `openraft` `0.10.0-alpha.32` +- `openraft-macros` `0.10.0-alpha.34` +- `openraft-rt` `0.10.0-alpha.34` +- `openraft-rt-tokio` `0.10.0-alpha.34` - `parking_lot` `0.12.5` - `parking_lot_core` `0.9.12` - `ppv-lite86` `0.2.21` @@ -216,6 +216,7 @@ with a commercial licensing path available from `croit.io`. - `socket2` `0.6.3` - `syn` `1.0.109` - `syn` `2.0.117` +- `syn` `3.0.4` - `thiserror` `2.0.18` - `thiserror-impl` `2.0.18` - `thread_local` `1.1.9` diff --git a/config.example.yaml b/config.example.yaml index 7e80f2d..5062ee9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -28,12 +28,12 @@ peers: raft_address: "127.0.0.1:7020" client_submit_address: "127.0.0.1:7021" -# Virtual IPs to distribute across healthy voters. `address` takes an optional CIDR -# suffix (`10.0.0.101/24`); without one the VIP is bound as a host route (`/32` for IPv4, +# Virtual IPs to distribute across healthy voters. `address` takes an optional keepalived-style +# CIDR suffix (`10.0.0.101/24`); without one the VIP is bound as a host route (`/32` for IPv4, # `/128` for IPv6), which is the usual choice. The prefix must match how the address is reachable on # `interface`. This list must be identical on every node. # -# Optional: `vlan` (IEEE 802.1Q tag, 1–4094). When set, `ip addr` targets `{interface}.{vlan}` +# Optional: `vlan` (IEEE 802.1Q tag, 1-4094). When set, `ip addr` targets `{interface}.{vlan}` # (e.g. `eth0.100`). The sub-interface must already exist; keepafloatd does not create VLANs. vips: - address: "10.0.0.101/24" @@ -75,20 +75,22 @@ dry_run: false # Optional notify script called when this node gains or releases a VIP (keepalived-compatible). # Invoked as: