detached d0e22fa

fix(telemetry): export managed container gauge from v3 app state (#291)

Replace the unused gordon.container.managed UpDownCounter with an observable gauge that counts ACTIVE app service containers (excluding stopped apps) at each collection. Closes #289.
commit
d0e22fa16474cba463ccfee2c0af590597b6f59a
author
bnema · · 2026-09-26 01:21 UTC
committer
GitHub · · 2026-09-26 01:21 UTC
parent
7d43d7c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +240 -8
M docs/config/telemetry.md +2 -2
@@ -147,9 +147,9 @@ Attributes: `domain`, `image`
147147 |--------|------|------|-------------|
148148 | `gordon.container.restarts` | Counter | - | Container restart count |
149149 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
150-| `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
150+| `gordon.container.managed` | Gauge | - | Containers of running app services, read from ACTIVE app state at each export |
151151
152-Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
152+Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.managed` has no attributes; tell instances apart with the `host.name` resource attribute.
153153
154154 ### Registry
155155
@@ -147,9 +147,9 @@ Attributes: `domain`, `image`
147 |--------|------|------|-------------|147 |--------|------|------|-------------|
148 | `gordon.container.restarts` | Counter | - | Container restart count |148 | `gordon.container.restarts` | Counter | - | Container restart count |
149 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |149 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
150-| `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |150+| `gordon.container.managed` | Gauge | - | Containers of running app services, read from ACTIVE app state at each export |
151 151
152-Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes152+Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.managed` has no attributes; tell instances apart with the `host.name` resource attribute.
153 153
154 ### Registry154 ### Registry
155 155
M internal/adapters/out/telemetry/metrics.go +21 -6
@@ -15,6 +15,8 @@ var _ out.Metrics = (*Metrics)(nil)
1515
1616 // Metrics holds Gordon-specific OTel metrics instruments.
1717 type Metrics struct {
18+ meter metric.Meter
19+
1820 // Deployments
1921 DeployTotal metric.Int64Counter
2022 DeployDuration metric.Float64Histogram
@@ -23,7 +25,6 @@ type Metrics struct {
2325 // Container lifecycle
2426 ContainerRestarts metric.Int64Counter
2527 ContainerCrashLoops metric.Int64Counter
26- ManagedContainers metric.Int64UpDownCounter
2728
2829 // Registry
2930 ImagePushTotal metric.Int64Counter
@@ -39,7 +40,7 @@ type Metrics struct {
3940 // (OTel returns noop instruments when no MeterProvider is set).
4041 func NewMetrics() (*Metrics, error) {
4142 meter := otel.Meter("gordon")
42- m := &Metrics{}
43+ m := &Metrics{meter: meter}
4344 var err error
4445
4546 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
@@ -64,10 +65,6 @@ func NewMetrics() (*Metrics, error) {
6465 metric.WithDescription("Total crash loop detections")); err != nil {
6566 return nil, err
6667 }
67- if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed",
68- metric.WithDescription("Currently managed containers")); err != nil {
69- return nil, err
70- }
7168 if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total",
7269 metric.WithDescription("Total image pushes")); err != nil {
7370 return nil, err
@@ -89,6 +86,24 @@ func NewMetrics() (*Metrics, error) {
8986 return m, nil
9087 }
9188
89+// ObserveManagedContainers registers the gordon.container.managed gauge.
90+// count is called at each collection, so the exported value always reflects
91+// current state instead of drifting like an incremental counter. A count
92+// error skips that observation rather than exporting a wrong value.
93+func (m *Metrics) ObserveManagedContainers(count func(context.Context) (int64, error)) error {
94+ _, err := m.meter.Int64ObservableGauge("gordon.container.managed",
95+ metric.WithDescription("Currently managed containers"),
96+ metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
97+ n, err := count(ctx)
98+ if err != nil {
99+ return err
100+ }
101+ o.Observe(n)
102+ return nil
103+ }))
104+ return err
105+}
106+
92107 // RecordImagePush implements out.Metrics.
93108 func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) {
94109 attrs := metric.WithAttributes(
@@ -15,6 +15,8 @@ var _ out.Metrics = (*Metrics)(nil)
15 15
16 // Metrics holds Gordon-specific OTel metrics instruments.16 // Metrics holds Gordon-specific OTel metrics instruments.
17 type Metrics struct {17 type Metrics struct {
18+ meter metric.Meter
19+
18 // Deployments20 // Deployments
19 DeployTotal metric.Int64Counter21 DeployTotal metric.Int64Counter
20 DeployDuration metric.Float64Histogram22 DeployDuration metric.Float64Histogram
@@ -23,7 +25,6 @@ type Metrics struct {
23 // Container lifecycle25 // Container lifecycle
24 ContainerRestarts metric.Int64Counter26 ContainerRestarts metric.Int64Counter
25 ContainerCrashLoops metric.Int64Counter27 ContainerCrashLoops metric.Int64Counter
26- ManagedContainers metric.Int64UpDownCounter
27 28
28 // Registry29 // Registry
29 ImagePushTotal metric.Int64Counter30 ImagePushTotal metric.Int64Counter
@@ -39,7 +40,7 @@ type Metrics struct {
39 // (OTel returns noop instruments when no MeterProvider is set).40 // (OTel returns noop instruments when no MeterProvider is set).
40 func NewMetrics() (*Metrics, error) {41 func NewMetrics() (*Metrics, error) {
41 meter := otel.Meter("gordon")42 meter := otel.Meter("gordon")
42- m := &Metrics{}43+ m := &Metrics{meter: meter}
43 var err error44 var err error
44 45
45 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",46 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
@@ -64,10 +65,6 @@ func NewMetrics() (*Metrics, error) {
64 metric.WithDescription("Total crash loop detections")); err != nil {65 metric.WithDescription("Total crash loop detections")); err != nil {
65 return nil, err66 return nil, err
66 }67 }
67- if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed",
68- metric.WithDescription("Currently managed containers")); err != nil {
69- return nil, err
70- }
71 if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total",68 if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total",
72 metric.WithDescription("Total image pushes")); err != nil {69 metric.WithDescription("Total image pushes")); err != nil {
73 return nil, err70 return nil, err
@@ -89,6 +86,24 @@ func NewMetrics() (*Metrics, error) {
89 return m, nil86 return m, nil
90 }87 }
91 88
89+// ObserveManagedContainers registers the gordon.container.managed gauge.
90+// count is called at each collection, so the exported value always reflects
91+// current state instead of drifting like an incremental counter. A count
92+// error skips that observation rather than exporting a wrong value.
93+func (m *Metrics) ObserveManagedContainers(count func(context.Context) (int64, error)) error {
94+ _, err := m.meter.Int64ObservableGauge("gordon.container.managed",
95+ metric.WithDescription("Currently managed containers"),
96+ metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
97+ n, err := count(ctx)
98+ if err != nil {
99+ return err
100+ }
101+ o.Observe(n)
102+ return nil
103+ }))
104+ return err
105+}
106+
92 // RecordImagePush implements out.Metrics.107 // RecordImagePush implements out.Metrics.
93 func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) {108 func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) {
94 attrs := metric.WithAttributes(109 attrs := metric.WithAttributes(
A internal/adapters/out/telemetry/metrics_test.go +67 -0
@@ -0,0 +1,67 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+ "go.opentelemetry.io/otel"
11+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
12+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
13+)
14+
15+func collectManaged(t *testing.T, reader *sdkmetric.ManualReader) (int64, bool) {
16+ t.Helper()
17+ var rm metricdata.ResourceMetrics
18+ require.NoError(t, reader.Collect(context.Background(), &rm))
19+ for _, sm := range rm.ScopeMetrics {
20+ for _, m := range sm.Metrics {
21+ if m.Name != "gordon.container.managed" {
22+ continue
23+ }
24+ gauge, ok := m.Data.(metricdata.Gauge[int64])
25+ require.True(t, ok)
26+ require.Len(t, gauge.DataPoints, 1)
27+ assert.Equal(t, 0, gauge.DataPoints[0].Attributes.Len(), "gauge must stay label-free")
28+ return gauge.DataPoints[0].Value, true
29+ }
30+ }
31+ return 0, false
32+}
33+
34+func TestObserveManagedContainers(t *testing.T) {
35+ reader := sdkmetric.NewManualReader()
36+ prev := otel.GetMeterProvider()
37+ otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
38+ t.Cleanup(func() { otel.SetMeterProvider(prev) })
39+
40+ m, err := NewMetrics()
41+ require.NoError(t, err)
42+
43+ current := int64(3)
44+ var countErr error
45+ require.NoError(t, m.ObserveManagedContainers(func(context.Context) (int64, error) {
46+ return current, countErr
47+ }))
48+
49+ v, ok := collectManaged(t, reader)
50+ require.True(t, ok)
51+ assert.Equal(t, int64(3), v)
52+
53+ current = 1
54+ v, ok = collectManaged(t, reader)
55+ require.True(t, ok)
56+ assert.Equal(t, int64(1), v)
57+
58+ // A failed count skips the observation instead of exporting a wrong value.
59+ countErr = errors.New("state unavailable")
60+ var rm metricdata.ResourceMetrics
61+ _ = reader.Collect(context.Background(), &rm)
62+ for _, sm := range rm.ScopeMetrics {
63+ for _, metric := range sm.Metrics {
64+ assert.NotEqual(t, "gordon.container.managed", metric.Name)
65+ }
66+ }
67+}
@@ -0,0 +1,67 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+ "go.opentelemetry.io/otel"
11+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
12+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
13+)
14+
15+func collectManaged(t *testing.T, reader *sdkmetric.ManualReader) (int64, bool) {
16+ t.Helper()
17+ var rm metricdata.ResourceMetrics
18+ require.NoError(t, reader.Collect(context.Background(), &rm))
19+ for _, sm := range rm.ScopeMetrics {
20+ for _, m := range sm.Metrics {
21+ if m.Name != "gordon.container.managed" {
22+ continue
23+ }
24+ gauge, ok := m.Data.(metricdata.Gauge[int64])
25+ require.True(t, ok)
26+ require.Len(t, gauge.DataPoints, 1)
27+ assert.Equal(t, 0, gauge.DataPoints[0].Attributes.Len(), "gauge must stay label-free")
28+ return gauge.DataPoints[0].Value, true
29+ }
30+ }
31+ return 0, false
32+}
33+
34+func TestObserveManagedContainers(t *testing.T) {
35+ reader := sdkmetric.NewManualReader()
36+ prev := otel.GetMeterProvider()
37+ otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
38+ t.Cleanup(func() { otel.SetMeterProvider(prev) })
39+
40+ m, err := NewMetrics()
41+ require.NoError(t, err)
42+
43+ current := int64(3)
44+ var countErr error
45+ require.NoError(t, m.ObserveManagedContainers(func(context.Context) (int64, error) {
46+ return current, countErr
47+ }))
48+
49+ v, ok := collectManaged(t, reader)
50+ require.True(t, ok)
51+ assert.Equal(t, int64(3), v)
52+
53+ current = 1
54+ v, ok = collectManaged(t, reader)
55+ require.True(t, ok)
56+ assert.Equal(t, int64(1), v)
57+
58+ // A failed count skips the observation instead of exporting a wrong value.
59+ countErr = errors.New("state unavailable")
60+ var rm metricdata.ResourceMetrics
61+ _ = reader.Collect(context.Background(), &rm)
62+ for _, sm := range rm.ScopeMetrics {
63+ for _, metric := range sm.Metrics {
64+ assert.NotEqual(t, "gordon.container.managed", metric.Name)
65+ }
66+ }
67+}
M internal/app/run.go +16 -0
@@ -292,6 +292,7 @@ type Config struct {
292292 type services struct {
293293 runtime *docker.Runtime
294294 eventBus *eventbus.InMemory
295+ metrics *telemetry.Metrics
295296 blobStorage *filesystem.BlobStorage
296297 manifestStorage *filesystem.ManifestStorage
297298 backupStorage *filesystem.BackupStorage
@@ -888,6 +889,7 @@ func (si *serviceInit) initApps() error {
888889 }
889890 store.WithRevisionRetention(si.cfg.Apps.RevisionRetention)
890891 si.svc.appState = store
892+ observeManagedContainers(si.svc.metrics, store, si.log)
891893 // One process-wide GC barrier owns the ordering between resource
892894 // acquisition/publication and destructive prune.
893895 si.svc.gcBarrier = newGCBarrier()
@@ -1206,6 +1208,20 @@ func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
12061208 }
12071209 svc.registrySvc.SetMetrics(gordonMetrics)
12081210 svc.eventBus.SetMetrics(gordonMetrics)
1211+ svc.metrics = gordonMetrics
1212+}
1213+
1214+// observeManagedContainers exports the managed-container gauge from app
1215+// state. No-op when metrics are disabled.
1216+func observeManagedContainers(metrics *telemetry.Metrics, state out.AppStateReader, log zerowrap.Logger) {
1217+ if metrics == nil {
1218+ return
1219+ }
1220+ if err := metrics.ObserveManagedContainers(func(ctx context.Context) (int64, error) {
1221+ return apps.CountManagedContainers(ctx, state)
1222+ }); err != nil {
1223+ log.Warn().Err(err).Msg("failed to register managed container gauge")
1224+ }
12091225 }
12101226
12111227 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
@@ -292,6 +292,7 @@ type Config struct {
292 type services struct {292 type services struct {
293 runtime *docker.Runtime293 runtime *docker.Runtime
294 eventBus *eventbus.InMemory294 eventBus *eventbus.InMemory
295+ metrics *telemetry.Metrics
295 blobStorage *filesystem.BlobStorage296 blobStorage *filesystem.BlobStorage
296 manifestStorage *filesystem.ManifestStorage297 manifestStorage *filesystem.ManifestStorage
297 backupStorage *filesystem.BackupStorage298 backupStorage *filesystem.BackupStorage
@@ -888,6 +889,7 @@ func (si *serviceInit) initApps() error {
888 }889 }
889 store.WithRevisionRetention(si.cfg.Apps.RevisionRetention)890 store.WithRevisionRetention(si.cfg.Apps.RevisionRetention)
890 si.svc.appState = store891 si.svc.appState = store
892+ observeManagedContainers(si.svc.metrics, store, si.log)
891 // One process-wide GC barrier owns the ordering between resource893 // One process-wide GC barrier owns the ordering between resource
892 // acquisition/publication and destructive prune.894 // acquisition/publication and destructive prune.
893 si.svc.gcBarrier = newGCBarrier()895 si.svc.gcBarrier = newGCBarrier()
@@ -1206,6 +1208,20 @@ func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
1206 }1208 }
1207 svc.registrySvc.SetMetrics(gordonMetrics)1209 svc.registrySvc.SetMetrics(gordonMetrics)
1208 svc.eventBus.SetMetrics(gordonMetrics)1210 svc.eventBus.SetMetrics(gordonMetrics)
1211+ svc.metrics = gordonMetrics
1212+}
1213+
1214+// observeManagedContainers exports the managed-container gauge from app
1215+// state. No-op when metrics are disabled.
1216+func observeManagedContainers(metrics *telemetry.Metrics, state out.AppStateReader, log zerowrap.Logger) {
1217+ if metrics == nil {
1218+ return
1219+ }
1220+ if err := metrics.ObserveManagedContainers(func(ctx context.Context) (int64, error) {
1221+ return apps.CountManagedContainers(ctx, state)
1222+ }); err != nil {
1223+ log.Warn().Err(err).Msg("failed to register managed container gauge")
1224+ }
1209 }1225 }
1210 1226
1211 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {1227 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
A internal/usecase/apps/managed_count.go +42 -0
@@ -0,0 +1,42 @@
1+package apps
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+)
9+
10+// CountManagedContainers returns the number of containers Gordon currently
11+// manages: ACTIVE app services bound to a container, excluding apps whose
12+// durable intent is stopped. It reads persisted state only, so the result is
13+// correct after boot and after every lifecycle operation, including failures.
14+func CountManagedContainers(ctx context.Context, state out.AppStateReader) (int64, error) {
15+ names, err := state.ListApps(ctx)
16+ if err != nil {
17+ return 0, fmt.Errorf("failed to list apps: %w", err)
18+ }
19+ var total int64
20+ for _, name := range names {
21+ intent, err := state.LoadIntent(ctx, name)
22+ if err != nil {
23+ return 0, fmt.Errorf("failed to load intent for app %q: %w", name, err)
24+ }
25+ if intent.Stopped {
26+ continue
27+ }
28+ active, ok, err := state.LoadActive(ctx, name)
29+ if err != nil {
30+ return 0, fmt.Errorf("failed to load active state for app %q: %w", name, err)
31+ }
32+ if !ok {
33+ continue
34+ }
35+ for _, svc := range active.Services {
36+ if svc.Container != "" {
37+ total++
38+ }
39+ }
40+ }
41+ return total, nil
42+}
@@ -0,0 +1,42 @@
1+package apps
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+)
9+
10+// CountManagedContainers returns the number of containers Gordon currently
11+// manages: ACTIVE app services bound to a container, excluding apps whose
12+// durable intent is stopped. It reads persisted state only, so the result is
13+// correct after boot and after every lifecycle operation, including failures.
14+func CountManagedContainers(ctx context.Context, state out.AppStateReader) (int64, error) {
15+ names, err := state.ListApps(ctx)
16+ if err != nil {
17+ return 0, fmt.Errorf("failed to list apps: %w", err)
18+ }
19+ var total int64
20+ for _, name := range names {
21+ intent, err := state.LoadIntent(ctx, name)
22+ if err != nil {
23+ return 0, fmt.Errorf("failed to load intent for app %q: %w", name, err)
24+ }
25+ if intent.Stopped {
26+ continue
27+ }
28+ active, ok, err := state.LoadActive(ctx, name)
29+ if err != nil {
30+ return 0, fmt.Errorf("failed to load active state for app %q: %w", name, err)
31+ }
32+ if !ok {
33+ continue
34+ }
35+ for _, svc := range active.Services {
36+ if svc.Container != "" {
37+ total++
38+ }
39+ }
40+ }
41+ return total, nil
42+}
A internal/usecase/apps/managed_count_test.go +92 -0
@@ -0,0 +1,92 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/internal/usecase/apps"
13+)
14+
15+// fakeAppStateReader is an in-memory AppStateReader for lifecycle scenarios.
16+type fakeAppStateReader struct {
17+ active map[string]domain.AppActive
18+ stopped map[string]bool
19+ listErr error
20+}
21+
22+func newFakeAppStateReader() *fakeAppStateReader {
23+ return &fakeAppStateReader{active: map[string]domain.AppActive{}, stopped: map[string]bool{}}
24+}
25+
26+func (f *fakeAppStateReader) ListApps(context.Context) ([]string, error) {
27+ if f.listErr != nil {
28+ return nil, f.listErr
29+ }
30+ names := make([]string, 0, len(f.active))
31+ for name := range f.active {
32+ names = append(names, name)
33+ }
34+ return names, nil
35+}
36+
37+func (f *fakeAppStateReader) LoadActive(_ context.Context, app string) (domain.AppActive, bool, error) {
38+ a, ok := f.active[app]
39+ return a, ok, nil
40+}
41+
42+func (f *fakeAppStateReader) LoadIntent(_ context.Context, app string) (domain.AppStopIntent, error) {
43+ return domain.AppStopIntent{App: app, Stopped: f.stopped[app]}, nil
44+}
45+
46+func activeWith(app string, containers ...string) domain.AppActive {
47+ services := map[string]domain.AppEffectiveService{}
48+ for i, c := range containers {
49+ services[string(rune('a'+i))] = domain.AppEffectiveService{Container: c}
50+ }
51+ return domain.AppActive{App: app, Services: services}
52+}
53+
54+func TestCountManagedContainers_Lifecycle(t *testing.T) {
55+ ctx := context.Background()
56+ state := newFakeAppStateReader()
57+ count := func() int64 {
58+ t.Helper()
59+ n, err := apps.CountManagedContainers(ctx, state)
60+ require.NoError(t, err)
61+ return n
62+ }
63+
64+ // Boot with no apps.
65+ assert.Equal(t, int64(0), count())
66+
67+ // Boot hydration: persisted ACTIVE state is counted immediately.
68+ state.active["web"] = activeWith("web", "c1", "c2")
69+ state.active["db"] = activeWith("db", "c3")
70+ assert.Equal(t, int64(3), count())
71+
72+ // Failed deploy of a new service leaves it without a container.
73+ state.active["web"] = activeWith("web", "c1", "c2", "")
74+ assert.Equal(t, int64(3), count())
75+
76+ // Stop excludes the app; start restores it.
77+ state.stopped["web"] = true
78+ assert.Equal(t, int64(1), count())
79+ state.stopped["web"] = false
80+ assert.Equal(t, int64(3), count())
81+
82+ // Remove retires the app state.
83+ delete(state.active, "db")
84+ assert.Equal(t, int64(2), count())
85+}
86+
87+func TestCountManagedContainers_Error(t *testing.T) {
88+ state := newFakeAppStateReader()
89+ state.listErr = errors.New("boom")
90+ _, err := apps.CountManagedContainers(context.Background(), state)
91+ require.ErrorContains(t, err, "boom")
92+}
@@ -0,0 +1,92 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/internal/usecase/apps"
13+)
14+
15+// fakeAppStateReader is an in-memory AppStateReader for lifecycle scenarios.
16+type fakeAppStateReader struct {
17+ active map[string]domain.AppActive
18+ stopped map[string]bool
19+ listErr error
20+}
21+
22+func newFakeAppStateReader() *fakeAppStateReader {
23+ return &fakeAppStateReader{active: map[string]domain.AppActive{}, stopped: map[string]bool{}}
24+}
25+
26+func (f *fakeAppStateReader) ListApps(context.Context) ([]string, error) {
27+ if f.listErr != nil {
28+ return nil, f.listErr
29+ }
30+ names := make([]string, 0, len(f.active))
31+ for name := range f.active {
32+ names = append(names, name)
33+ }
34+ return names, nil
35+}
36+
37+func (f *fakeAppStateReader) LoadActive(_ context.Context, app string) (domain.AppActive, bool, error) {
38+ a, ok := f.active[app]
39+ return a, ok, nil
40+}
41+
42+func (f *fakeAppStateReader) LoadIntent(_ context.Context, app string) (domain.AppStopIntent, error) {
43+ return domain.AppStopIntent{App: app, Stopped: f.stopped[app]}, nil
44+}
45+
46+func activeWith(app string, containers ...string) domain.AppActive {
47+ services := map[string]domain.AppEffectiveService{}
48+ for i, c := range containers {
49+ services[string(rune('a'+i))] = domain.AppEffectiveService{Container: c}
50+ }
51+ return domain.AppActive{App: app, Services: services}
52+}
53+
54+func TestCountManagedContainers_Lifecycle(t *testing.T) {
55+ ctx := context.Background()
56+ state := newFakeAppStateReader()
57+ count := func() int64 {
58+ t.Helper()
59+ n, err := apps.CountManagedContainers(ctx, state)
60+ require.NoError(t, err)
61+ return n
62+ }
63+
64+ // Boot with no apps.
65+ assert.Equal(t, int64(0), count())
66+
67+ // Boot hydration: persisted ACTIVE state is counted immediately.
68+ state.active["web"] = activeWith("web", "c1", "c2")
69+ state.active["db"] = activeWith("db", "c3")
70+ assert.Equal(t, int64(3), count())
71+
72+ // Failed deploy of a new service leaves it without a container.
73+ state.active["web"] = activeWith("web", "c1", "c2", "")
74+ assert.Equal(t, int64(3), count())
75+
76+ // Stop excludes the app; start restores it.
77+ state.stopped["web"] = true
78+ assert.Equal(t, int64(1), count())
79+ state.stopped["web"] = false
80+ assert.Equal(t, int64(3), count())
81+
82+ // Remove retires the app state.
83+ delete(state.active, "db")
84+ assert.Equal(t, int64(2), count())
85+}
86+
87+func TestCountManagedContainers_Error(t *testing.T) {
88+ state := newFakeAppStateReader()
89+ state.listErr = errors.New("boom")
90+ _, err := apps.CountManagedContainers(context.Background(), state)
91+ require.ErrorContains(t, err, "boom")
92+}

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