Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,19 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Keep the release run open until both registries have published the tag.
# This covers the normal release dispatch and a manually pushed tag without
# racing a second tag-triggered image workflow.
publish-images:
if: github.ref_type == 'tag'
needs: [test, goreleaser]
permissions:
contents: read
id-token: write
packages: write
uses: ./.github/workflows/lint-test-build.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }}
GSA: ${{ secrets.GSA }}
37 changes: 23 additions & 14 deletions .github/workflows/lint-test-build.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
name: build-push
on:
workflow_call:
secrets:
CODECOV_TOKEN:
required: false
GCLOUD_OIDC_POOL:
required: false
GSA:
required: false
pull_request:
push:
branches:
- main
tags:
- "*"
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -48,21 +54,24 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

publish-ghcr:
# PPB is public in GHCR and also runs on Cloud Run. The shared workflow
# builds and scans each native image once, then publishes the same digest to
# both registries.
publish:
if: github.event_name != 'pull_request'
needs: [lint-test]
uses: libops/.github/.github/workflows/build-push.yaml@3bdb895c3fd05490b0c6a1341e630ac554842468 # main
permissions:
contents: read
packages: write

publish-gar:
if: github.event_name != 'pull_request'
needs: [lint-test]
uses: libops/.github/.github/workflows/build-push.yaml@3bdb895c3fd05490b0c6a1341e630ac554842468 # main
uses: libops/.github/.github/workflows/build-push.yaml@a86300fb8020d0f7141bb9f833d89b5dbd7aa4d7 # guarded-signed-image-publisher
with:
docker-registry: us-docker.pkg.dev/libops-images/public
ref: ${{ github.sha }}
expected-main-sha: ${{ github.ref == 'refs/heads/main' && github.sha || '' }}
additional-gar-registry: us-docker.pkg.dev/libops-images/public
scan: true
sign: true
certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@a86300fb8020d0f7141bb9f833d89b5dbd7aa4d7
permissions:
contents: read
packages: write
id-token: write
secrets: inherit
secrets:
GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }}
GSA: ${{ secrets.GSA }}
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ghcr.io/libops/base:main@sha256:389706a359c6ba0a4ffb9c3d21c0d909a1c2e5e0c4ab79cb5779129267207049 AS builder
FROM ghcr.io/libops/go:1.26.5@sha256:ea764e85e42a243217c621891123b3fda9374674c29d59785414fc6b15815b3d AS builder

SHELL ["/bin/ash", "-o", "pipefail", "-ex", "-c"]

Expand All @@ -14,7 +14,7 @@ COPY pkg ./pkg
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/binary .

FROM ghcr.io/libops/base:main@sha256:389706a359c6ba0a4ffb9c3d21c0d909a1c2e5e0c4ab79cb5779129267207049
FROM ghcr.io/libops/base:3.2.2.0@sha256:0c6bab5444d4758bb977a42c1e5199cba733c7776e1aa006f8021108a785c547

COPY --from=builder /app/binary /app/binary

Expand Down
84 changes: 84 additions & 0 deletions ci/workflow_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package ci

import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
)

const sharedPublisherSHA = "a86300fb8020d0f7141bb9f833d89b5dbd7aa4d7"

func repositoryRoot(t *testing.T) string {
t.Helper()
_, current, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
return filepath.Dir(filepath.Dir(current))
}

func readRepositoryFile(t *testing.T, path ...string) string {
t.Helper()
content, err := os.ReadFile(filepath.Join(append([]string{repositoryRoot(t)}, path...)...))
if err != nil {
t.Fatalf("read %s: %v", filepath.Join(path...), err)
}
return string(content)
}

func TestImagePublicationUsesGuardedSharedContract(t *testing.T) {
workflow := readRepositoryFile(t, ".github", "workflows", "lint-test-build.yml")
for _, required := range []string{
"libops/.github/.github/workflows/build-push.yaml@" + sharedPublisherSHA,
"additional-gar-registry: us-docker.pkg.dev/libops-images/public",
"expected-main-sha:",
"scan: true",
"sign: true",
"certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@" + sharedPublisherSHA,
"GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }}",
"GSA: ${{ secrets.GSA }}",
} {
if !strings.Contains(workflow, required) {
t.Errorf("image workflow must contain %q", required)
}
}
if strings.Contains(workflow, "secrets: inherit") {
t.Fatal("image workflow must map only its required registry secrets")
}
}

func TestReleaseImageCallMapsOnlyRegistrySecrets(t *testing.T) {
workflow := readRepositoryFile(t, ".github", "workflows", "goreleaser.yml")
if strings.Contains(workflow, "secrets: inherit") {
t.Fatal("release workflow must not pass every repository secret")
}
for _, required := range []string{
"if: github.ref_type == 'tag'",
"uses: ./.github/workflows/lint-test-build.yml",
"CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}",
"GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }}",
"GSA: ${{ secrets.GSA }}",
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow must contain %q", required)
}
}
}

func TestDockerfilePinsPublishedBuildkitImages(t *testing.T) {
dockerfile := readRepositoryFile(t, "Dockerfile")
for _, pattern := range []string{
`FROM ghcr\.io/libops/go:[^\s]+@sha256:[0-9a-f]{64} AS builder`,
`FROM ghcr\.io/libops/base:[0-9][^\s]*@sha256:[0-9a-f]{64}`,
} {
if !regexp.MustCompile(pattern).MatchString(dockerfile) {
t.Errorf("Dockerfile must match %q", pattern)
}
}
if strings.Contains(dockerfile, "ghcr.io/libops/base:main") {
t.Fatal("runtime base must use a released version tag")
}
}
5 changes: 4 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,13 @@ func newHandler(c *config.Config, backend http.Handler) http.Handler {
// requests can then be cancelled cleanly during disconnect or shutdown.
powerCtx, powerCancel := context.WithTimeout(r.Context(), time.Duration(c.PowerOnTimeout)*time.Second)
err = c.Machine.PowerOnWithCooldown(powerCtx, c.PowerOnCooldown)
powerTimedOut := powerCtx.Err() == context.DeadlineExceeded && r.Context().Err() == nil
powerCancel()
if err != nil {
slog.Error("Power-on attempt failed", "err", err)
w.Header().Set("Retry-After", "5")
if powerTimedOut {
w.Header().Set("Retry-After", "5")
}
http.Error(w, "Backend not available", http.StatusServiceUnavailable)
return
}
Expand Down
43 changes: 41 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"github.com/libops/ppb/pkg/machine"
)

func TestHandlerPowerFailureReturnsRetryableUnavailable(t *testing.T) {
func TestHandlerPermanentPowerFailureOmitsRetryAfter(t *testing.T) {
t.Parallel()

_, allowed, err := net.ParseCIDR("127.0.0.1/32")
Expand All @@ -38,14 +38,53 @@ func TestHandlerPowerFailureReturnsRetryableUnavailable(t *testing.T) {

handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
if got := recorder.Header().Get("Retry-After"); got != "" {
t.Fatalf("Retry-After = %q, want omitted for a permanent power failure", got)
}
if backendCalled {
t.Fatal("backend handler ran after power-on failure")
}
}

func TestHandlerPowerTimeoutReturnsRetryableUnavailable(t *testing.T) {
t.Parallel()

_, allowed, err := net.ParseCIDR("127.0.0.1/32")
if err != nil {
t.Fatal(err)
}
machine := machine.NewGceMachine()
if err := machine.Lock.Acquire(context.Background(), 1); err != nil {
t.Fatal(err)
}
defer machine.Lock.Release(1)

backendCalled := false
handler := newHandler(&config.Config{
AllowedIps: []config.IPNet{{IPNet: allowed}},
PowerOnCooldown: 30,
PowerOnTimeout: 1,
Machine: machine,
}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
backendCalled = true
}))
request := httptest.NewRequest(http.MethodPost, "http://example.test/", strings.NewReader("must-not-be-dispatched"))
request.RemoteAddr = "127.0.0.1:12345"
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
if got := recorder.Header().Get("Retry-After"); got != "5" {
t.Fatalf("Retry-After = %q, want 5", got)
}
if backendCalled {
t.Fatal("backend handler ran after power-on failure")
t.Fatal("backend handler ran after power-on timeout")
}
}

Expand Down
Loading
Loading