mirror of
https://github.com/docker/compose.git
synced 2026-08-28 12:23:49 +00:00
Introduce ObservedState + populate from inspected resources
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
parent
5d67ce6dfe
commit
34693bd14d
2 changed files with 384 additions and 0 deletions
181
pkg/compose/observed_state.go
Normal file
181
pkg/compose/observed_state.go
Normal 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,
|
||||
}
|
||||
}
|
||||
203
pkg/compose/observed_state_test.go
Normal file
203
pkg/compose/observed_state_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue