diff --git a/.github/deb-s3-gems.sha256 b/.github/deb-s3-gems.sha256 new file mode 100644 index 000000000000..75afacad61c1 --- /dev/null +++ b/.github/deb-s3-gems.sha256 @@ -0,0 +1,9 @@ +116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b aws-eventstream-1.4.0.gem +40bda996876a45a60c43fbf489b04b46216e98c1814c1ac6453b942e0df6501e aws-partitions-1.1277.0.gem +ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe aws-sdk-core-3.254.0.gem +a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 aws-sdk-kms-1.130.0.gem +1217b878b554b45f2152115c5d2623e3497222f46a738e5a96e9767cbf41468b aws-sdk-s3-1.228.2.gem +6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 aws-sigv4-1.12.1.gem +8beb36bd7d5f524644f2e4b947e9212bcb47cab0b50cd8ad459ce527f938b956 deb-s3-26.1.0.gem +238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 jmespath-1.6.2.gem +e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thor-1.5.0.gem diff --git a/.github/scripts/install-deb-s3.sh b/.github/scripts/install-deb-s3.sh new file mode 100755 index 000000000000..9f975b9e82a2 --- /dev/null +++ b/.github/scripts/install-deb-s3.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Install deb-s3 and its full runtime dependency tree from a pinned, checksum +# verified manifest. +# +# deb-s3 runs with the package signing key and the pre-release AWS credentials +# in scope, so it must not be resolved at run time. Every gem is fetched at the +# exact version recorded in the manifest and verified against the SHA256 that +# RubyGems publishes for that release before anything is installed or executed. +# +# Usage: install-deb-s3.sh [manifest] +# +# Regenerating the manifest: fetch each gem and record +# " -.gem", matching the checksum published at +# https://rubygems.org/api/v1/versions/.json for that version. + +set -euo pipefail + +MANIFEST="${1:-.github/deb-s3-gems.sha256}" + +if [[ ! -f "${MANIFEST}" ]]; then + echo "gem manifest not found: ${MANIFEST}" >&2 + exit 1 +fi + +MANIFEST_ABS="$(cd "$(dirname "${MANIFEST}")" && pwd)/$(basename "${MANIFEST}")" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT + +cp "${MANIFEST_ABS}" "${WORKDIR}/gems.sha256" +cd "${WORKDIR}" + +while read -r _sha file; do + [[ -n "${file:-}" ]] || continue + name="${file%-*}" + version="${file##*-}" + version="${version%.gem}" + echo "fetching ${name} ${version}" + gem fetch "${name}" --version "${version}" --platform ruby +done < gems.sha256 + +echo "verifying checksums" +sha256sum --check --strict gems.sha256 + +echo "installing" +${GEM_INSTALL_SUDO-sudo} gem install --local --no-document --ignore-dependencies ./*.gem + +# RubyGems installs versioned binstubs on some distributions (deb-s3.ruby3.2, +# deb-s33.2), so a plain "deb-s3" on PATH is not guaranteed. publish_package.sh +# invokes it by bare name, so link the canonical executable when it is missing. +if ! command -v deb-s3 >/dev/null 2>&1; then + canonical="$(gem contents deb-s3 | grep -E '/bin/deb-s3$' | head -n 1)" + if [[ -z "${canonical}" ]]; then + echo "deb-s3 was installed but its executable could not be located" >&2 + exit 1 + fi + ${GEM_INSTALL_SUDO-sudo} ln -sf "${canonical}" "${LINK_DIR:-/usr/local/bin}/deb-s3" +fi + +# Smoke check: this activates the whole pinned dependency set, so a missing or +# incompatible gem fails here rather than midway through publishing. +echo "verifying deb-s3" +deb-s3 help >/dev/null +echo "deb-s3 ready: $(command -v deb-s3)" diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index ced8a5452144..4b5503ce2f36 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -399,6 +399,139 @@ jobs: target-registry-user: ${{ vars.QUAY_USER }} target-registry-password: ${{ secrets.QUAY_PASSWORD }} + sign-and-publish-deb: + name: Sign DEB packages (GPG) and publish to the pre-release apt repository + needs: + - build-deb + - test-deb-renaming + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + environment: agent-pre-release + timeout-minutes: 30 + permissions: + contents: read + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download amd64 DEB package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: deb-package-amd64 + + - name: Download arm64 DEB package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: deb-package-arm64 + + - name: Install debsigs and GnuPG + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends debsigs gnupg gpg-agent ruby + + - name: Install deb-s3 from the pinned, checksum-verified manifest + run: ./.github/scripts/install-deb-s3.sh .github/deb-s3-gems.sha256 + + - name: Assume the pre-release publishing role + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ vars.AGENT_PRERELEASE_ROLE_ARN }} + aws-region: eu-west-1 + + - name: Sign the DEB packages with debsigs + env: + SIGNING_PUBLIC_KEY: ${{ secrets.SIGNING_PUBLIC_KEY }} + SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }} + SIGNING_PRIVATE_PASSPHRASE: ${{ secrets.SIGNING_PRIVATE_PASSPHRASE }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + run: ./omnibus/package-scripts/sign_debian_package.sh + + - name: Publish the DEB packages to the pre-release apt repository + env: + SIGNING_PUBLIC_KEY: ${{ secrets.SIGNING_PUBLIC_KEY }} + SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }} + SIGNING_PRIVATE_PASSPHRASE: ${{ secrets.SIGNING_PRIVATE_PASSPHRASE }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + run: ./omnibus/package-scripts/publish_package.sh stackstate-agent-3-test + + generate-install-script: + name: Generate the pre-release agent install script + needs: godeps-cache-amd64 + if: github.event_name == 'push' + runs-on: xlarge-public + timeout-minutes: 30 + permissions: + contents: read + container: + image: ${{ needs.godeps-cache-amd64.outputs.ci_image }} # zizmor: ignore[unpinned-images] + credentials: + username: ${{ vars.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Render install.sh against the pre-release repositories + env: + STS_AWS_TEST_BUCKET: stackstate-agent-3-test + STS_AWS_TEST_BUCKET_YUM: stackstate-agent-3-rpm-test + STS_AWS_TEST_BUCKET_WIN: stackstate-agent-3-test + run: | + set -eo pipefail + . /root/miniforge3/etc/profile.d/conda.sh + conda activate "${CONDA_ENV}" + git config --global --add safe.directory '*' + inv release.generate-install -t + grep -q 's3.amazonaws.com' ./cmd/agent/install.sh + if grep -q 'None.s3.amazonaws.com' ./cmd/agent/install.sh; then + echo "install.sh references an unset bucket variable" >&2 + exit 1 + fi + + - name: Upload the rendered install script + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: agent-install-script + path: cmd/agent/install.sh + retention-days: 5 + if-no-files-found: error + + publish-install-script: + name: Publish the pre-release agent install script to S3 + needs: + - generate-install-script + - sign-and-publish-deb + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + environment: agent-pre-release + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - name: Download the rendered install script + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: agent-install-script + + - name: Assume the pre-release publishing role + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ vars.AGENT_PRERELEASE_ROLE_ARN }} + aws-region: eu-west-1 + + - name: Upload install.sh + run: | + set -euo pipefail + aws s3 cp ./install.sh s3://stackstate-agent-3-test/install.sh --acl public-read + aws s3 ls s3://stackstate-agent-3-test/ + cerberus-notify: name: Report failure to Slack (Cerberus) needs: @@ -409,6 +542,9 @@ jobs: - build-agent-image - publish-agent-image - merge-agent-manifest + - sign-and-publish-deb + - generate-install-script + - publish-install-script if: >- always() && github.event_name == 'push' diff --git a/omnibus/package-scripts/gpg_signing_setup.sh b/omnibus/package-scripts/gpg_signing_setup.sh new file mode 100755 index 000000000000..973f7b4160eb --- /dev/null +++ b/omnibus/package-scripts/gpg_signing_setup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Sourced by sign_debian_package.sh and publish_package.sh. +# +# Both scripts need the same signing key in the same state, but they are +# separate processes and on GitHub Actions they may run as separate steps, so +# neither can rely on a keyring the other left behind. Sourcing this keeps the +# setup identical in both without duplicating it. + +gpg_signing_setup() { + : "${SIGNING_PUBLIC_KEY:?SIGNING_PUBLIC_KEY is not set}" + : "${SIGNING_PRIVATE_KEY:?SIGNING_PRIVATE_KEY is not set}" + : "${SIGNING_PRIVATE_PASSPHRASE:?SIGNING_PRIVATE_PASSPHRASE is not set}" + : "${SIGNING_KEY_ID:?SIGNING_KEY_ID is not set}" + + # Debian ships this under /usr/lib/gnupg2 on the old signing image and under + # /usr/lib/gnupg on current releases, and it is not on PATH in either. + local preset="${GPG_PRESET_PASSPHRASE:-}" + if [ -z "${preset}" ]; then + for candidate in \ + /usr/lib/gnupg2/gpg-preset-passphrase \ + /usr/lib/gnupg/gpg-preset-passphrase \ + /usr/libexec/gpg-preset-passphrase \ + "$(command -v gpg-preset-passphrase 2>/dev/null || true)"; do + if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then + preset="${candidate}" + break + fi + done + fi + if [ ! -x "${preset:-}" ]; then + echo "gpg-preset-passphrase not found; set GPG_PRESET_PASSPHRASE" >&2 + return 1 + fi + + # Keep the keyring and the private key off the build workspace: this + # repository is public, and anything left in the checkout can be swept up by + # an artifact upload. The trap also stops a gpg-agent holding a preset + # passphrase from outliving the job on a reused runner. + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + chmod 700 "${GNUPGHOME}" + trap 'gpgconf --kill gpg-agent >/dev/null 2>&1 || true; rm -rf "${GNUPGHOME}"' EXIT + + cat <<-CONF >"${GNUPGHOME}/gpg-agent.conf" + default-cache-ttl 46000 + allow-preset-passphrase + CONF + + local passphrase_file="${GNUPGHOME}/passphrase" + (umask 077; printf '%s' "${SIGNING_PRIVATE_PASSPHRASE}" >"${passphrase_file}") + + printf '%s\n' "${SIGNING_PUBLIC_KEY}" | gpg --batch --quiet --import + printf '%s\n' "${SIGNING_PRIVATE_KEY}" \ + | gpg --batch --yes --quiet --pinentry-mode loopback \ + --passphrase-file "${passphrase_file}" --import + + gpg-connect-agent RELOADAGENT /bye + + # A key can expose more than one keygrip (primary plus subkeys); preset each + # so signing never blocks on a pinentry prompt a CI runner cannot answer. + gpg --list-secret-keys --with-fingerprint --with-colons \ + | awk -F: '$1 == "grp" { print $10 }' \ + | while read -r keygrip; do + "${preset}" --preset "${keygrip}" <"${passphrase_file}" + done +} diff --git a/omnibus/package-scripts/publish_package.sh b/omnibus/package-scripts/publish_package.sh index 828fabf40c71..3af581dbf98f 100755 --- a/omnibus/package-scripts/publish_package.sh +++ b/omnibus/package-scripts/publish_package.sh @@ -1,29 +1,31 @@ #!/bin/bash -TARGET_BUCKET=$1 +set -euo pipefail -CODENAME=${2:-$CI_COMMIT_REF_NAME} -TARGET_CODENAME=${CODENAME:-dirty} +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=omnibus/package-scripts/gpg_signing_setup.sh +source "${script_dir}/gpg_signing_setup.sh" - -if [ -z ${TARGET_BUCKET+x} ]; then - echo "Missing S3 bucket parameter" - exit 1; +TARGET_BUCKET="${1:-}" +if [ -z "${TARGET_BUCKET}" ]; then + echo "Missing S3 bucket parameter" >&2 + exit 1 fi -if [ -z ${STACKSTATE_AGENT_VERSION+x} ]; then - STACKSTATE_AGENT_VERSION=$(cat $CI_PROJECT_DIR/version.txt) -fi -echo $STACKSTATE_AGENT_VERSION +# CI_PROJECT_DIR is GitLab's; GITHUB_WORKSPACE is the GitHub Actions equivalent. +PROJECT_DIR="${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-$(pwd)}}" +PKG_DIR="${PKG_DIR:-${PROJECT_DIR}/outcomes/pkg}" -ls $CI_PROJECT_DIR/outcomes/pkg/*.* +CODENAME="${2:-${CI_COMMIT_REF_NAME:-${GITHUB_REF_NAME:-}}}" +TARGET_CODENAME="${CODENAME:-dirty}" + +if [ -z "${STACKSTATE_AGENT_VERSION:-}" ]; then + STACKSTATE_AGENT_VERSION=$(cat "${PROJECT_DIR}/version.txt") +fi -cat <~/.gnupg/gpg-agent.conf -default-cache-ttl 46000 -allow-preset-passphrase -EOF +echo "Publishing stackstate-agent ${STACKSTATE_AGENT_VERSION} to ${TARGET_BUCKET} (${TARGET_CODENAME})" +ls "${PKG_DIR}"/*.* -gpg-connect-agent RELOADAGENT /bye -echo $SIGNING_PRIVATE_PASSPHRASE | /usr/lib/gnupg2/gpg-preset-passphrase -v -c $(gpg --list-secret-keys --with-fingerprint --with-colons | awk -F: '$1 == "grp" { print $10 }') +gpg_signing_setup -deb-s3 upload --sign=${SIGNING_KEY_ID} --codename ${TARGET_CODENAME} --bucket ${TARGET_BUCKET} $CI_PROJECT_DIR/outcomes/pkg/*.deb +deb-s3 upload --sign="${SIGNING_KEY_ID}" --codename "${TARGET_CODENAME}" --bucket "${TARGET_BUCKET}" "${PKG_DIR}"/*.deb diff --git a/omnibus/package-scripts/sign_debian_package.sh b/omnibus/package-scripts/sign_debian_package.sh index 7ed0f3c8aa94..a1915bdec7ad 100755 --- a/omnibus/package-scripts/sign_debian_package.sh +++ b/omnibus/package-scripts/sign_debian_package.sh @@ -1,30 +1,24 @@ #!/bin/bash -set -e +set -euo pipefail -if [ -z ${STACKSTATE_AGENT_VERSION+x} ]; then +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=omnibus/package-scripts/gpg_signing_setup.sh +source "${script_dir}/gpg_signing_setup.sh" + +# CI_PROJECT_DIR is GitLab's; GITHUB_WORKSPACE is the GitHub Actions equivalent. +PROJECT_DIR="${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-$(pwd)}}" +PKG_DIR="${PKG_DIR:-${PROJECT_DIR}/outcomes/pkg}" + +if [ -z "${STACKSTATE_AGENT_VERSION:-}" ]; then # Pick the latest tag by default for our version. - STACKSTATE_AGENT_VERSION=$(cat $CI_PROJECT_DIR/version.txt) + STACKSTATE_AGENT_VERSION=$(cat "${PROJECT_DIR}/version.txt") # But we will be building from the master branch in this case. fi -echo $STACKSTATE_AGENT_VERSION - -printenv - -echo "$SIGNING_PUBLIC_KEY" | gpg --import -echo "$SIGNING_PRIVATE_KEY" > gpg_private.key -echo "$SIGNING_PRIVATE_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 --import gpg_private.key -echo "$SIGNING_KEY_ID" - -ls $CI_PROJECT_DIR/outcomes/pkg/*.* - -cat <~/.gnupg/gpg-agent.conf -default-cache-ttl 46000 -allow-preset-passphrase -EOF +echo "Signing stackstate-agent ${STACKSTATE_AGENT_VERSION}" +ls "${PKG_DIR}"/*.* -gpg-connect-agent RELOADAGENT /bye -echo $SIGNING_PRIVATE_PASSPHRASE | /usr/lib/gnupg2/gpg-preset-passphrase -v -c $(gpg --list-secret-keys --with-fingerprint --with-colons | awk -F: '$1 == "grp" { print $10 }') +gpg_signing_setup -debsigs --sign=origin -k ${SIGNING_KEY_ID} $CI_PROJECT_DIR/outcomes/pkg/*.deb +debsigs --sign=origin -k "${SIGNING_KEY_ID}" "${PKG_DIR}"/*.deb