From 34693bd14d6146c58e36f6b2f3f6f26944e156c8 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Sun, 19 Apr 2026 16:36:45 +0200 Subject: [PATCH] Introduce ObservedState + populate from inspected resources Signed-off-by: Nicolas De Loof --- pkg/compose/observed_state.go | 181 +++++++++++++++++++++++++ pkg/compose/observed_state_test.go | 203 +++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 pkg/compose/observed_state.go create mode 100644 pkg/compose/observed_state_test.go diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go new file mode 100644 index 000000000..661744203 --- /dev/null +++ b/pkg/compose/observed_state.go @@ -0,0 +1,181 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + "strconv" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + + "github.com/docker/compose/v5/pkg/api" +) + +// ObservedState captures the current state of all Docker resources belonging to +// a Compose project. It is a snapshot taken before reconciliation so that the +// reconciler can compare desired state (types.Project) with reality without +// making any further API calls. +type ObservedState struct { + ProjectName string + Containers map[string][]ObservedContainer // service name → containers + Orphans []ObservedContainer // containers with no matching service + Networks map[string]ObservedNetwork // compose network key → observed + Volumes map[string]ObservedVolume // compose volume key → observed +} + +// ObservedContainer holds the relevant state extracted from a running or stopped +// container, with label values pre-parsed for efficient comparison. +type ObservedContainer struct { + ID string + Name string + State container.ContainerState // "running", "exited", "created", "restarting", etc. + ConfigHash string // label com.docker.compose.config-hash + ImageDigest string // label com.docker.compose.image + Number int // label com.docker.compose.container-number + + // ConnectedNetworks maps network IDs found in the container's network + // settings. Key is the network name as seen by Docker, value is the + // network ID. + ConnectedNetworks map[string]string + + // Raw summary kept for the executor which needs it to call Moby APIs. + Summary container.Summary +} + +// ObservedNetwork holds the state of a Docker network that belongs to the +// project, identified by the com.docker.compose.network label. +type ObservedNetwork struct { + ID string + Name string + ConfigHash string // label com.docker.compose.config-hash + ProjectName string // label com.docker.compose.project +} + +// ObservedVolume holds the state of a Docker volume that belongs to the +// project, identified by the com.docker.compose.volume label. +type ObservedVolume struct { + Name string + ConfigHash string // label com.docker.compose.config-hash + ProjectName string // label com.docker.compose.project + Driver string +} + +// collectObservedState queries the Docker daemon for all resources belonging to +// the given project and returns a structured snapshot. +// The project model is used to classify containers by service and to identify +// orphans, and to scope network/volume queries to declared resources. +func (s *composeService) collectObservedState(ctx context.Context, project *types.Project) (*ObservedState, error) { + state := &ObservedState{ + ProjectName: project.Name, + Containers: map[string][]ObservedContainer{}, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{}, + } + + // --- Containers --- + raw, err := s.getContainers(ctx, project.Name, oneOffExclude, true) + if err != nil { + return nil, err + } + + knownServices := map[string]bool{} + for _, svc := range project.Services { + knownServices[svc.Name] = true + state.Containers[svc.Name] = nil // ensure key exists even if empty + } + for _, ds := range project.DisabledServices { + knownServices[ds.Name] = true + } + + for _, c := range raw.filter(isNotOneOff) { + oc := toObservedContainer(c) + svcName := c.Labels[api.ServiceLabel] + if knownServices[svcName] { + state.Containers[svcName] = append(state.Containers[svcName], oc) + } else { + state.Orphans = append(state.Orphans, oc) + } + } + + // --- Networks --- + nwList, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{ + Filters: projectFilter(project.Name), + }) + if err != nil { + return nil, err + } + for _, nw := range nwList.Items { + key := nw.Labels[api.NetworkLabel] + if key == "" { + continue + } + state.Networks[key] = ObservedNetwork{ + ID: nw.ID, + Name: nw.Name, + ConfigHash: nw.Labels[api.ConfigHashLabel], + ProjectName: nw.Labels[api.ProjectLabel], + } + } + + // --- Volumes --- + volList, err := s.apiClient().VolumeList(ctx, client.VolumeListOptions{ + Filters: projectFilter(project.Name), + }) + if err != nil { + return nil, err + } + for _, vol := range volList.Items { + key := vol.Labels[api.VolumeLabel] + if key == "" { + continue + } + state.Volumes[key] = ObservedVolume{ + Name: vol.Name, + ConfigHash: vol.Labels[api.ConfigHashLabel], + ProjectName: vol.Labels[api.ProjectLabel], + Driver: vol.Driver, + } + } + + return state, nil +} + +// toObservedContainer extracts the relevant fields from a container.Summary, +// parsing labels into typed values. +func toObservedContainer(c container.Summary) ObservedContainer { + number, _ := strconv.Atoi(c.Labels[api.ContainerNumberLabel]) + + networks := map[string]string{} + if c.NetworkSettings != nil { + for name, settings := range c.NetworkSettings.Networks { + networks[name] = settings.NetworkID + } + } + + return ObservedContainer{ + ID: c.ID, + Name: getCanonicalContainerName(c), + State: c.State, + ConfigHash: c.Labels[api.ConfigHashLabel], + ImageDigest: c.Labels[api.ImageDigestLabel], + Number: number, + ConnectedNetworks: networks, + Summary: c, + } +} diff --git a/pkg/compose/observed_state_test.go b/pkg/compose/observed_state_test.go new file mode 100644 index 000000000..d75b97eac --- /dev/null +++ b/pkg/compose/observed_state_test.go @@ -0,0 +1,203 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/api/types/volume" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" +) + +func TestToObservedContainer(t *testing.T) { + c := container.Summary{ + ID: "abc123", + Names: []string{"/testProject-web-1"}, + State: container.StateRunning, + Labels: map[string]string{ + api.ServiceLabel: "web", + api.ConfigHashLabel: "sha256:aaa", + api.ImageDigestLabel: "sha256:bbb", + api.ContainerNumberLabel: "1", + api.ProjectLabel: "testproject", + }, + NetworkSettings: &container.NetworkSettingsSummary{ + Networks: map[string]*network.EndpointSettings{ + "mynet": {NetworkID: "net123"}, + }, + }, + } + + oc := toObservedContainer(c) + + assert.Equal(t, oc.ID, "abc123") + assert.Equal(t, oc.Name, "testProject-web-1") + assert.Equal(t, oc.State, container.StateRunning) + assert.Equal(t, oc.ConfigHash, "sha256:aaa") + assert.Equal(t, oc.ImageDigest, "sha256:bbb") + assert.Equal(t, oc.Number, 1) + assert.Equal(t, oc.ConnectedNetworks["mynet"], "net123") + assert.Equal(t, oc.Summary.ID, "abc123") +} + +func TestToObservedContainerNoNetworkSettings(t *testing.T) { + c := container.Summary{ + ID: "def456", + Names: []string{"/testProject-db-1"}, + State: container.StateExited, + Labels: map[string]string{}, + } + + oc := toObservedContainer(c) + + assert.Equal(t, oc.ID, "def456") + assert.Equal(t, oc.Number, 0) + assert.Equal(t, oc.ConfigHash, "") + assert.Equal(t, oc.ImageDigest, "") + assert.Equal(t, len(oc.ConnectedNetworks), 0) +} + +func TestCollectObservedState(t *testing.T) { + mockCtrl := gomock.NewController(t) + + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + + project := &types.Project{ + Name: "myproject", + Services: types.Services{ + "web": {Name: "web"}, + "db": {Name: "db"}, + }, + Networks: types.Networks{ + "frontend": {Name: "myproject_frontend"}, + }, + Volumes: types.Volumes{ + "data": {Name: "myproject_data"}, + }, + } + + // Mock ContainerList + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{ + Items: []container.Summary{ + { + ID: "c1", + Names: []string{"/myproject-web-1"}, + State: container.StateRunning, + Labels: map[string]string{ + api.ServiceLabel: "web", + api.ProjectLabel: "myproject", + api.ConfigHashLabel: "hash1", + api.ContainerNumberLabel: "1", + api.OneoffLabel: "False", + }, + }, + { + ID: "c2", + Names: []string{"/myproject-db-1"}, + State: container.StateRunning, + Labels: map[string]string{ + api.ServiceLabel: "db", + api.ProjectLabel: "myproject", + api.ConfigHashLabel: "hash2", + api.ContainerNumberLabel: "1", + api.OneoffLabel: "False", + }, + }, + { + ID: "c3", + Names: []string{"/myproject-old-1"}, + State: container.StateExited, + Labels: map[string]string{ + api.ServiceLabel: "old", + api.ProjectLabel: "myproject", + api.ConfigHashLabel: "hash3", + api.ContainerNumberLabel: "1", + api.OneoffLabel: "False", + }, + }, + }, + }, nil) + + // Mock NetworkList + apiClient.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{ + Items: []network.Summary{ + {Network: network.Network{ + ID: "net1", + Name: "myproject_frontend", + Labels: map[string]string{ + api.NetworkLabel: "frontend", + api.ProjectLabel: "myproject", + api.ConfigHashLabel: "nethash1", + }, + }}, + }, + }, nil) + + // Mock VolumeList + apiClient.EXPECT().VolumeList(gomock.Any(), gomock.Any()).Return(client.VolumeListResult{ + Items: []volume.Volume{ + { + Name: "myproject_data", + Driver: "local", + Labels: map[string]string{ + api.VolumeLabel: "data", + api.ProjectLabel: "myproject", + api.ConfigHashLabel: "volhash1", + }, + }, + }, + }, nil) + + state, err := tested.(*composeService).collectObservedState(t.Context(), project) + assert.NilError(t, err) + + // Containers classified by service + assert.Equal(t, len(state.Containers["web"]), 1) + assert.Equal(t, state.Containers["web"][0].ID, "c1") + assert.Equal(t, len(state.Containers["db"]), 1) + assert.Equal(t, state.Containers["db"][0].ID, "c2") + + // Orphan container (service "old" not in project) + assert.Equal(t, len(state.Orphans), 1) + assert.Equal(t, state.Orphans[0].ID, "c3") + + // Networks + assert.Equal(t, len(state.Networks), 1) + nw := state.Networks["frontend"] + assert.Equal(t, nw.ID, "net1") + assert.Equal(t, nw.Name, "myproject_frontend") + assert.Equal(t, nw.ConfigHash, "nethash1") + + // Volumes + assert.Equal(t, len(state.Volumes), 1) + vol := state.Volumes["data"] + assert.Equal(t, vol.Name, "myproject_data") + assert.Equal(t, vol.Driver, "local") + assert.Equal(t, vol.ConfigHash, "volhash1") +}