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 Unverified
B5690EEEBB952194The key is unknown
6 files · +240 -8
M docs/config/telemetry.md +2 -2
| @@ -147,9 +147,9 @@ Attributes: `domain`, `image` | |||
| 147 | 147 | |--------|------|------|-------------| | |
| 148 | 148 | | `gordon.container.restarts` | Counter | - | Container restart count | | |
| 149 | 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 attributes | |
| 152 | + | 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 | 154 | ### Registry | |
| 155 | 155 | ||
| @@ -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 attributes | 152 | + | 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 | ### Registry | 154 | ### Registry | ||
| 155 | 155 | ||||
M internal/adapters/out/telemetry/metrics.go +21 -6
| @@ -15,6 +15,8 @@ var _ out.Metrics = (*Metrics)(nil) | |||
| 15 | 15 | ||
| 16 | 16 | // Metrics holds Gordon-specific OTel metrics instruments. | |
| 17 | 17 | type Metrics struct { | |
| 18 | + | meter metric.Meter | |
| 19 | + | ||
| 18 | 20 | // Deployments | |
| 19 | 21 | DeployTotal metric.Int64Counter | |
| 20 | 22 | DeployDuration metric.Float64Histogram | |
| @@ -23,7 +25,6 @@ type Metrics struct { | |||
| 23 | 25 | // Container lifecycle | |
| 24 | 26 | ContainerRestarts metric.Int64Counter | |
| 25 | 27 | ContainerCrashLoops metric.Int64Counter | |
| 26 | - | ManagedContainers metric.Int64UpDownCounter | |
| 27 | 28 | ||
| 28 | 29 | // Registry | |
| 29 | 30 | ImagePushTotal metric.Int64Counter | |
| @@ -39,7 +40,7 @@ type Metrics struct { | |||
| 39 | 40 | // (OTel returns noop instruments when no MeterProvider is set). | |
| 40 | 41 | func NewMetrics() (*Metrics, error) { | |
| 41 | 42 | meter := otel.Meter("gordon") | |
| 42 | - | m := &Metrics{} | |
| 43 | + | m := &Metrics{meter: meter} | |
| 43 | 44 | var err error | |
| 44 | 45 | ||
| 45 | 46 | if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total", | |
| @@ -64,10 +65,6 @@ func NewMetrics() (*Metrics, error) { | |||
| 64 | 65 | metric.WithDescription("Total crash loop detections")); err != nil { | |
| 65 | 66 | 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 | 68 | if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total", | |
| 72 | 69 | metric.WithDescription("Total image pushes")); err != nil { | |
| 73 | 70 | return nil, err | |
| @@ -89,6 +86,24 @@ func NewMetrics() (*Metrics, error) { | |||
| 89 | 86 | 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 | 107 | // RecordImagePush implements out.Metrics. | |
| 93 | 108 | func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) { | |
| 94 | 109 | 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 | // Deployments | 20 | // Deployments | ||
| 19 | DeployTotal metric.Int64Counter | 21 | DeployTotal metric.Int64Counter | ||
| 20 | DeployDuration metric.Float64Histogram | 22 | DeployDuration metric.Float64Histogram | ||
| @@ -23,7 +25,6 @@ type Metrics struct { | |||||
| 23 | // Container lifecycle | 25 | // Container lifecycle | ||
| 24 | ContainerRestarts metric.Int64Counter | 26 | ContainerRestarts metric.Int64Counter | ||
| 25 | ContainerCrashLoops metric.Int64Counter | 27 | ContainerCrashLoops metric.Int64Counter | ||
| 26 | - | ManagedContainers metric.Int64UpDownCounter | |||
| 27 | 28 | ||||
| 28 | // Registry | 29 | // Registry | ||
| 29 | ImagePushTotal metric.Int64Counter | 30 | 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 error | 44 | 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, err | 66 | 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, err | 70 | return nil, err | ||
| @@ -89,6 +86,24 @@ func NewMetrics() (*Metrics, error) { | |||||
| 89 | return m, nil | 86 | 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 { | |||
| 292 | 292 | type services struct { | |
| 293 | 293 | runtime *docker.Runtime | |
| 294 | 294 | eventBus *eventbus.InMemory | |
| 295 | + | metrics *telemetry.Metrics | |
| 295 | 296 | blobStorage *filesystem.BlobStorage | |
| 296 | 297 | manifestStorage *filesystem.ManifestStorage | |
| 297 | 298 | backupStorage *filesystem.BackupStorage | |
| @@ -888,6 +889,7 @@ func (si *serviceInit) initApps() error { | |||
| 888 | 889 | } | |
| 889 | 890 | store.WithRevisionRetention(si.cfg.Apps.RevisionRetention) | |
| 890 | 891 | si.svc.appState = store | |
| 892 | + | observeManagedContainers(si.svc.metrics, store, si.log) | |
| 891 | 893 | // One process-wide GC barrier owns the ordering between resource | |
| 892 | 894 | // acquisition/publication and destructive prune. | |
| 893 | 895 | si.svc.gcBarrier = newGCBarrier() | |
| @@ -1206,6 +1208,20 @@ func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | |||
| 1206 | 1208 | } | |
| 1207 | 1209 | svc.registrySvc.SetMetrics(gordonMetrics) | |
| 1208 | 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 | 1227 | 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.Runtime | 293 | runtime *docker.Runtime | ||
| 294 | eventBus *eventbus.InMemory | 294 | eventBus *eventbus.InMemory | ||
| 295 | + | metrics *telemetry.Metrics | |||
| 295 | blobStorage *filesystem.BlobStorage | 296 | blobStorage *filesystem.BlobStorage | ||
| 296 | manifestStorage *filesystem.ManifestStorage | 297 | manifestStorage *filesystem.ManifestStorage | ||
| 297 | backupStorage *filesystem.BackupStorage | 298 | 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 = store | 891 | si.svc.appState = store | ||
| 892 | + | observeManagedContainers(si.svc.metrics, store, si.log) | |||
| 891 | // One process-wide GC barrier owns the ordering between resource | 893 | // 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 | + | } | |||