detached 82c3ab4

feat(registry): add deploy event suppression for CLI-managed pushes

commit
82c3ab44d35baed671b6cc2d67329277f9ca2f04
author
brice · · 2026-03-04 06:38 UTC
parent
40bf8ea
signature
unsigned
2 files · +82 -11
M internal/usecase/registry/service.go +43 -11
@@ -7,6 +7,8 @@ import (
77 "fmt"
88 "io"
99 "strings"
10+ "sync"
11+ "time"
1012
1113 "github.com/bnema/zerowrap"
1214 "go.opentelemetry.io/otel"
@@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry")
2426
2527 // Service implements the RegistryService interface.
2628 type Service struct {
27- blobStorage out.BlobStorage
28- manifestStorage out.ManifestStorage
29- eventBus out.EventPublisher
30- metrics *telemetry.Metrics
29+ blobStorage out.BlobStorage
30+ manifestStorage out.ManifestStorage
31+ eventBus out.EventPublisher
32+ metrics *telemetry.Metrics
33+ suppressedImages sync.Map // imageName -> *time.Timer
3134 }
3235
3336 // SetMetrics sets the telemetry metrics for the registry service.
@@ -48,6 +51,31 @@ func NewService(
4851 }
4952 }
5053
54+// SuppressDeployEvent marks an image name to skip image.pushed events.
55+// The suppression auto-expires after 2 minutes to prevent leaks.
56+func (s *Service) SuppressDeployEvent(imageName string) {
57+ timer := time.AfterFunc(2*time.Minute, func() {
58+ s.suppressedImages.Delete(imageName)
59+ })
60+ if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded {
61+ existing.(*time.Timer).Stop()
62+ s.suppressedImages.Store(imageName, timer)
63+ }
64+}
65+
66+// ClearDeployEventSuppression removes event suppression for an image.
67+func (s *Service) ClearDeployEventSuppression(imageName string) {
68+ if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded {
69+ v.(*time.Timer).Stop()
70+ }
71+}
72+
73+// IsDeployEventSuppressed checks if deploy events are suppressed for an image.
74+func (s *Service) IsDeployEventSuppressed(imageName string) bool {
75+ _, exists := s.suppressedImages.Load(imageName)
76+ return exists
77+}
78+
5179 // GetManifest retrieves a manifest by name and reference.
5280 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {
5381 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -110,13 +138,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
110138 // A docker push sends manifests by both digest and tag; firing only on
111139 // tag prevents duplicate deploy triggers for the same push.
112140 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
113- if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
114- Name: manifest.Name,
115- Reference: manifest.Reference,
116- Manifest: manifest.Data,
117- Annotations: manifest.Annotations,
118- }); err != nil {
119- log.Warn().Err(err).Msg("failed to publish image pushed event")
141+ if s.IsDeployEventSuppressed(manifest.Name) {
142+ log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
143+ } else {
144+ if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
145+ Name: manifest.Name,
146+ Reference: manifest.Reference,
147+ Manifest: manifest.Data,
148+ Annotations: manifest.Annotations,
149+ }); err != nil {
150+ log.Warn().Err(err).Msg("failed to publish image pushed event")
151+ }
120152 }
121153 }
122154
@@ -7,6 +7,8 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "strings"9 "strings"
10+ "sync"
11+ "time"
10 12
11 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
12 "go.opentelemetry.io/otel"14 "go.opentelemetry.io/otel"
@@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry")
24 26
25 // Service implements the RegistryService interface.27 // Service implements the RegistryService interface.
26 type Service struct {28 type Service struct {
27- blobStorage out.BlobStorage29+ blobStorage out.BlobStorage
28- manifestStorage out.ManifestStorage30+ manifestStorage out.ManifestStorage
29- eventBus out.EventPublisher31+ eventBus out.EventPublisher
30- metrics *telemetry.Metrics32+ metrics *telemetry.Metrics
33+ suppressedImages sync.Map // imageName -> *time.Timer
31 }34 }
32 35
33 // SetMetrics sets the telemetry metrics for the registry service.36 // SetMetrics sets the telemetry metrics for the registry service.
@@ -48,6 +51,31 @@ func NewService(
48 }51 }
49 }52 }
50 53
54+// SuppressDeployEvent marks an image name to skip image.pushed events.
55+// The suppression auto-expires after 2 minutes to prevent leaks.
56+func (s *Service) SuppressDeployEvent(imageName string) {
57+ timer := time.AfterFunc(2*time.Minute, func() {
58+ s.suppressedImages.Delete(imageName)
59+ })
60+ if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded {
61+ existing.(*time.Timer).Stop()
62+ s.suppressedImages.Store(imageName, timer)
63+ }
64+}
65+
66+// ClearDeployEventSuppression removes event suppression for an image.
67+func (s *Service) ClearDeployEventSuppression(imageName string) {
68+ if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded {
69+ v.(*time.Timer).Stop()
70+ }
71+}
72+
73+// IsDeployEventSuppressed checks if deploy events are suppressed for an image.
74+func (s *Service) IsDeployEventSuppressed(imageName string) bool {
75+ _, exists := s.suppressedImages.Load(imageName)
76+ return exists
77+}
78+
51 // GetManifest retrieves a manifest by name and reference.79 // GetManifest retrieves a manifest by name and reference.
52 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {80 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {
53 ctx = zerowrap.CtxWithFields(ctx, map[string]any{81 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -110,13 +138,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
110 // A docker push sends manifests by both digest and tag; firing only on138 // A docker push sends manifests by both digest and tag; firing only on
111 // tag prevents duplicate deploy triggers for the same push.139 // tag prevents duplicate deploy triggers for the same push.
112 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {140 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
113- if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{141+ if s.IsDeployEventSuppressed(manifest.Name) {
114- Name: manifest.Name,142+ log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
115- Reference: manifest.Reference,143+ } else {
116- Manifest: manifest.Data,144+ if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
117- Annotations: manifest.Annotations,145+ Name: manifest.Name,
118- }); err != nil {146+ Reference: manifest.Reference,
119- log.Warn().Err(err).Msg("failed to publish image pushed event")147+ Manifest: manifest.Data,
148+ Annotations: manifest.Annotations,
149+ }); err != nil {
150+ log.Warn().Err(err).Msg("failed to publish image pushed event")
151+ }
120 }152 }
121 }153 }
122 154
M internal/usecase/registry/service_test.go +39 -0
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/zerowrap"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
1415
1516 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1617 "github.com/bnema/gordon/internal/domain"
@@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) {
437438 assert.Nil(t, repos)
438439 assert.Contains(t, err.Error(), "failed to list repositories")
439440 }
441+
442+func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) {
443+ blobStorage := mocks.NewMockBlobStorage(t)
444+ manifestStorage := mocks.NewMockManifestStorage(t)
445+ eventBus := mocks.NewMockEventPublisher(t)
446+
447+ svc := NewService(blobStorage, manifestStorage, eventBus)
448+
449+ svc.SuppressDeployEvent("my-app")
450+
451+ manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil)
452+
453+ manifest := &domain.Manifest{
454+ Name: "my-app",
455+ Reference: "latest",
456+ ContentType: "application/vnd.oci.image.manifest.v1+json",
457+ Data: []byte(`{"schemaVersion":2}`),
458+ }
459+
460+ _, err := svc.PutManifest(context.Background(), manifest)
461+ require.NoError(t, err)
462+
463+ eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything)
464+}
465+
466+func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) {
467+ blobStorage := mocks.NewMockBlobStorage(t)
468+ manifestStorage := mocks.NewMockManifestStorage(t)
469+ eventBus := mocks.NewMockEventPublisher(t)
470+
471+ svc := NewService(blobStorage, manifestStorage, eventBus)
472+
473+ svc.SuppressDeployEvent("my-app")
474+ assert.True(t, svc.IsDeployEventSuppressed("my-app"))
475+
476+ svc.ClearDeployEventSuppression("my-app")
477+ assert.False(t, svc.IsDeployEventSuppressed("my-app"))
478+}
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
14 15
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
@@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) {
437 assert.Nil(t, repos)438 assert.Nil(t, repos)
438 assert.Contains(t, err.Error(), "failed to list repositories")439 assert.Contains(t, err.Error(), "failed to list repositories")
439 }440 }
441+
442+func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) {
443+ blobStorage := mocks.NewMockBlobStorage(t)
444+ manifestStorage := mocks.NewMockManifestStorage(t)
445+ eventBus := mocks.NewMockEventPublisher(t)
446+
447+ svc := NewService(blobStorage, manifestStorage, eventBus)
448+
449+ svc.SuppressDeployEvent("my-app")
450+
451+ manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil)
452+
453+ manifest := &domain.Manifest{
454+ Name: "my-app",
455+ Reference: "latest",
456+ ContentType: "application/vnd.oci.image.manifest.v1+json",
457+ Data: []byte(`{"schemaVersion":2}`),
458+ }
459+
460+ _, err := svc.PutManifest(context.Background(), manifest)
461+ require.NoError(t, err)
462+
463+ eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything)
464+}
465+
466+func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) {
467+ blobStorage := mocks.NewMockBlobStorage(t)
468+ manifestStorage := mocks.NewMockManifestStorage(t)
469+ eventBus := mocks.NewMockEventPublisher(t)
470+
471+ svc := NewService(blobStorage, manifestStorage, eventBus)
472+
473+ svc.SuppressDeployEvent("my-app")
474+ assert.True(t, svc.IsDeployEventSuppressed("my-app"))
475+
476+ svc.ClearDeployEventSuppression("my-app")
477+ assert.False(t, svc.IsDeployEventSuppressed("my-app"))
478+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close