diff --git a/.github/workflows/chart-release.yml b/.github/workflows/chart-release.yml new file mode 100644 index 0000000..8c70197 --- /dev/null +++ b/.github/workflows/chart-release.yml @@ -0,0 +1,153 @@ +name: Helm Chart Release + +on: + workflow_call: + inputs: + operator_version: + description: Operator image version referenced by the chart + required: true + type: string + chart_version: + description: Immutable Helm chart version + required: true + type: string + publish: + description: Publish after validation and release-environment approval + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + operator_version: + description: Operator image version referenced by the chart + required: true + default: 0.8.0 + type: string + chart_version: + description: Immutable Helm chart version + required: true + default: 0.7.0 + type: string + +permissions: + actions: read + contents: read + +jobs: + package: + name: Package and validate + runs-on: ubuntu-latest + steps: + - name: Checkout exact revision + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.27.1" + + - name: Install checksum-pinned Helm + run: ./.test/install-helm.sh .test/helm + + - name: Build reproducible chart artifact + env: + OPERATOR_VERSION: ${{ inputs.operator_version }} + CHART_VERSION: ${{ inputs.chart_version }} + HELM_BIN: .test/helm + run: | + go test ./.scripts + ./.scripts/chart-release.sh package + ./.scripts/chart-release.sh verify-remote + + - name: Upload candidate artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: helm-chart-${{ inputs.chart_version }} + path: | + dist/chart/webhookrelay-operator-${{ inputs.chart_version }}.tgz + dist/chart/webhookrelay-operator-${{ inputs.chart_version }}.tgz.sha256 + dist/chart/release-metadata.json + if-no-files-found: error + retention-days: 14 + + lifecycle: + name: Test exact chart artifact in k3s + needs: package + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout exact revision + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.27.1" + + - name: Download validated artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: helm-chart-${{ inputs.chart_version }} + path: validated + + - name: Run packaged chart lifecycle + env: + WHR_E2E_CANDIDATE_CHART: ${{ github.workspace }}/validated/webhookrelay-operator-${{ inputs.chart_version }}.tgz + run: make e2e + + - name: Upload failure diagnostics + if: failure() || cancelled() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: chart-lifecycle-${{ github.run_id }}-${{ github.run_attempt }} + path: .test/artifacts/ + if-no-files-found: warn + retention-days: 7 + + publish: + name: Publish immutable chart + if: inputs.publish == true + needs: [package, lifecycle] + runs-on: ubuntu-latest + environment: release + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Checkout exact revision + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Download validated artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: helm-chart-${{ inputs.chart_version }} + path: validated + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.27.1" + + - name: Authenticate to Google Cloud without a stored key + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - name: Set up Google Cloud CLI + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + with: + version: 577.0.0 + + - name: Install checksum-pinned Helm + run: ./.test/install-helm.sh .test/helm + + - name: Publish with immutable-object preconditions + env: + OPERATOR_VERSION: ${{ inputs.operator_version }} + CHART_VERSION: ${{ inputs.chart_version }} + HELM_BIN: .test/helm + CHART_BUCKET: ${{ vars.CHART_BUCKET }} + VALIDATED_ARTIFACT: validated/webhookrelay-operator-${{ inputs.chart_version }}.tgz + run: ./.scripts/chart-release.sh publish diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099f59b..47f6f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,14 @@ jobs: - name: Install Helm run: ./.test/install-helm.sh .test/helm - - name: Lint and render chart + - name: Lint, render, and package chart run: | + go test ./.scripts .test/helm lint charts/webhookrelay-operator .test/helm template webhookrelay-operator charts/webhookrelay-operator >/dev/null + make chart-package HELM_BIN=.test/helm + make chart-release-check HELM_BIN=.test/helm + make chart-release-test HELM_BIN=.test/helm e2e: name: End-to-End and Helm Lifecycle (k3s) diff --git a/.scripts/chart-artifact.go b/.scripts/chart-artifact.go new file mode 100644 index 0000000..2d34c7c --- /dev/null +++ b/.scripts/chart-artifact.go @@ -0,0 +1,256 @@ +// Command chart-artifact creates reproducible Helm chart archives and verifies +// that a repository index contains the expected immutable artifact. +package main + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func main() { + if len(os.Args) < 2 { + fatalf("usage: chart-artifact package CHART_DIR OUTPUT | verify-index INDEX VERSION DIGEST URL | verify-retained OLD_INDEX NEW_INDEX") + } + var err error + switch os.Args[1] { + case "package": + if len(os.Args) != 4 { + fatalf("usage: chart-artifact package CHART_DIR OUTPUT") + } + err = packageChart(os.Args[2], os.Args[3]) + case "verify-index": + if len(os.Args) != 6 { + fatalf("usage: chart-artifact verify-index INDEX VERSION DIGEST URL") + } + err = verifyIndex(os.Args[2], os.Args[3], os.Args[4], os.Args[5]) + case "verify-retained": + if len(os.Args) != 4 { + fatalf("usage: chart-artifact verify-retained OLD_INDEX NEW_INDEX") + } + err = verifyRetained(os.Args[2], os.Args[3]) + default: + fatalf("unknown command %q", os.Args[1]) + } + if err != nil { + fatalf("%v", err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + +func packageChart(chartDir, output string) error { + chartDir = filepath.Clean(chartDir) + rootName := filepath.Base(chartDir) + var files []string + err := filepath.WalkDir(chartDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if path != chartDir && ignoredDirectory(entry.Name()) { + return filepath.SkipDir + } + return nil + } + if entry.Type().IsRegular() && !ignoredFile(entry.Name()) { + files = append(files, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk chart: %w", err) + } + sort.Strings(files) + if len(files) == 0 { + return errors.New("chart contains no packageable files") + } + + outputFile, err := os.Create(output) + if err != nil { + return fmt.Errorf("create archive: %w", err) + } + defer outputFile.Close() + gzipWriter, err := gzip.NewWriterLevel(outputFile, gzip.BestCompression) + if err != nil { + return fmt.Errorf("create gzip writer: %w", err) + } + gzipWriter.Header.ModTime = time.Unix(0, 0) + gzipWriter.Header.OS = 255 + tarWriter := tar.NewWriter(gzipWriter) + + for _, path := range files { + contents, readErr := os.ReadFile(path) + if readErr != nil { + return fmt.Errorf("read %s: %w", path, readErr) + } + relative, relErr := filepath.Rel(chartDir, path) + if relErr != nil { + return fmt.Errorf("resolve %s: %w", path, relErr) + } + header := &tar.Header{ + Name: filepath.ToSlash(filepath.Join(rootName, relative)), + Mode: 0o644, + Size: int64(len(contents)), + ModTime: time.Unix(0, 0), + Typeflag: tar.TypeReg, + Format: tar.FormatUSTAR, + } + if err := tarWriter.WriteHeader(header); err != nil { + return fmt.Errorf("write header for %s: %w", path, err) + } + if _, err := tarWriter.Write(contents); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + if err := tarWriter.Close(); err != nil { + return fmt.Errorf("close tar archive: %w", err) + } + if err := gzipWriter.Close(); err != nil { + return fmt.Errorf("close gzip archive: %w", err) + } + return outputFile.Close() +} + +func ignoredDirectory(name string) bool { + switch name { + case ".git", ".bzr", ".hg", ".svn", ".idea", ".vscode": + return true + default: + return false + } +} + +func ignoredFile(name string) bool { + if name == ".DS_Store" || name == ".gitignore" || name == ".bzrignore" || name == ".hgignore" || name == ".project" { + return true + } + for _, suffix := range []string{".swp", ".bak", ".tmp", ".orig", ".tmproj", "~"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +type indexEntry struct { + version string + digest string + urls []string +} + +func verifyIndex(path, version, digest, artifactURL string) error { + entries, err := parseIndex(path) + if err != nil { + return err + } + for _, entry := range entries { + if entry.version != version || entry.digest != digest { + continue + } + for _, url := range entry.urls { + if url == artifactURL { + return nil + } + } + } + return fmt.Errorf("index has no version %s with digest %s and URL %s", version, digest, artifactURL) +} + +func parseIndex(path string) ([]indexEntry, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open index: %w", err) + } + defer file.Close() + var entries []indexEntry + var current *indexEntry + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, " - apiVersion:") { + entries = append(entries, indexEntry{}) + current = &entries[len(entries)-1] + continue + } + if current == nil { + continue + } + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "version:"): + current.version = strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "version:")), "\"") + case strings.HasPrefix(trimmed, "digest:"): + current.digest = strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "digest:")), "\"") + case strings.HasPrefix(line, " - "): + current.urls = append(current.urls, strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, " - ")), "\"")) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read index: %w", err) + } + return entries, nil +} + +func verifyRetained(oldPath, newPath string) error { + oldEntries, err := parseIndex(oldPath) + if err != nil { + return err + } + newEntries, err := parseIndex(newPath) + if err != nil { + return err + } + for _, oldEntry := range oldEntries { + found := false + for _, newEntry := range newEntries { + if reflectEntry(oldEntry, newEntry) { + found = true + break + } + } + if !found { + return fmt.Errorf("new index dropped version %s digest %s URLs %v", oldEntry.version, oldEntry.digest, oldEntry.urls) + } + } + return nil +} + +func reflectEntry(left, right indexEntry) bool { + if left.version != right.version || left.digest != right.digest || len(left.urls) != len(right.urls) { + return false + } + for i := range left.urls { + if left.urls[i] != right.urls[i] { + return false + } + } + return true +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/.scripts/chart-artifact_test.go b/.scripts/chart-artifact_test.go new file mode 100644 index 0000000..b218446 --- /dev/null +++ b/.scripts/chart-artifact_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestPackageChartIsReproducible(t *testing.T) { + chartDir := filepath.Join(t.TempDir(), "example") + mustWrite(t, filepath.Join(chartDir, "Chart.yaml"), "name: example\nversion: 1.0.0\n") + mustWrite(t, filepath.Join(chartDir, "templates", "deployment.yaml"), "kind: Deployment\n") + mustWrite(t, filepath.Join(chartDir, ".DS_Store"), "ignored") + + first := filepath.Join(t.TempDir(), "first.tgz") + second := filepath.Join(t.TempDir(), "second.tgz") + if err := packageChart(chartDir, first); err != nil { + t.Fatal(err) + } + if err := packageChart(chartDir, second); err != nil { + t.Fatal(err) + } + firstDigest, err := fileSHA256(first) + if err != nil { + t.Fatal(err) + } + secondDigest, err := fileSHA256(second) + if err != nil { + t.Fatal(err) + } + if firstDigest != secondDigest { + t.Fatalf("archive digests differ: %s != %s", firstDigest, secondDigest) + } + + file, err := os.Open(first) + if err != nil { + t.Fatal(err) + } + gzipReader, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + tarReader := tar.NewReader(gzipReader) + var names []string + for { + header, err := tarReader.Next() + if err != nil { + break + } + names = append(names, header.Name) + } + want := []string{"example/Chart.yaml", "example/templates/deployment.yaml"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("archive files = %v, want %v", names, want) + } +} + +func TestVerifyIndex(t *testing.T) { + path := filepath.Join(t.TempDir(), "index.yaml") + mustWrite(t, path, `apiVersion: v1 +entries: + webhookrelay-operator: + - apiVersion: v2 + digest: abc123 + urls: + - https://charts.example/operator-0.7.0.tgz + version: 0.7.0 +`) + if err := verifyIndex(path, "0.7.0", "abc123", "https://charts.example/operator-0.7.0.tgz"); err != nil { + t.Fatal(err) + } + if err := verifyIndex(path, "0.7.0", "wrong", "https://charts.example/operator-0.7.0.tgz"); err == nil { + t.Fatal("expected digest mismatch") + } + + newPath := filepath.Join(t.TempDir(), "new-index.yaml") + mustWrite(t, newPath, `apiVersion: v1 +entries: + webhookrelay-operator: + - apiVersion: v2 + digest: def456 + urls: + - https://charts.example/operator-0.8.0.tgz + version: 0.8.0 + - apiVersion: v2 + digest: abc123 + urls: + - https://charts.example/operator-0.7.0.tgz + version: 0.7.0 +`) + if err := verifyRetained(path, newPath); err != nil { + t.Fatal(err) + } + if err := verifyRetained(newPath, path); err == nil { + t.Fatal("expected dropped entry error") + } +} + +func mustWrite(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/.scripts/chart-release.sh b/.scripts/chart-release.sh new file mode 100755 index 0000000..b09bb27 --- /dev/null +++ b/.scripts/chart-release.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly REPO_ROOT="$(git rev-parse --show-toplevel)" +readonly CHART_DIR="${REPO_ROOT}/charts/webhookrelay-operator" +readonly OPERATOR_VERSION="${OPERATOR_VERSION:-0.8.0}" +readonly CHART_VERSION="${CHART_VERSION:-0.7.0}" +readonly AGENT_IMAGE="${AGENT_IMAGE:-webhookrelay/webhookrelayd-ubi8:1.37.0}" +readonly OUTPUT_DIR="${OUTPUT_DIR:-${REPO_ROOT}/dist/chart}" +readonly HELM_BIN="${HELM_BIN:-helm}" +readonly REPOSITORY_URL="${REPOSITORY_URL:-https://charts.webhookrelay.com}" +readonly CHART_BUCKET="${CHART_BUCKET:-charts.webhookrelay.com}" +readonly ARTIFACT_NAME="webhookrelay-operator-${CHART_VERSION}.tgz" +readonly ARTIFACT_PATH="${OUTPUT_DIR}/${ARTIFACT_NAME}" +readonly CHECKSUM_PATH="${ARTIFACT_PATH}.sha256" +readonly METADATA_PATH="${OUTPUT_DIR}/release-metadata.json" +readonly VALIDATED_ARTIFACT="${VALIDATED_ARTIFACT:-}" +readonly INDEX_CACHE_CONTROL="no-cache,max-age=0,must-revalidate" + +fail() { + printf 'chart-release: %s\n' "$*" >&2 + exit 1 +} + +yaml_value() { + local file="$1" + local key="$2" + awk -F ': *' -v key="${key}" '$1 == key {gsub(/"/, "", $2); print $2; exit}' "${file}" +} + +validate_versions() { + local chart_app_version chart_version default_image default_image_tag + chart_version="$(yaml_value "${CHART_DIR}/Chart.yaml" version)" + chart_app_version="$(yaml_value "${CHART_DIR}/Chart.yaml" appVersion)" + default_image_tag="$(awk ' + /^image:/ {in_image=1; next} + in_image && /^ tag:/ {gsub(/[" ]/, "", $2); print $2; exit} + ' "${CHART_DIR}/values.yaml")" + default_image="$(awk -F '"' '/Image string `default:/ {print $2; exit}' \ + "${REPO_ROOT}/pkg/config/config.go")" + [[ "${chart_version}" == "${CHART_VERSION}" ]] || \ + fail "Chart.version ${chart_version} does not equal ${CHART_VERSION}" + [[ "${chart_app_version}" == "${OPERATOR_VERSION}" ]] || \ + fail "Chart.appVersion ${chart_app_version} does not equal ${OPERATOR_VERSION}" + [[ "${default_image_tag}" == "${OPERATOR_VERSION}" ]] || \ + fail "chart image tag ${default_image_tag} does not equal ${OPERATOR_VERSION}" + [[ "${default_image}" == "${AGENT_IMAGE}" ]] || \ + fail "operator agent image ${default_image} does not equal ${AGENT_IMAGE}" +} + +package_chart() { + local digest reference_archive reference_dir second_archive + mkdir -p "${OUTPUT_DIR}" + validate_versions + "${HELM_BIN}" lint "${CHART_DIR}" + go run "${REPO_ROOT}/.scripts/chart-artifact.go" package "${CHART_DIR}" "${ARTIFACT_PATH}" + second_archive="${OUTPUT_DIR}/.${ARTIFACT_NAME}.reproducibility-check" + go run "${REPO_ROOT}/.scripts/chart-artifact.go" package "${CHART_DIR}" "${second_archive}" + cmp "${ARTIFACT_PATH}" "${second_archive}" || fail "chart packaging is not reproducible" + rm -f "${second_archive}" + reference_dir="$(mktemp -d)" + "${HELM_BIN}" package "${CHART_DIR}" --destination "${reference_dir}" >/dev/null + reference_archive="${reference_dir}/${ARTIFACT_NAME}" + diff -u <(tar -tzf "${reference_archive}" | sort) <(tar -tzf "${ARTIFACT_PATH}" | sort) || \ + fail "reproducible archive members differ from pinned Helm packaging" + rm -rf -- "${reference_dir}" + "${HELM_BIN}" lint "${ARTIFACT_PATH}" + digest="$(sha256sum "${ARTIFACT_PATH}" | awk '{print $1}')" + printf '%s %s\n' "${digest}" "${ARTIFACT_NAME}" >"${CHECKSUM_PATH}" + printf '{"operatorVersion":"%s","chartVersion":"%s","agentImage":"%s","sha256":"%s"}\n' \ + "${OPERATOR_VERSION}" "${CHART_VERSION}" "${AGENT_IMAGE}" "${digest}" >"${METADATA_PATH}" +} + +gcs_artifact_status() { + local error_file generation remote_artifact + error_file="${OUTPUT_DIR}/gcs-artifact-error.txt" + if generation="$(gcloud storage objects describe "gs://${CHART_BUCKET}/${ARTIFACT_NAME}" \ + --format='value(generation)' 2>"${error_file}")"; then + remote_artifact="${OUTPUT_DIR}/gcs-${ARTIFACT_NAME}" + gcloud storage cp "gs://${CHART_BUCKET}/${ARTIFACT_NAME}#${generation}" \ + "${remote_artifact}" >/dev/null + cmp "${ARTIFACT_PATH}" "${remote_artifact}" || \ + fail "refusing to overwrite published ${ARTIFACT_NAME} with different bytes" + printf 'identical\n' + return + fi + if grep -Eqi 'not found|404' "${error_file}"; then + printf 'missing\n' + return + fi + fail "could not inspect gs://${CHART_BUCKET}/${ARTIFACT_NAME}: $(<"${error_file}")" +} + +verify_public_repository() { + local digest="$1" + local http_status + for _ in $(seq 1 30); do + http_status="$(curl --location --show-error --silent \ + --output "${OUTPUT_DIR}/published-artifact.tgz" --write-out '%{http_code}' \ + "${REPOSITORY_URL}/${ARTIFACT_NAME}")" + if [[ "${http_status}" == "200" ]] && \ + cmp "${ARTIFACT_PATH}" "${OUTPUT_DIR}/published-artifact.tgz" && \ + curl --fail --location --show-error --silent --header 'Cache-Control: no-cache' \ + --output "${OUTPUT_DIR}/published-index.yaml" "${REPOSITORY_URL}/index.yaml" && \ + go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-index \ + "${OUTPUT_DIR}/published-index.yaml" "${CHART_VERSION}" "${digest}" \ + "${REPOSITORY_URL}/${ARTIFACT_NAME}" >/dev/null 2>&1; then + return + fi + sleep 2 + done + fail "public chart artifact and index did not converge" +} + +remote_artifact_status() { + local http_status remote_artifact + remote_artifact="${OUTPUT_DIR}/remote-${ARTIFACT_NAME}" + http_status="$(curl --location --show-error --silent --output "${remote_artifact}" \ + --write-out '%{http_code}' "${REPOSITORY_URL}/${ARTIFACT_NAME}")" + case "${http_status}" in + 200) + cmp "${ARTIFACT_PATH}" "${remote_artifact}" || \ + fail "refusing to overwrite published ${ARTIFACT_NAME} with different bytes" + printf 'identical\n' + ;; + 404) + printf 'missing\n' + ;; + *) + fail "artifact lookup returned HTTP ${http_status}" + ;; + esac +} + +verify_remote() { + local digest status remote_index + package_chart + status="$(remote_artifact_status)" + [[ "${status}" == "identical" ]] || { + printf 'chart-release: %s is not published\n' "${ARTIFACT_NAME}" + return 0 + } + digest="$(awk '{print $1}' "${CHECKSUM_PATH}")" + remote_index="${OUTPUT_DIR}/remote-index.yaml" + curl --fail --location --show-error --silent --output "${remote_index}" \ + "${REPOSITORY_URL}/index.yaml" + go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-index "${remote_index}" \ + "${CHART_VERSION}" "${digest}" "${REPOSITORY_URL}/${ARTIFACT_NAME}" +} + +publish_chart() { + local digest index_cache_control index_dir index_generation status + command -v gcloud >/dev/null || fail "gcloud is required to publish" + package_chart + if [[ -n "${VALIDATED_ARTIFACT}" ]]; then + cmp "${ARTIFACT_PATH}" "${VALIDATED_ARTIFACT}" || \ + fail "rebuilt chart does not match the validated artifact" + fi + digest="$(awk '{print $1}' "${CHECKSUM_PATH}")" + status="$(gcs_artifact_status)" + index_generation="$(gcloud storage objects describe "gs://${CHART_BUCKET}/index.yaml" \ + --format='value(generation)')" + gcloud storage cp "gs://${CHART_BUCKET}/index.yaml#${index_generation}" \ + "${OUTPUT_DIR}/remote-index.yaml" >/dev/null + if [[ "${status}" == "identical" ]] && \ + go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-index "${OUTPUT_DIR}/remote-index.yaml" \ + "${CHART_VERSION}" "${digest}" "${REPOSITORY_URL}/${ARTIFACT_NAME}" >/dev/null 2>&1; then + verify_public_repository "${digest}" + printf 'chart-release: authoritative and public artifacts already match\n' + return 0 + fi + if [[ "${status}" == "missing" ]]; then + gcloud storage cp "${ARTIFACT_PATH}" "gs://${CHART_BUCKET}/${ARTIFACT_NAME}" \ + --if-generation-match=0 --content-type=application/gzip + fi + + index_dir="${OUTPUT_DIR}/repository" + mkdir -p "${index_dir}" + cp "${ARTIFACT_PATH}" "${index_dir}/${ARTIFACT_NAME}" + "${HELM_BIN}" repo index "${index_dir}" --url "${REPOSITORY_URL}" \ + --merge "${OUTPUT_DIR}/remote-index.yaml" + go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-retained \ + "${OUTPUT_DIR}/remote-index.yaml" "${index_dir}/index.yaml" + go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-index "${index_dir}/index.yaml" \ + "${CHART_VERSION}" "${digest}" "${REPOSITORY_URL}/${ARTIFACT_NAME}" + gcloud storage cp "${index_dir}/index.yaml" "gs://${CHART_BUCKET}/index.yaml" \ + --if-generation-match="${index_generation}" --content-type=application/yaml \ + --cache-control="${INDEX_CACHE_CONTROL}" + index_cache_control="$(gcloud storage objects describe "gs://${CHART_BUCKET}/index.yaml" \ + --format='value(cache_control)')" + [[ "${index_cache_control}" == "${INDEX_CACHE_CONTROL}" ]] || \ + fail "published index cache policy is ${index_cache_control:-unset}, expected ${INDEX_CACHE_CONTROL}" + + verify_public_repository "${digest}" +} + +case "${1:-}" in + package) package_chart ;; + verify-remote) verify_remote ;; + publish) publish_chart ;; + *) fail "usage: $0 package|verify-remote|publish" ;; +esac diff --git a/.scripts/ct.yaml b/.scripts/ct.yaml deleted file mode 100644 index f3b401c..0000000 --- a/.scripts/ct.yaml +++ /dev/null @@ -1,7 +0,0 @@ -remote: k8s -target-branch: master -chart-dirs: - - chart -excluded-charts: - - common -helm-extra-args: --timeout 800 diff --git a/.scripts/gen_packages.sh b/.scripts/gen_packages.sh deleted file mode 100644 index 9d3db66..0000000 --- a/.scripts/gen_packages.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -set -e - -echo "Installing curl" -apt update -apt install curl -y - -echo "Installing helm" -curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash - -echo "Packaging charts from source code" -mkdir -p temp -for d in charts/* -do - # shellcheck disable=SC2039 - if [[ -d $d ]] - then - # Will generate a helm package per chart in a folder - echo "$d" - helm package "$d" - # shellcheck disable=SC2035 - mv *.tgz temp/ - fi -done diff --git a/.scripts/index_repo.sh b/.scripts/index_repo.sh deleted file mode 100644 index 5541169..0000000 --- a/.scripts/index_repo.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -set -e - -echo "Installing curl" -apt update -apt install curl -y - -echo "Installing helm" -curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash - -echo "Indexing repository" -if [ -f index.yaml ]; then - helm repo index --url "${REPO_URL}" --merge index.yaml ./temp -else - helm repo index --url "${REPO_URL}" ./temp -fi \ No newline at end of file diff --git a/.scripts/repo_sync.sh b/.scripts/repo_sync.sh deleted file mode 100644 index 0b6ecb0..0000000 --- a/.scripts/repo_sync.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -set -e - -# the repo path to this repository -REPO_URL="https://charts.webhookrelay.com" - -function gen_packages() { - echo "Packaging charts from source code" - mkdir -p temp - for d in charts/* - do - if [[ -d $d ]] - then - # Will generate a helm package per chart in a folder - echo "$d" - helm package "$d" - # shellcheck disable=SC2035 - mv *.tgz temp/ - fi - done -} - -function index() { - echo "Fetch charts and index.yaml" - gsutil rsync gs://charts.webhookrelay.com ./temp/ - - echo "Indexing repository" - if [ -f index.yaml ]; then - helm repo index --url ${REPO_URL} --merge index.yaml ./temp - else - helm repo index --url ${REPO_URL} ./temp - fi -} - -function upload() { - echo "Upload charts to GCS bucket" - gsutil rsync ./temp/ gs://charts.webhookrelay.com -} - -# generate helm chart packages -gen_packages - -# create index -index - -# upload to GCS bucket -upload diff --git a/.test/chart-release.sh b/.test/chart-release.sh new file mode 100755 index 0000000..6422eae --- /dev/null +++ b/.test/chart-release.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly REPO_ROOT="$(git rev-parse --show-toplevel)" +readonly TEST_ROOT="$(mktemp -d)" +readonly SERVER_DIR="${TEST_ROOT}/server" +readonly TEST_OUTPUT_DIR="${TEST_ROOT}/output" +readonly LOCAL_REPOSITORY_URL="http://127.0.0.1:18080" +SERVER_PID="" +SYNC_PID="" + +cleanup() { + if [[ "${SERVER_PID}" =~ ^[0-9]+$ ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + if [[ "${SYNC_PID}" =~ ^[0-9]+$ ]]; then + kill "${SYNC_PID}" >/dev/null 2>&1 || true + wait "${SYNC_PID}" 2>/dev/null || true + fi + rm -rf -- "${TEST_ROOT}" +} +trap cleanup EXIT + +mkdir -p "${SERVER_DIR}" +HELM_BIN="${HELM_BIN:-helm}" OUTPUT_DIR="${TEST_ROOT}/source" \ + "${REPO_ROOT}/.scripts/chart-release.sh" package >/dev/null +cp "${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" "${SERVER_DIR}/" +"${HELM_BIN:-helm}" repo index "${SERVER_DIR}" --url "${LOCAL_REPOSITORY_URL}" +python3 -m http.server 18080 --bind 127.0.0.1 --directory "${SERVER_DIR}" \ + >"${TEST_ROOT}/http.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 30); do + curl --fail --silent "${LOCAL_REPOSITORY_URL}/index.yaml" >/dev/null && break + sleep 0.1 +done + +HELM_BIN="${HELM_BIN:-helm}" OUTPUT_DIR="${TEST_OUTPUT_DIR}" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + "${REPO_ROOT}/.scripts/chart-release.sh" verify-remote >/dev/null + +printf 'different artifact\n' >"${SERVER_DIR}/webhookrelay-operator-0.7.0.tgz" +if HELM_BIN="${HELM_BIN:-helm}" OUTPUT_DIR="${TEST_OUTPUT_DIR}" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + "${REPO_ROOT}/.scripts/chart-release.sh" verify-remote >/dev/null 2>&1; then + printf 'chart release accepted a different published artifact\n' >&2 + exit 1 +fi + +fake_bin="${TEST_ROOT}/bin" +fake_store="${TEST_ROOT}/gcs" +mkdir -p "${fake_bin}" "${fake_store}" +cp "${REPO_ROOT}/.test/fake-gcloud.sh" "${fake_bin}/gcloud" +chmod 0755 "${fake_bin}/gcloud" +cp "${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" \ + "${SERVER_DIR}/webhookrelay-operator-0.7.0.tgz" +cat >"${fake_store}/index.yaml" <"${fake_store}/index.yaml.generation" +cp "${fake_store}/index.yaml" "${SERVER_DIR}/index.yaml" +rm -f "${SERVER_DIR}/webhookrelay-operator-0.7.0.tgz" + +if PATH="${fake_bin}:${PATH}" FAKE_GCS_ROOT="${fake_store}" \ + FAKE_GCS_BUMP_BEFORE_INDEX_WRITE=true HELM_BIN="${HELM_BIN:-helm}" \ + OUTPUT_DIR="${TEST_ROOT}/publish-first" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + CHART_BUCKET=test-bucket VALIDATED_ARTIFACT="${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" \ + "${REPO_ROOT}/.scripts/chart-release.sh" publish >/dev/null 2>&1; then + printf 'chart release ignored a concurrent index generation change\n' >&2 + exit 1 +fi +[[ -f "${fake_store}/webhookrelay-operator-0.7.0.tgz" ]] || { + printf 'partial publication fixture did not create the chart object\n' >&2 + exit 1 +} + +( + for _ in $(seq 1 100); do + if grep -q 'version: 0.7.0' "${fake_store}/index.yaml" 2>/dev/null; then + cp "${fake_store}/index.yaml" "${SERVER_DIR}/index.yaml" + cp "${fake_store}/webhookrelay-operator-0.7.0.tgz" "${SERVER_DIR}/" + exit 0 + fi + sleep 0.1 + done + exit 1 +) & +SYNC_PID=$! +PATH="${fake_bin}:${PATH}" FAKE_GCS_ROOT="${fake_store}" HELM_BIN="${HELM_BIN:-helm}" \ + OUTPUT_DIR="${TEST_ROOT}/publish-retry" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + CHART_BUCKET=test-bucket VALIDATED_ARTIFACT="${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" \ + "${REPO_ROOT}/.scripts/chart-release.sh" publish >/dev/null +wait "${SYNC_PID}" +SYNC_PID="" +go run "${REPO_ROOT}/.scripts/chart-artifact.go" verify-retained \ + "${TEST_ROOT}/publish-retry/remote-index.yaml" "${fake_store}/index.yaml" +[[ "$(<"${fake_store}/index.yaml.cache-control")" == "no-cache,max-age=0,must-revalidate" ]] + +artifact_generation="$(<"${fake_store}/webhookrelay-operator-0.7.0.tgz.generation")" +index_generation="$(<"${fake_store}/index.yaml.generation")" +PATH="${fake_bin}:${PATH}" FAKE_GCS_ROOT="${fake_store}" HELM_BIN="${HELM_BIN:-helm}" \ + OUTPUT_DIR="${TEST_ROOT}/publish-idempotent" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + CHART_BUCKET=test-bucket VALIDATED_ARTIFACT="${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" \ + "${REPO_ROOT}/.scripts/chart-release.sh" publish >/dev/null +[[ "$(<"${fake_store}/webhookrelay-operator-0.7.0.tgz.generation")" == "${artifact_generation}" ]] +[[ "$(<"${fake_store}/index.yaml.generation")" == "${index_generation}" ]] + +printf 'different authoritative artifact\n' >"${fake_store}/webhookrelay-operator-0.7.0.tgz" +if PATH="${fake_bin}:${PATH}" FAKE_GCS_ROOT="${fake_store}" HELM_BIN="${HELM_BIN:-helm}" \ + OUTPUT_DIR="${TEST_ROOT}/publish-collision" REPOSITORY_URL="${LOCAL_REPOSITORY_URL}" \ + CHART_BUCKET=test-bucket VALIDATED_ARTIFACT="${TEST_ROOT}/source/webhookrelay-operator-0.7.0.tgz" \ + "${REPO_ROOT}/.scripts/chart-release.sh" publish >/dev/null 2>&1; then + printf 'chart release accepted different authoritative artifact bytes\n' >&2 + exit 1 +fi + +printf 'chart release authoritative retry, collision, and concurrency checks passed\n' diff --git a/.test/e2e-k3s.sh b/.test/e2e-k3s.sh index b27b72a..de8203e 100755 --- a/.test/e2e-k3s.sh +++ b/.test/e2e-k3s.sh @@ -13,7 +13,7 @@ ARTIFACT_DIR="${REPO_ROOT}/.test/artifacts/${RUN_ID}" BIN_DIR="${RUN_DIR}/bin" K3S_BIN="${BIN_DIR}/k3s" HELM_BIN="${BIN_DIR}/helm" -CANDIDATE_CHART="${RUN_DIR}/webhookrelay-operator-0.6.0.tgz" +CANDIDATE_CHART="${WHR_E2E_CANDIDATE_CHART:-${RUN_DIR}/webhookrelay-operator-0.7.0.tgz}" OLD_CHART="${RUN_DIR}/webhookrelay-operator-0.4.1.tgz" KUBECONFIG="${RUN_DIR}/kubeconfig" K3S_CONFIG="${RUN_DIR}/k3s.yaml" @@ -89,14 +89,16 @@ download_tools() { } package_candidate_chart() { - local packaged_chart + if [[ -n "${WHR_E2E_CANDIDATE_CHART:-}" ]]; then + [[ -f "${CANDIDATE_CHART}" ]] || fail "supplied candidate chart does not exist: ${CANDIDATE_CHART}" + [[ "$(basename "${CANDIDATE_CHART}")" == "webhookrelay-operator-0.7.0.tgz" ]] || \ + fail "supplied candidate chart has an unexpected filename" + helm lint "${CANDIDATE_CHART}" + return + fi log "packaging candidate Helm chart" - helm package "${REPO_ROOT}/charts/webhookrelay-operator" --destination "${RUN_DIR}" \ - >"${ARTIFACT_DIR}/helm-package.txt" - packaged_chart="$(awk '/Successfully packaged chart and saved it to:/ {print $NF}' \ - "${ARTIFACT_DIR}/helm-package.txt")" - [[ "${packaged_chart}" == "${CANDIDATE_CHART}" ]] || \ - fail "candidate chart package was ${packaged_chart}, expected ${CANDIDATE_CHART}" + OPERATOR_VERSION=0.8.0 CHART_VERSION=0.7.0 OUTPUT_DIR="${RUN_DIR}" HELM_BIN="${HELM_BIN}" \ + "${REPO_ROOT}/.scripts/chart-release.sh" package >"${ARTIFACT_DIR}/helm-package.txt" [[ -f "${CANDIDATE_CHART}" ]] || fail "candidate chart package was not created" } diff --git a/.test/fake-api/Dockerfile b/.test/fake-api/Dockerfile index ff408da..ebdc30c 100644 --- a/.test/fake-api/Dockerfile +++ b/.test/fake-api/Dockerfile @@ -5,7 +5,7 @@ RUN go mod download COPY .test/fake-api/main.go ./main.go RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /fake-relay-api ./main.go -FROM cgr.dev/chainguard/static:latest@sha256:f51c2493951313c3ad4069080b2814ffb6ed6fe3909dabeb84a9482f42d5600b +FROM cgr.dev/chainguard/static@sha256:f51c2493951313c3ad4069080b2814ffb6ed6fe3909dabeb84a9482f42d5600b COPY --from=build /fake-relay-api /fake-relay-api USER 65532:65532 ENTRYPOINT ["/fake-relay-api"] diff --git a/.test/fake-gcloud.sh b/.test/fake-gcloud.sh new file mode 100755 index 0000000..02c5ac7 --- /dev/null +++ b/.test/fake-gcloud.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +[[ "${1:-}" == "storage" ]] || { printf 'unsupported fake gcloud command\n' >&2; exit 2; } + +object_path() { + local uri="${1#gs://}" + uri="${uri#*/}" + printf '%s\n' "${uri%%#*}" +} + +generation_path() { + printf '%s.generation\n' "${FAKE_GCS_ROOT}/$(object_path "$1")" +} + +case "${2:-}" in + objects) + [[ "${3:-}" == "describe" ]] || exit 2 + object="${FAKE_GCS_ROOT}/$(object_path "$4")" + [[ -f "${object}" ]] || { printf 'ERROR: object not found (404)\n' >&2; exit 1; } + generation_file="$(generation_path "$4")" + [[ -f "${generation_file}" ]] && generation="$(<"${generation_file}")" || generation=1 + if [[ "$*" == *"value(cache_control)"* ]]; then + cache_control_file="${object}.cache-control" + [[ -f "${cache_control_file}" ]] && cat "${cache_control_file}" + else + printf '%s\n' "${generation}" + fi + ;; + cp) + source_path="$3" + destination_path="$4" + if [[ "${source_path}" == gs://* ]]; then + cp "${FAKE_GCS_ROOT}/$(object_path "${source_path}")" "${destination_path}" + exit 0 + fi + object="${FAKE_GCS_ROOT}/$(object_path "${destination_path}")" + generation_file="$(generation_path "${destination_path}")" + [[ -f "${generation_file}" ]] && current_generation="$(<"${generation_file}")" || current_generation=0 + expected_generation="" + cache_control="" + for argument in "$@"; do + [[ "${argument}" == --if-generation-match=* ]] && expected_generation="${argument#*=}" + [[ "${argument}" == --cache-control=* ]] && cache_control="${argument#*=}" + done + if [[ "$(object_path "${destination_path}")" == "index.yaml" && \ + "${FAKE_GCS_BUMP_BEFORE_INDEX_WRITE:-false}" == "true" && \ + ! -f "${FAKE_GCS_ROOT}/.index-bumped" ]]; then + current_generation=$((current_generation + 1)) + printf '%s\n' "${current_generation}" >"${generation_file}" + : >"${FAKE_GCS_ROOT}/.index-bumped" + fi + [[ "${expected_generation}" == "${current_generation}" ]] || { + printf 'ERROR: generation precondition failed\n' >&2 + exit 1 + } + mkdir -p "$(dirname "${object}")" + cp "${source_path}" "${object}" + printf '%s\n' "$((current_generation + 1))" >"${generation_file}" + if [[ -n "${cache_control}" ]]; then + printf '%s\n' "${cache_control}" >"${object}.cache-control" + fi + ;; + *) + printf 'unsupported fake gcloud storage command\n' >&2 + exit 2 + ;; +esac diff --git a/Makefile b/Makefile index 77477a1..c167871 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ GIT_REVISION = $(shell git rev-parse --short HEAD) VERSION ?= $(shell git describe --tags --abbrev=0) OPERATOR_PREVIOUS_VERSION ?= $(shell git describe --abbrev=0 --tags $(VERSION)^) OPERATOR_IMAGE ?= webhookrelay/webhookrelay-operator:test +OPERATOR_VERSION ?= 0.8.0 +CHART_VERSION ?= 0.7.0 GO_ENV = GOOS=linux CGO_ENABLED=0 GO_BUILD_CMD = go build @@ -47,6 +49,18 @@ test: e2e: ./.test/e2e-k3s.sh +.PHONY: chart-package chart-release-check chart-release-test +chart-package: + OPERATOR_VERSION=$(OPERATOR_VERSION) CHART_VERSION=$(CHART_VERSION) \ + HELM_BIN=$${HELM_BIN:-helm} ./.scripts/chart-release.sh package + +chart-release-check: + OPERATOR_VERSION=$(OPERATOR_VERSION) CHART_VERSION=$(CHART_VERSION) \ + HELM_BIN=$${HELM_BIN:-helm} ./.scripts/chart-release.sh verify-remote + +chart-release-test: + HELM_BIN=$${HELM_BIN:-helm} ./.test/chart-release.sh + ## Start local Webhook Relay operator local-run: WATCH_NAMESPACE=$${WATCH_NAMESPACE:-default} go run ./cmd/manager --zap-devel diff --git a/README.md b/README.md index bd122fc..70f40f4 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,8 @@ make e2e passwordless sudo. Pull requests use GitHub Actions. The protected production workflow additionally creates uniquely owned Relay resources, delivers real webhooks into the K3s receiver, and removes only the exact resources it owns. +Maintainers should follow the [versioned release process](docs/releasing.md); +chart publication is reproducible, collision-safe, and separately approved. ## License diff --git a/charts/webhookrelay-operator/Chart.yaml b/charts/webhookrelay-operator/Chart.yaml index bf414ba..6c6e993 100644 --- a/charts/webhookrelay-operator/Chart.yaml +++ b/charts/webhookrelay-operator/Chart.yaml @@ -29,9 +29,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.6.0 +version: 0.7.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 0.7.0 +appVersion: 0.8.0 diff --git a/charts/webhookrelay-operator/README.md b/charts/webhookrelay-operator/README.md index 2c20b57..d1bd4be 100644 --- a/charts/webhookrelay-operator/README.md +++ b/charts/webhookrelay-operator/README.md @@ -19,7 +19,7 @@ first Helm install. Helm deliberately does not upgrade or delete CRDs. Pin one chart version and use it for both the CRD and operator upgrade: ```bash -CHART_VERSION=0.6.0 +CHART_VERSION=0.7.0 helm show crds webhookrelay/webhookrelay-operator \ --version "${CHART_VERSION}" | kubectl apply -f - helm upgrade webhookrelay-operator webhookrelay/webhookrelay-operator \ @@ -73,7 +73,7 @@ line values can be retained in shell history and Helm release data. | --- | --- | --- | | `replicaCount` | Operator replicas | `1` | | `image.repository` | Operator image repository | `webhookrelay/webhookrelay-operator` | -| `image.tag` | Operator image tag | `0.7.0` | +| `image.tag` | Operator image tag | `0.8.0` | | `image.pullPolicy` | Operator image pull policy | `Always` | | `credentials.key` | Optional operator-wide Relay token key | empty | | `credentials.secret` | Optional operator-wide Relay token secret | empty | diff --git a/charts/webhookrelay-operator/values.yaml b/charts/webhookrelay-operator/values.yaml index 8087fea..0672d25 100644 --- a/charts/webhookrelay-operator/values.yaml +++ b/charts/webhookrelay-operator/values.yaml @@ -8,7 +8,7 @@ image: repository: webhookrelay/webhookrelay-operator pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: "0.7.0" + tag: "0.8.0" credentials: key: "" diff --git a/cloudbuild.yaml b/cloudbuild.yaml deleted file mode 100644 index 7636f2a..0000000 --- a/cloudbuild.yaml +++ /dev/null @@ -1,22 +0,0 @@ -steps: - -# Generate Helm packages -- name: 'ubuntu' - args: ['bash', './.scripts/gen_packages.sh'] - id: 'gen_packages' - -# Fetch charts and index.yaml from GCS bucket -- name: 'gcr.io/cloud-builders/gsutil' - args: ['rsync', 'gs://charts.webhookrelay.com', 'temp/'] - id: 'fetch_charts_index' - -# Index repository -- name: 'ubuntu' - env: - - 'REPO_URL=https://charts.webhookrelay.com' - args: ['bash', './.scripts/index_repo.sh'] - -# Upload charts to GCS bucket -- name: gcr.io/cloud-builders/gsutil - args: ['rsync', 'temp/', 'gs://charts.webhookrelay.com'] - id: 'upload_charts' diff --git a/docs/modernization-plan.md b/docs/modernization-plan.md index 67a73aa..8c4fbd6 100644 --- a/docs/modernization-plan.md +++ b/docs/modernization-plan.md @@ -35,8 +35,10 @@ independently. Every implementation pull request must receive an independent agent review, address all actionable findings, pass its pull-request gates, and update this table after merge. -The next coordinated release reserves operator `0.8.0` and Helm chart `0.6.0`; -the already-published chart `0.5.0` must never be overwritten. The release tag +The next coordinated release reserves operator `0.8.0` and Helm chart `0.7.0`. +The legacy Cloud Build publisher uploaded chart `0.6.0` with operator `0.7.0` +before it was disabled, so neither published chart `0.5.0` nor `0.6.0` may be +overwritten. The release tag must equal `Chart.appVersion` and the operator image tag, while `Chart.version` remains independently versioned. The relay-agent version is read from the shipped operator default and validated rather than assumed to match either. @@ -45,8 +47,8 @@ shipped operator default and validated rather than assumed to match either. | --- | --- | --- | --- | | 12 | Bump the candidate chart to `0.6.0`. Add an operator-admin-only Relay API endpoint override (environment/Helm configuration, never a CR field) and a stateful fake Relay API used by isolated K3s. Replace the deliberately failing isolated reconcile with deterministic create, update, idempotency, error, and cleanup assertions. | Unit tests validate the production default, absolute HTTP(S) URL and no-userinfo rules, proxy composition, and absence of credentials from logs/status. Isolated K3s proves remote-style convergence without production credentials; production delivery remains separate. | Merged ([#43](https://github.com/webhookrelay/webhookrelay-operator/pull/43)); [CI](https://github.com/webhookrelay/webhookrelay-operator/actions/runs/34352337807) passed unit/lint, Helm, and isolated K3s create/update/output-prune/input-retention/idempotency/failure/recovery coverage | | 13 | Add Kubernetes-standard `RoutingReady`, `AgentReady`, and aggregate `Ready` conditions, top-level and per-condition `observedGeneration`, and actual Deployment rollout readiness while retaining existing status fields as compatibility mirrors. Watch owned Deployment changes, handle missing/unobserved/partial/available/`ProgressDeadlineExceeded` states, retry status conflicts, and return Deployment reconcile failures. | Focused tests cover condition reasons, stable transition times/no write loop, generation changes, status conflicts, deletion, routing failures, and legacy mirrors. Generated CRDs agree; isolated K3s proves false-to-true readiness and update transitions; protected production delivery passes. | Merged ([#44](https://github.com/webhookrelay/webhookrelay-operator/pull/44)); [CI](https://github.com/webhookrelay/webhookrelay-operator/actions/runs/34355001359) and [protected production delivery](https://github.com/webhookrelay/webhookrelay-operator/actions/runs/34355358893) passed | -| 14 | Remove/deprecate the duplicate Helm-managed CRD template and define `crds/` as the supported retained CRD path. Add a packaged-chart lifecycle suite: clean install, CR deletion and owned Deployment garbage collection, Helm uninstall with CRD retention, and separately guarded CRD removal only in disposable K3s. Upgrade from the checksum-pinned published chart `0.4.1`, explicitly apply the candidate CRD, and test application-only safe rollback after scaling the operator to zero. | CI verifies checksum `7de5d0afa11405d603c41b81bab1b33f93e0cf6f9ddb3fc7ae5e9a1fd85a4907`, Helm revision transitions, CR UID and legacy `function_id`, Lease/probes/RBAC/schema, zero old/new pod overlap, old-controller-compatible rollback fixtures, no remote duplication, namespaced cleanup, CRD retention, and guarded test teardown. Documentation states CRD upgrade, rollback limitations, and uninstall behavior. | In progress; depends on phase 13 | -| 15 | Replace legacy chart scripts with deterministic GitHub Actions packaging/publication. Pin tooling, validate the explicit operator `0.8.0` / chart `0.6.0` mapping, refuse overwriting an existing version, produce chart SHA256/checksum metadata, and expose reusable publish steps without creating a GitHub release. | Packaged chart passes lint and lifecycle tests; retries accept only an already-published artifact with the identical digest; remote index URL/digest match local output. Configure a protected `release` environment with required reviewers, scoped Docker credentials, and a scoped charts storage identity before enabling writes. | Planned; depends on phase 14; release environment and storage identity required | +| 14 | Remove/deprecate the duplicate Helm-managed CRD template and define `crds/` as the supported retained CRD path. Add a packaged-chart lifecycle suite: clean install, CR deletion and owned Deployment garbage collection, Helm uninstall with CRD retention, and separately guarded CRD removal only in disposable K3s. Upgrade from the checksum-pinned published chart `0.4.1`, explicitly apply the candidate CRD, and test application-only safe rollback after scaling the operator to zero. | CI verifies checksum `7de5d0afa11405d603c41b81bab1b33f93e0cf6f9ddb3fc7ae5e9a1fd85a4907`, Helm revision transitions, CR UID and legacy `function_id`, Lease/probes/RBAC/schema, zero old/new pod overlap, old-controller-compatible rollback fixtures, no remote duplication, namespaced cleanup, CRD retention, and guarded test teardown. Documentation states CRD upgrade, rollback limitations, and uninstall behavior. | Merged ([#45](https://github.com/webhookrelay/webhookrelay-operator/pull/45)); [CI](https://github.com/webhookrelay/webhookrelay-operator/actions/runs/34358692419) passed the packaged install, real legacy-CRD upgrade, alias mapping, isolated rollback, uninstall, retention, and guarded cleanup lifecycle | +| 15 | Replace legacy chart scripts with deterministic GitHub Actions packaging/publication. Pin tooling, validate the explicit operator `0.8.0` / chart `0.7.0` mapping, refuse overwriting an existing version, produce chart SHA256/checksum metadata, and expose reusable publish steps without creating a GitHub release. | The exact packaged chart passes lint and lifecycle tests; authenticated retries accept only an already-published artifact with identical bytes; generation-bound index updates retain every old entry; the public index URL/digest and artifact converge. Manual dispatch is validation-only. Configure a protected `release` environment with required reviewers and a scoped charts storage identity before enabling writes; image credentials remain part of phase 16. | In progress; legacy Cloud Build trigger disabled; protected `release` environment and bucket-scoped keyless identity configured; Artifact Hub recovered chart `0.6.0` after the legacy stale-index alert | | 16 | Orchestrate publication from an operator tag reachable from `master`: validate explicit operator/chart/agent mapping; build immutable image and chart artifacts; assert exact amd64/arm64 platform sets; smoke-run both platforms for the operator and both pinned agent variants; run packaged-chart K3s and protected production live delivery against immutable tags; publish the chart; promote `latest`; and create the GitHub release last with chart/checksum assets and image digests. | A dry-run fixture proves job ordering. Build, validation, or chart-publication failures cannot create a public release or move `latest`. If the final release API call fails after promotion, recovery must resume from the digest-verified immutable artifacts without rebuilding or changing `latest`. The production gate includes function behavior and exact cleanup. Release notes record operator, chart, and agent versions plus artifact digests. | Planned; depends on phase 15 | Known follow-ups outside this sequence are per-object API clients for safe diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..b440576 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,59 @@ +# Release process + +Releases are built by GitHub Actions from immutable source revisions. The old +Cloud Build chart trigger is disabled and its unaudited packaging scripts have +been removed. + +## Version mapping + +The next coordinated release uses: + +| Component | Version | +| --- | --- | +| Operator tag and image | `0.8.0` | +| Helm chart | `0.7.0` | +| Default relay agent | `webhookrelay/webhookrelayd-ubi8:1.37.0` | + +Chart versions `0.5.0` and `0.6.0` already exist and must never be overwritten. +`Chart.appVersion` and `values.yaml`'s operator image tag must equal the operator +version. The agent version is validated from the compiled operator default. + +## Chart artifact + +Run the same packaging and collision checks as CI: + +```bash +make chart-package +make chart-release-check +make chart-release-test +``` + +The packager normalizes archive order, ownership, permissions, and timestamps, +then builds the archive twice and compares the bytes. It emits the chart, +SHA-256 file, and release metadata under `dist/chart/`. A published chart is +accepted on retry only when its bytes and index digest/URL match exactly; a +different artifact at the same version aborts publication. + +Manual `Helm Chart Release` dispatches are validation-only. Publication can be +requested only by the reusable workflow call used by the final release +orchestrator. It downloads and lifecycle-tests the exact chart artifact before +entering the protected `release` environment. That environment requires review +by a different maintainer and accepts only `master` or `0.*` tags. GitHub OIDC +can impersonate the publisher only for this repository's `release` environment +on those refs, and the service account's write permission is scoped to +`gs://charts.webhookrelay.com`. No Google Cloud key is stored in GitHub. + +The authenticated publisher reads chart and index objects directly from GCS. +It reads `index.yaml` at a specific generation, proves the merge retained every +existing version/digest/URL entry, and uses that same generation as the update +precondition. The chart object uses a zero-generation creation precondition, so +it cannot replace an existing object. Retries accept an authoritative object +only when its bytes are identical, including the partial-success case where the +chart exists but the index update failed. The mutable index is uploaded with +`Cache-Control: no-cache,max-age=0,must-revalidate`, and the publisher verifies +that policy from authoritative object metadata. Public verification requests +revalidation, then bounded polling waits for the artifact and index to expose +the same bytes, digest, and URL. The existing index received the same metadata +policy as a one-time migration before this pipeline was enabled. +This prevents the stale-index mismatch previously reported by Artifact Hub. +The workflow never creates a GitHub release.