Skip to content

Commit b8ba1da

Browse files
committed
Introduce ObservedState + populate from inspected resources
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent df73026 commit b8ba1da

File tree

2 files changed

+384
-0
lines changed

2 files changed

+384
-0
lines changed

pkg/compose/observed_state.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"context"
21+
"strconv"
22+
23+
"github.com/compose-spec/compose-go/v2/types"
24+
"github.com/moby/moby/api/types/container"
25+
"github.com/moby/moby/client"
26+
27+
"github.com/docker/compose/v5/pkg/api"
28+
)
29+
30+
// ObservedState captures the current state of all Docker resources belonging to
31+
// a Compose project. It is a snapshot taken before reconciliation so that the
32+
// reconciler can compare desired state (types.Project) with reality without
33+
// making any further API calls.
34+
type ObservedState struct {
35+
ProjectName string
36+
Containers map[string][]ObservedContainer // service name → containers
37+
Orphans []ObservedContainer // containers with no matching service
38+
Networks map[string]ObservedNetwork // compose network key → observed
39+
Volumes map[string]ObservedVolume // compose volume key → observed
40+
}
41+
42+
// ObservedContainer holds the relevant state extracted from a running or stopped
43+
// container, with label values pre-parsed for efficient comparison.
44+
type ObservedContainer struct {
45+
ID string
46+
Name string
47+
State container.ContainerState // "running", "exited", "created", "restarting", etc.
48+
ConfigHash string // label com.docker.compose.config-hash
49+
ImageDigest string // label com.docker.compose.image
50+
Number int // label com.docker.compose.container-number
51+
52+
// ConnectedNetworks maps network IDs found in the container's network
53+
// settings. Key is the network name as seen by Docker, value is the
54+
// network ID.
55+
ConnectedNetworks map[string]string
56+
57+
// Raw summary kept for the executor which needs it to call Moby APIs.
58+
Summary container.Summary
59+
}
60+
61+
// ObservedNetwork holds the state of a Docker network that belongs to the
62+
// project, identified by the com.docker.compose.network label.
63+
type ObservedNetwork struct {
64+
ID string
65+
Name string
66+
ConfigHash string // label com.docker.compose.config-hash
67+
ProjectName string // label com.docker.compose.project
68+
}
69+
70+
// ObservedVolume holds the state of a Docker volume that belongs to the
71+
// project, identified by the com.docker.compose.volume label.
72+
type ObservedVolume struct {
73+
Name string
74+
ConfigHash string // label com.docker.compose.config-hash
75+
ProjectName string // label com.docker.compose.project
76+
Driver string
77+
}
78+
79+
// collectObservedState queries the Docker daemon for all resources belonging to
80+
// the given project and returns a structured snapshot.
81+
// The project model is used to classify containers by service and to identify
82+
// orphans, and to scope network/volume queries to declared resources.
83+
func (s *composeService) collectObservedState(ctx context.Context, project *types.Project) (*ObservedState, error) {
84+
state := &ObservedState{
85+
ProjectName: project.Name,
86+
Containers: map[string][]ObservedContainer{},
87+
Networks: map[string]ObservedNetwork{},
88+
Volumes: map[string]ObservedVolume{},
89+
}
90+
91+
// --- Containers ---
92+
raw, err := s.getContainers(ctx, project.Name, oneOffExclude, true)
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
knownServices := map[string]bool{}
98+
for _, svc := range project.Services {
99+
knownServices[svc.Name] = true
100+
state.Containers[svc.Name] = nil // ensure key exists even if empty
101+
}
102+
for _, ds := range project.DisabledServices {
103+
knownServices[ds.Name] = true
104+
}
105+
106+
for _, c := range raw.filter(isNotOneOff) {
107+
oc := toObservedContainer(c)
108+
svcName := c.Labels[api.ServiceLabel]
109+
if knownServices[svcName] {
110+
state.Containers[svcName] = append(state.Containers[svcName], oc)
111+
} else {
112+
state.Orphans = append(state.Orphans, oc)
113+
}
114+
}
115+
116+
// --- Networks ---
117+
nwList, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{
118+
Filters: projectFilter(project.Name),
119+
})
120+
if err != nil {
121+
return nil, err
122+
}
123+
for _, nw := range nwList.Items {
124+
key := nw.Labels[api.NetworkLabel]
125+
if key == "" {
126+
continue
127+
}
128+
state.Networks[key] = ObservedNetwork{
129+
ID: nw.ID,
130+
Name: nw.Name,
131+
ConfigHash: nw.Labels[api.ConfigHashLabel],
132+
ProjectName: nw.Labels[api.ProjectLabel],
133+
}
134+
}
135+
136+
// --- Volumes ---
137+
volList, err := s.apiClient().VolumeList(ctx, client.VolumeListOptions{
138+
Filters: projectFilter(project.Name),
139+
})
140+
if err != nil {
141+
return nil, err
142+
}
143+
for _, vol := range volList.Items {
144+
key := vol.Labels[api.VolumeLabel]
145+
if key == "" {
146+
continue
147+
}
148+
state.Volumes[key] = ObservedVolume{
149+
Name: vol.Name,
150+
ConfigHash: vol.Labels[api.ConfigHashLabel],
151+
ProjectName: vol.Labels[api.ProjectLabel],
152+
Driver: vol.Driver,
153+
}
154+
}
155+
156+
return state, nil
157+
}
158+
159+
// toObservedContainer extracts the relevant fields from a container.Summary,
160+
// parsing labels into typed values.
161+
func toObservedContainer(c container.Summary) ObservedContainer {
162+
number, _ := strconv.Atoi(c.Labels[api.ContainerNumberLabel])
163+
164+
networks := map[string]string{}
165+
if c.NetworkSettings != nil {
166+
for name, settings := range c.NetworkSettings.Networks {
167+
networks[name] = settings.NetworkID
168+
}
169+
}
170+
171+
return ObservedContainer{
172+
ID: c.ID,
173+
Name: getCanonicalContainerName(c),
174+
State: c.State,
175+
ConfigHash: c.Labels[api.ConfigHashLabel],
176+
ImageDigest: c.Labels[api.ImageDigestLabel],
177+
Number: number,
178+
ConnectedNetworks: networks,
179+
Summary: c,
180+
}
181+
}

pkg/compose/observed_state_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"testing"
21+
22+
"github.com/compose-spec/compose-go/v2/types"
23+
"github.com/moby/moby/api/types/container"
24+
"github.com/moby/moby/api/types/network"
25+
"github.com/moby/moby/api/types/volume"
26+
"github.com/moby/moby/client"
27+
"go.uber.org/mock/gomock"
28+
"gotest.tools/v3/assert"
29+
30+
"github.com/docker/compose/v5/pkg/api"
31+
"github.com/docker/compose/v5/pkg/mocks"
32+
)
33+
34+
func TestToObservedContainer(t *testing.T) {
35+
c := container.Summary{
36+
ID: "abc123",
37+
Names: []string{"/testProject-web-1"},
38+
State: container.StateRunning,
39+
Labels: map[string]string{
40+
api.ServiceLabel: "web",
41+
api.ConfigHashLabel: "sha256:aaa",
42+
api.ImageDigestLabel: "sha256:bbb",
43+
api.ContainerNumberLabel: "1",
44+
api.ProjectLabel: "testproject",
45+
},
46+
NetworkSettings: &container.NetworkSettingsSummary{
47+
Networks: map[string]*network.EndpointSettings{
48+
"mynet": {NetworkID: "net123"},
49+
},
50+
},
51+
}
52+
53+
oc := toObservedContainer(c)
54+
55+
assert.Equal(t, oc.ID, "abc123")
56+
assert.Equal(t, oc.Name, "testProject-web-1")
57+
assert.Equal(t, oc.State, container.StateRunning)
58+
assert.Equal(t, oc.ConfigHash, "sha256:aaa")
59+
assert.Equal(t, oc.ImageDigest, "sha256:bbb")
60+
assert.Equal(t, oc.Number, 1)
61+
assert.Equal(t, oc.ConnectedNetworks["mynet"], "net123")
62+
assert.Equal(t, oc.Summary.ID, "abc123")
63+
}
64+
65+
func TestToObservedContainerNoNetworkSettings(t *testing.T) {
66+
c := container.Summary{
67+
ID: "def456",
68+
Names: []string{"/testProject-db-1"},
69+
State: container.StateExited,
70+
Labels: map[string]string{},
71+
}
72+
73+
oc := toObservedContainer(c)
74+
75+
assert.Equal(t, oc.ID, "def456")
76+
assert.Equal(t, oc.Number, 0)
77+
assert.Equal(t, oc.ConfigHash, "")
78+
assert.Equal(t, oc.ImageDigest, "")
79+
assert.Equal(t, len(oc.ConnectedNetworks), 0)
80+
}
81+
82+
func TestCollectObservedState(t *testing.T) {
83+
mockCtrl := gomock.NewController(t)
84+
85+
apiClient := mocks.NewMockAPIClient(mockCtrl)
86+
cli := mocks.NewMockCli(mockCtrl)
87+
tested, err := NewComposeService(cli)
88+
assert.NilError(t, err)
89+
cli.EXPECT().Client().Return(apiClient).AnyTimes()
90+
91+
project := &types.Project{
92+
Name: "myproject",
93+
Services: types.Services{
94+
"web": {Name: "web"},
95+
"db": {Name: "db"},
96+
},
97+
Networks: types.Networks{
98+
"frontend": {Name: "myproject_frontend"},
99+
},
100+
Volumes: types.Volumes{
101+
"data": {Name: "myproject_data"},
102+
},
103+
}
104+
105+
// Mock ContainerList
106+
apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{
107+
Items: []container.Summary{
108+
{
109+
ID: "c1",
110+
Names: []string{"/myproject-web-1"},
111+
State: container.StateRunning,
112+
Labels: map[string]string{
113+
api.ServiceLabel: "web",
114+
api.ProjectLabel: "myproject",
115+
api.ConfigHashLabel: "hash1",
116+
api.ContainerNumberLabel: "1",
117+
api.OneoffLabel: "False",
118+
},
119+
},
120+
{
121+
ID: "c2",
122+
Names: []string{"/myproject-db-1"},
123+
State: container.StateRunning,
124+
Labels: map[string]string{
125+
api.ServiceLabel: "db",
126+
api.ProjectLabel: "myproject",
127+
api.ConfigHashLabel: "hash2",
128+
api.ContainerNumberLabel: "1",
129+
api.OneoffLabel: "False",
130+
},
131+
},
132+
{
133+
ID: "c3",
134+
Names: []string{"/myproject-old-1"},
135+
State: container.StateExited,
136+
Labels: map[string]string{
137+
api.ServiceLabel: "old",
138+
api.ProjectLabel: "myproject",
139+
api.ConfigHashLabel: "hash3",
140+
api.ContainerNumberLabel: "1",
141+
api.OneoffLabel: "False",
142+
},
143+
},
144+
},
145+
}, nil)
146+
147+
// Mock NetworkList
148+
apiClient.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
149+
Items: []network.Summary{
150+
{Network: network.Network{
151+
ID: "net1",
152+
Name: "myproject_frontend",
153+
Labels: map[string]string{
154+
api.NetworkLabel: "frontend",
155+
api.ProjectLabel: "myproject",
156+
api.ConfigHashLabel: "nethash1",
157+
},
158+
}},
159+
},
160+
}, nil)
161+
162+
// Mock VolumeList
163+
apiClient.EXPECT().VolumeList(gomock.Any(), gomock.Any()).Return(client.VolumeListResult{
164+
Items: []volume.Volume{
165+
{
166+
Name: "myproject_data",
167+
Driver: "local",
168+
Labels: map[string]string{
169+
api.VolumeLabel: "data",
170+
api.ProjectLabel: "myproject",
171+
api.ConfigHashLabel: "volhash1",
172+
},
173+
},
174+
},
175+
}, nil)
176+
177+
state, err := tested.(*composeService).collectObservedState(t.Context(), project)
178+
assert.NilError(t, err)
179+
180+
// Containers classified by service
181+
assert.Equal(t, len(state.Containers["web"]), 1)
182+
assert.Equal(t, state.Containers["web"][0].ID, "c1")
183+
assert.Equal(t, len(state.Containers["db"]), 1)
184+
assert.Equal(t, state.Containers["db"][0].ID, "c2")
185+
186+
// Orphan container (service "old" not in project)
187+
assert.Equal(t, len(state.Orphans), 1)
188+
assert.Equal(t, state.Orphans[0].ID, "c3")
189+
190+
// Networks
191+
assert.Equal(t, len(state.Networks), 1)
192+
nw := state.Networks["frontend"]
193+
assert.Equal(t, nw.ID, "net1")
194+
assert.Equal(t, nw.Name, "myproject_frontend")
195+
assert.Equal(t, nw.ConfigHash, "nethash1")
196+
197+
// Volumes
198+
assert.Equal(t, len(state.Volumes), 1)
199+
vol := state.Volumes["data"]
200+
assert.Equal(t, vol.Name, "myproject_data")
201+
assert.Equal(t, vol.Driver, "local")
202+
assert.Equal(t, vol.ConfigHash, "volhash1")
203+
}

0 commit comments

Comments
 (0)