Skip to content
Open
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
59 changes: 57 additions & 2 deletions pkg/collector/collector.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package main

import (
"context"
"flag"
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"syscall"
"time"

"github.com/eraser-dev/eraser/pkg/cri"
Expand Down Expand Up @@ -85,8 +88,60 @@ func main() {
os.Exit(1)
}

if err := util.WriteImagesPipe(path, finalImages); err != nil {
log.Error(err, "failed to send images", "pipeFile", path)
// Registering a handler suppresses the default SIGTERM exit, so it covers
// exactly the one call that observes ctx. Everything above builds its own
// timeouts from Background, and Await below has no context at all; holding
// the handler across either would swallow the signal.
//
// signal.Notify rather than NotifyContext, because deregistering has to stay
// distinguishable from receiving a signal, and NotifyContext's stop func
// cancels the very context a signal would.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

ctx, cancel := context.WithCancel(context.Background())
signaled := make(chan struct{})
watching := make(chan struct{})
go func() {
defer close(watching)
select {
case <-sigCh:
close(signaled)
cancel()
case <-ctx.Done():
}
}()

writeErr := util.WriteImagesPipe(ctx, path, finalImages)

// Deregister, then join the watcher. Afterwards a signal has either closed
// signaled or is still sitting in the buffer, and there is no third
// outcome -- previously one that landed between the check and the stop was
// consumed by the handler and lost, leaving the collector in an Await it
// could not be interrupted out of.
signal.Stop(sigCh)
cancel()
<-watching
Comment on lines +122 to +124

terminating := false
select {
case <-signaled:
terminating = true
default:
select {
case <-sigCh:
terminating = true
default:
}
}

if writeErr != nil {
log.Error(writeErr, "failed to send images", "pipeFile", path)
os.Exit(1)
}

if terminating {
log.Error(context.Canceled, "terminating before waiting for completion")
os.Exit(1)
}

Expand Down
7 changes: 5 additions & 2 deletions pkg/remover/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (
util "github.com/eraser-dev/eraser/pkg/utils"
)

func removeImages(c cri.Remover, targetImages []string) (int, error) {
func removeImages(ctx context.Context, c cri.Remover, targetImages []string) (int, error) {
removed := 0

backgroundContext, cancel := context.WithTimeout(context.Background(), timeout)
// Derived from the caller's context, not Background: signal notification is
// registered for the whole process, so nothing would observe a SIGTERM during
// the deletion loop otherwise.
backgroundContext, cancel := context.WithTimeout(ctx, timeout)
Comment on lines +14 to +17

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed in 822f166c.

Deriving the deletion timeout from the signal context is what created it: before this branch removeImages built its own budget from context.Background(), so cancellation could not reach it and could not be misreported by it. Once it can, nil from removeImages stops meaning "the images are gone".

main now refuses to treat the removal as successful without checking:

// A signal that landed during removal was consumed rather than killing the
// process, and with --imagelist there is no completion write below to report
// it, so an interrupted run would otherwise exit 0 having removed nothing.
if err := ctx.Err(); err != nil {
    log.Error(err, "terminating before removal finished", "removed", removed)
    os.Exit(generalErr)
}

I took your fallback rather than the first suggestion deliberately, because the two are at different layers and this PR only owns one of them. Returning the context error from inside the delete branches also stops the loop early, which is a behavior change to the removal loop itself; the stacked #1239 makes it, with the tests for it, because that PR is what gives each deletion its own budget and therefore has to distinguish "this image ran out of time, carry on" from "the caller is gone, stop". Here the only defect is the exit status, so that is all that changes: an interrupted run still walks the remaining list logging instant failures, but it can no longer exit 0 while doing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the coverage was theatre. Fixed in 768d5bfc.

Every case in TestRemoveImages passes a context that is never done, and the fake discarded the argument outright, so reverting context.WithTimeout(ctx, timeout) to context.WithTimeout(context.Background(), timeout) left the suite green. That is the one thing this hunk exists to do.

The fake now observes its context, which is what a real client does anyway, and the propagation is pinned by behavior rather than by inspection:

func TestRemoveImagesPassesTheCallersContextToTheRuntime(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	client := &testClient{t: t, images: []*v1.Image{{Id: "sha256:aaaa"}}}

	removed, err := removeImages(ctx, client, []string{"sha256:aaaa"})
	...
}

Against context.Background() it reports removed = 1, want 0 and the image was deleted for a caller that was already gone.

I went with an already-cancelled caller rather than the blocking fake you suggested, because a blocking fake asserts that cancellation unblocks removeImages, and here nothing blocks — DeleteImage is a call, not a wait, and the loop has no guard in this PR. The question this hunk raises is narrower: does the runtime see the caller's context or a detached one. An already-dead context answers exactly that, without inventing a blocking behavior the real CRI client doesn't have.

Worth flagging for when you look at the stacked #1239: it adds a guard that returns before the loop reaches the runtime at all, which makes this test assert the wrong thing there, so it is removed in the commit that introduces the guard. The propagation stays covered there by TestRemoveImagesSurfacesCancellationDuringTheFinalDeletion, which cancels during a deletion and so still requires the runtime to be holding the caller's context.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should context.Canceled stop the deletion loop immediately? At present it is handled like an ordinary per-image error, so every remaining DeleteImage call is attempted before main notices cancellation. Could we return the context error here and in the prune loop?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it exists — in the stacked #1239, with tests. Two commits there do exactly what you describe:

  • 085dc1c adds the guard to the top of both loops, so a cancelled caller stops rather than walking the rest of the list
  • 588635d handles the case the guard can't see: cancellation during the last deletion, where there is no later iteration to reach the guard

The split was deliberate but I'm now the only one who thinks so, which is usually the sign it was wrong. My reasoning was that returning the context error from inside the delete branches changes the loop's behaviour, and #1239 is the PR that has to distinguish "this image ran out of its own budget, carry on" from "the caller is gone, stop" — because it's the one that gives each deletion a budget. Here, without per-image budgets, the only defect is the exit status, so that's all I changed: main checks ctx.Err() after removeImages and exits non-zero, which stops an interrupted run reporting success even though it still walks the remaining list.

You're the second reviewer to raise it against this PR, so if you'd rather #1237 not merge without the early stop, say so and I'll move both hunks down here and rebase #1239 on top. It's a contained change; I just didn't want to duplicate it across two open PRs on my own initiative.

defer cancel()

images, err := c.ListImages(backgroundContext)
Expand Down
24 changes: 18 additions & 6 deletions pkg/remover/remover.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,33 +106,45 @@ func main() {
log.Info("no images to exclude")
}

removed, err := removeImages(client, imagelist)
// Registering the handler suppresses the default SIGTERM exit, so it starts
// only here: once the peer publishes its endpoint the read above blocks in a
// call no context can interrupt, and covering it would swallow the signal
// until SIGKILL. The stop func is discarded rather than deferred because
// every exit path below is os.Exit, which would skip it anyway.
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

removed, err := removeImages(ctx, client, imagelist)
if err != nil {
log.Error(err, "failed to remove images")
os.Exit(generalErr)
}

// A signal that landed during removal was consumed rather than killing the
// process, and with --imagelist there is no completion write below to report
// it, so an interrupted run would otherwise exit 0 having removed nothing.
if err := ctx.Err(); err != nil {
log.Error(err, "terminating before removal finished", "removed", removed)
os.Exit(generalErr)
}

if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" {
// record metrics
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

exporter, reader, provider := metrics.ConfigureMetrics(ctx, log, os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
otel.SetMeterProvider(provider)

if err := metrics.RecordMetricsRemover(ctx, otel.GetMeterProvider(), int64(removed)); err != nil {
log.Error(err, "error recording metrics")
}
metrics.ExportMetrics(log, exporter, reader)
cancel()
}

if *imageListPtr == "" {
if err := util.WriteCompletionPipe(util.EraseCompleteCollectPath); err != nil {
if err := util.WriteCompletionPipe(ctx, util.EraseCompleteCollectPath); err != nil {
log.Error(err, "unable to signal completion", "pipeFile", util.EraseCompleteCollectPath)
os.Exit(generalErr)
}

err := util.WriteCompletionPipe(util.EraseCompleteScanPath)
err := util.WriteCompletionPipe(ctx, util.EraseCompleteScanPath)
// if the scanner is disabled
if os.IsNotExist(err) {
return
Expand Down
26 changes: 25 additions & 1 deletion pkg/remover/remover_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"testing"

v1 "k8s.io/cri-api/pkg/apis/runtime/v1"
Expand Down Expand Up @@ -50,7 +51,7 @@ func TestRemoveImages(t *testing.T) {
}
}

_, err := removeImages(client, tc.remove)
_, err := removeImages(context.Background(), client, tc.remove)
if tc.shouldErr && err == nil {
t.Fatal("expected error, got none")
}
Expand Down Expand Up @@ -85,3 +86,26 @@ func TestRemoveImages(t *testing.T) {
})
}
}

// removeImages builds its deadline from the caller rather than Background, so
// that a SIGTERM registered process-wide actually reaches the runtime. Nothing
// above notices if that regresses -- every case passes a context that is never
// done -- so this pins it: a caller who has already gone must not get images
// deleted on their behalf.
func TestRemoveImagesPassesTheCallersContextToTheRuntime(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

client := &testClient{t: t, images: []*v1.Image{{Id: "sha256:aaaa"}}}

removed, err := removeImages(ctx, client, []string{"sha256:aaaa"})
if err != nil {
t.Fatalf("removeImages: %v", err)
}
if removed != 0 {
t.Errorf("removed = %d, want 0", removed)
}
if len(client.images) != 1 {
t.Error("the image was deleted for a caller that was already gone")
}
}
6 changes: 5 additions & 1 deletion pkg/remover/test_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ func (c *testClient) removeImageFromSlice(index int) {
c.images = s
}

func (c *testClient) DeleteImage(_ context.Context, image string) (err error) {
func (c *testClient) DeleteImage(ctx context.Context, image string) (err error) {
c.logf("DeleteImage: %s", image)
// a real CRI client fails the call rather than deleting anyway
if err := ctx.Err(); err != nil {
return err
}
if image == "" {
return errImageEmpty
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/scanners/template/scanner_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (cfg *config) SendImages(nonCompliantImages, failedImages []unversioned.Ima
nonCompliantImages = append(nonCompliantImages, failedImages...)
}

if err := util.WriteScanErasePipe(nonCompliantImages); err != nil {
if err := util.WriteImagesPipe(cfg.ctx, util.ScanErasePath, nonCompliantImages); err != nil {
cfg.log.Error(err, "unable to write non-compliant images to scan erase pipe")
return err
}
Expand Down
Loading
Loading