Introduce ObservedState + populate from inspected resources

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-04-19 16:36:45 +02:00 committed by Guillaume Lours
parent 5d67ce6dfe
commit 34693bd14d
2 changed files with 384 additions and 0 deletions

View file

@ -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,
}
}

View file

@ -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")
}