From 1a37f3955666513fbf770350630eaaca364f42b8 Mon Sep 17 00:00:00 2001 From: Josh Sandlin Date: Thu, 30 Jul 2026 16:58:34 -0400 Subject: [PATCH 1/4] correct the module name, correct oci metadata, and try running tests in CI --- .github/workflows/ci.yaml | 104 +++++++++++++++++++++++++ .github/workflows/govulncheck.yaml | 42 ++++++++++ sidecar/Dockerfile | 2 +- sidecar/cmd/zot-ephemeral-ttl/main.go | 4 +- sidecar/go.mod | 2 +- sidecar/internal/app/app.go | 10 +-- sidecar/internal/app/app_test.go | 4 +- sidecar/internal/reaper/reaper.go | 2 +- sidecar/internal/reaper/reaper_test.go | 2 +- sidecar/internal/server/server.go | 4 +- sidecar/internal/server/server_test.go | 4 +- sidecar/internal/store/store_test.go | 2 +- 12 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/govulncheck.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0db5542 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + branches: [main] + # sidecar/ is the only Go module in this repo; skip CI for changes that + # cannot affect it (web/, ansible/, docs). + paths: + - "sidecar/**" + - ".github/workflows/ci.yaml" + +# Cancel in-progress runs for the same ref (PR or branch) when new commits land. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GO_VERSION: "1.26" + +defaults: + run: + working-directory: sidecar + +jobs: + lint: + name: Lint & format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + cache-dependency-path: sidecar/go.sum + + - name: Verify gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::The following files are not gofmt-formatted:" + echo "$unformatted" + echo "Run 'gofmt -w .' to fix." + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Verify go.mod is tidy + run: | + go mod tidy + if ! git diff --exit-code -- go.mod go.sum; then + echo "::error::go.mod/go.sum are not tidy. Run 'go mod tidy' and commit the result." + exit 1 + fi + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + # `defaults.run` does not apply to actions, so point it at the module. + working-directory: sidecar + test: + name: Test + runs-on: ubuntu-latest + + # No service container: internal/redistest starts its own Redis on the + # runner's Docker daemon. GitHub sets CI=true, which makes it fail rather + # than skip, so the persistence layer cannot drop out of this job. + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + cache-dependency-path: sidecar/go.sum + + - name: Run tests (race + coverage) + run: go test -race -coverprofile=cover.out -covermode=atomic ./... + + - name: Coverage summary + run: go tool cover -func=cover.out | tail -1 + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + cache-dependency-path: sidecar/go.sum + + - name: go build + env: + CGO_ENABLED: "0" + run: go build -ldflags="-s -w" ./... diff --git a/.github/workflows/govulncheck.yaml b/.github/workflows/govulncheck.yaml new file mode 100644 index 0000000..72206fd --- /dev/null +++ b/.github/workflows/govulncheck.yaml @@ -0,0 +1,42 @@ +name: Vulnerability check + +on: + pull_request: + branches: [main] + # sidecar/ is the only Go module in this repo. + paths: + - "sidecar/**" + - ".github/workflows/govulncheck.yaml" + schedule: + # Weekly scan so newly disclosed CVEs in pinned deps surface even + # without a code change. Mondays at 07:00 UTC. + - cron: "0 7 * * 1" + +concurrency: + group: govulncheck-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: sidecar + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + check-latest: true + cache-dependency-path: sidecar/go.sum + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... diff --git a/sidecar/Dockerfile b/sidecar/Dockerfile index 3b2a08a..b1cabd1 100644 --- a/sidecar/Dockerfile +++ b/sidecar/Dockerfile @@ -65,7 +65,7 @@ ARG BUILD_DATE LABEL org.opencontainers.image.title="zot-ephemeral-ttl" \ org.opencontainers.image.description="Tag-driven TTL sidecar for zot. Subscribes to image.updated CloudEvents and DELETEs expired manifests." \ - org.opencontainers.image.source="https://github.com/nullbytelabs/zot-ephemeral-ttl" \ + org.opencontainers.image.source="https://github.com/replicatedhq/ttl.sh" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.revision="${COMMIT}" \ diff --git a/sidecar/cmd/zot-ephemeral-ttl/main.go b/sidecar/cmd/zot-ephemeral-ttl/main.go index e4b10f5..ff685e3 100644 --- a/sidecar/cmd/zot-ephemeral-ttl/main.go +++ b/sidecar/cmd/zot-ephemeral-ttl/main.go @@ -9,8 +9,8 @@ import ( "os/signal" "syscall" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/app" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/config" + "github.com/replicatedhq/ttl.sh/sidecar/internal/app" + "github.com/replicatedhq/ttl.sh/sidecar/internal/config" ) // Build metadata, set via -ldflags "-X main.version=..." in the Dockerfile. diff --git a/sidecar/go.mod b/sidecar/go.mod index 8c6f48b..6cbdb13 100644 --- a/sidecar/go.mod +++ b/sidecar/go.mod @@ -1,4 +1,4 @@ -module github.com/nullbytelabs/zot-ephemeral-ttl +module github.com/replicatedhq/ttl.sh/sidecar go 1.26 diff --git a/sidecar/internal/app/app.go b/sidecar/internal/app/app.go index 0082b50..9c68570 100644 --- a/sidecar/internal/app/app.go +++ b/sidecar/internal/app/app.go @@ -13,11 +13,11 @@ import ( "net/http" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/config" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/reaper" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/registry" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/server" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/store" + "github.com/replicatedhq/ttl.sh/sidecar/internal/config" + "github.com/replicatedhq/ttl.sh/sidecar/internal/reaper" + "github.com/replicatedhq/ttl.sh/sidecar/internal/registry" + "github.com/replicatedhq/ttl.sh/sidecar/internal/server" + "github.com/replicatedhq/ttl.sh/sidecar/internal/store" ) // storeBackend is the union of persistence capabilities app injects into the diff --git a/sidecar/internal/app/app_test.go b/sidecar/internal/app/app_test.go index 5858093..4b149d2 100644 --- a/sidecar/internal/app/app_test.go +++ b/sidecar/internal/app/app_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/config" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/redistest" + "github.com/replicatedhq/ttl.sh/sidecar/internal/config" + "github.com/replicatedhq/ttl.sh/sidecar/internal/redistest" ) // The startup-path test needs a real Redis; redistest starts one per package. diff --git a/sidecar/internal/reaper/reaper.go b/sidecar/internal/reaper/reaper.go index 986512d..35f36a5 100644 --- a/sidecar/internal/reaper/reaper.go +++ b/sidecar/internal/reaper/reaper.go @@ -9,7 +9,7 @@ import ( "log" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/store" + "github.com/replicatedhq/ttl.sh/sidecar/internal/store" ) // Store is the subset of the persistence layer the reaper needs. diff --git a/sidecar/internal/reaper/reaper_test.go b/sidecar/internal/reaper/reaper_test.go index e1c4aab..e4c4cc1 100644 --- a/sidecar/internal/reaper/reaper_test.go +++ b/sidecar/internal/reaper/reaper_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/store" + "github.com/replicatedhq/ttl.sh/sidecar/internal/store" ) // In-memory fakes for the reaper's two dependencies. No real DB, no real HTTP. diff --git a/sidecar/internal/server/server.go b/sidecar/internal/server/server.go index 3baad2b..93f18cb 100644 --- a/sidecar/internal/server/server.go +++ b/sidecar/internal/server/server.go @@ -8,8 +8,8 @@ import ( "net/http" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/events" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/ttl" + "github.com/replicatedhq/ttl.sh/sidecar/internal/events" + "github.com/replicatedhq/ttl.sh/sidecar/internal/ttl" ) // Store is the subset of the persistence layer the server needs. diff --git a/sidecar/internal/server/server_test.go b/sidecar/internal/server/server_test.go index 22f70dc..2439285 100644 --- a/sidecar/internal/server/server_test.go +++ b/sidecar/internal/server/server_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/events" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/store" + "github.com/replicatedhq/ttl.sh/sidecar/internal/events" + "github.com/replicatedhq/ttl.sh/sidecar/internal/store" ) // Server.Store is documented as safe for concurrent use, so the fake locks its diff --git a/sidecar/internal/store/store_test.go b/sidecar/internal/store/store_test.go index df21254..8f8f19b 100644 --- a/sidecar/internal/store/store_test.go +++ b/sidecar/internal/store/store_test.go @@ -9,7 +9,7 @@ import ( "github.com/redis/go-redis/v9" - "github.com/nullbytelabs/zot-ephemeral-ttl/internal/redistest" + "github.com/replicatedhq/ttl.sh/sidecar/internal/redistest" ) // These tests run against a real Redis, started for the package by redistest. From 0cc77b8b38517ac35c491e2822dcb6c3f4b3cf10 Mon Sep 17 00:00:00 2001 From: Josh Sandlin Date: Thu, 30 Jul 2026 17:49:42 -0400 Subject: [PATCH 2/4] dont throw away errors, more gracefully exit, and dont wait 30 seconds for a delete response --- sidecar/internal/app/app.go | 22 ++++++++++++++++++++-- sidecar/internal/registry/registry.go | 11 ++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/sidecar/internal/app/app.go b/sidecar/internal/app/app.go index 9c68570..6ddb790 100644 --- a/sidecar/internal/app/app.go +++ b/sidecar/internal/app/app.go @@ -56,8 +56,18 @@ func Run(ctx context.Context, cfg config.Config) error { ReadHeaderTimeout: 10 * time.Second, } + // The sweep loop gets its own cancellable context so it can be stopped on + // the way out even when shutdown was triggered by a server error rather + // than by ctx. + reaperCtx, stopReaper := context.WithCancel(ctx) + defer stopReaper() + rp := reaper.New(cfg.SweepInterval, st, registry.New(cfg.ZotURL)) - go rp.Run(ctx) + reaperDone := make(chan struct{}) + go func() { + defer close(reaperDone) + rp.Run(reaperCtx) + }() serveErr := make(chan error, 1) go func() { @@ -76,5 +86,13 @@ func Run(ctx context.Context, cfg config.Config) error { log.Printf("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return httpSrv.Shutdown(shutdownCtx) + shutdownErr := httpSrv.Shutdown(shutdownCtx) + + // Wait for an in-flight sweep to unwind before returning, so it cannot + // still be talking to Redis when the deferred Close runs. Cancelling + // reaperCtx aborts the delete request in flight, so this does not block for + // long. + stopReaper() + <-reaperDone + return shutdownErr } diff --git a/sidecar/internal/registry/registry.go b/sidecar/internal/registry/registry.go index ce18f94..8bc4f0f 100644 --- a/sidecar/internal/registry/registry.go +++ b/sidecar/internal/registry/registry.go @@ -21,7 +21,11 @@ func New(baseURL string) *Client { return &Client{ baseURL: baseURL, http: &http.Client{ - Timeout: 30 * time.Second, + // A manifest delete is a metadata operation against a zot running + // alongside this process, so it should be fast. Failing quickly + // keeps one wedged tag from stalling the rest of the sweep; the row + // stays in the store and is retried on the next tick. + Timeout: 5 * time.Second, }, } } @@ -41,11 +45,12 @@ func (c *Client) DeleteManifest(ctx context.Context, repo, tag string) error { return err } defer func() { _ = resp.Body.Close() }() - _, _ = io.Copy(io.Discard, resp.Body) + body, _ := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusOK, http.StatusAccepted, http.StatusNoContent, http.StatusNotFound: return nil default: - return fmt.Errorf("DELETE %s -> %d", endpoint, resp.StatusCode) + // zot describes the failure in the body; %q keeps it on one log line. + return fmt.Errorf("DELETE %s -> %d: %q", endpoint, resp.StatusCode, body) } } From 9a12cca1d6770fe7c126ce7f75f706c8debcc3e4 Mon Sep 17 00:00:00 2001 From: Josh Sandlin Date: Thu, 30 Jul 2026 18:00:04 -0400 Subject: [PATCH 3/4] update dependabot to care about go, make ci and govulncheck a little less fragile --- .github/dependabot.yml | 5 +++++ .github/workflows/ci.yaml | 9 +++------ .github/workflows/govulncheck.yaml | 17 ++++++----------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7571621..e6cb0b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,11 @@ updates: schedule: interval: "daily" + - package-ecosystem: "gomod" + directory: "/sidecar" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0db5542..a57d5d7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,9 +17,6 @@ concurrency: permissions: contents: read -env: - GO_VERSION: "1.26" - defaults: run: working-directory: sidecar @@ -33,7 +30,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: sidecar/go.mod check-latest: true cache-dependency-path: sidecar/go.sum @@ -76,7 +73,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: sidecar/go.mod check-latest: true cache-dependency-path: sidecar/go.sum @@ -94,7 +91,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: sidecar/go.mod check-latest: true cache-dependency-path: sidecar/go.sum diff --git a/.github/workflows/govulncheck.yaml b/.github/workflows/govulncheck.yaml index 72206fd..08d2e05 100644 --- a/.github/workflows/govulncheck.yaml +++ b/.github/workflows/govulncheck.yaml @@ -19,10 +19,6 @@ concurrency: permissions: contents: read -defaults: - run: - working-directory: sidecar - jobs: govulncheck: name: govulncheck @@ -30,13 +26,12 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + # The action still installs govulncheck itself at @latest. + - name: Run govulncheck + uses: golang/govulncheck-action@v1 with: - go-version: "1.26" + go-version-file: sidecar/go.mod check-latest: true cache-dependency-path: sidecar/go.sum - - - name: Run govulncheck - run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./... + work-dir: sidecar + repo-checkout: false From 0e27605856bb0b94d0bb342f3575df9ad337962b Mon Sep 17 00:00:00 2001 From: Josh Sandlin Date: Thu, 30 Jul 2026 18:41:18 -0400 Subject: [PATCH 4/4] add smoke test back, too --- .github/workflows/deploy.yml | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 922766f..f0e6c8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -101,22 +101,23 @@ jobs: runs-on: ubuntu-latest needs: deploy steps: - - name: Test push and pull + - name: Test push, pull, and expiry run: | - IMAGE="ttl.sh/smoke-${{ github.sha }}:5m" - echo "๐Ÿงช Testing with image: $IMAGE" - - # Use busybox - tiny image (~1MB) + IMAGE="ttl.sh/smoke-${{ github.sha }}:1m" + docker pull busybox:latest docker tag busybox:latest "$IMAGE" - - echo "๐Ÿ“ค Pushing image..." docker push "$IMAGE" - - # Remove local copy to force pull from registry - docker rmi "$IMAGE" - - echo "๐Ÿ“ฅ Pulling image back..." + + docker rmi "$IMAGE" busybox:latest docker pull "$IMAGE" - - echo "โœ… Push/pull test passed!" + echo "โœ… Push/pull passed" + + docker rmi "$IMAGE" + sleep 90 + + if docker pull "$IMAGE"; then + echo "โŒ image still pullable after TTL" + exit 1 + fi + echo "โœ… Image expired as expected"