mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
chore: replace immutable hash with container spec comparison
This commit is contained in:
@@ -341,7 +341,7 @@ func (s *Server) CreateServiceContainer(
|
|||||||
if err := json.Unmarshal(req.ServiceSpec, &spec); err != nil {
|
if err := json.Unmarshal(req.ServiceSpec, &spec); err != nil {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal service spec: %v", err)
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal service spec: %v", err)
|
||||||
}
|
}
|
||||||
spec.ApplyDefaults()
|
spec = spec.SetDefaults()
|
||||||
if err := spec.Validate(); err != nil {
|
if err := spec.Validate(); err != nil {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid service spec: %v", err)
|
return nil, status.Errorf(codes.InvalidArgument, "invalid service spec: %v", err)
|
||||||
}
|
}
|
||||||
@@ -355,23 +355,16 @@ func (s *Server) CreateServiceContainer(
|
|||||||
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: do not set the immutable hash as container label once container diff uses the spec stored in DB.
|
|
||||||
specHash, err := spec.ImmutableHash()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("calculate immutable hash for service spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &container.Config{
|
config := &container.Config{
|
||||||
Cmd: spec.Container.Command,
|
Cmd: spec.Container.Command,
|
||||||
Entrypoint: spec.Container.Entrypoint,
|
Entrypoint: spec.Container.Entrypoint,
|
||||||
Hostname: containerName,
|
Hostname: containerName,
|
||||||
Image: spec.Container.Image,
|
Image: spec.Container.Image,
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
api.LabelServiceID: req.ServiceId,
|
api.LabelServiceID: req.ServiceId,
|
||||||
api.LabelServiceName: spec.Name,
|
api.LabelServiceName: spec.Name,
|
||||||
api.LabelServiceMode: spec.Mode,
|
api.LabelServiceMode: spec.Mode,
|
||||||
api.LabelServiceSpecHash: specHash,
|
api.LabelManaged: "",
|
||||||
api.LabelManaged: "",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if spec.Mode == "" {
|
if spec.Mode == "" {
|
||||||
@@ -379,6 +372,7 @@ func (s *Server) CreateServiceContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
||||||
|
var err error
|
||||||
if len(spec.Ports) > 0 {
|
if len(spec.Ports) > 0 {
|
||||||
encodedPorts := make([]string, len(spec.Ports))
|
encodedPorts := make([]string, len(spec.Ports))
|
||||||
for i, p := range spec.Ports {
|
for i, p := range spec.Ports {
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LabelManaged = "uncloud.managed"
|
LabelManaged = "uncloud.managed"
|
||||||
LabelServiceID = "uncloud.service.id"
|
LabelServiceID = "uncloud.service.id"
|
||||||
LabelServiceName = "uncloud.service.name"
|
LabelServiceName = "uncloud.service.name"
|
||||||
LabelServiceMode = "uncloud.service.mode"
|
LabelServiceMode = "uncloud.service.mode"
|
||||||
LabelServicePorts = "uncloud.service.ports"
|
LabelServicePorts = "uncloud.service.ports"
|
||||||
LabelServiceSpecHash = "uncloud.service.spec-hash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Container struct {
|
type Container struct {
|
||||||
|
|||||||
+33
-72
@@ -1,8 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -37,6 +35,8 @@ func ValidateServiceID(id string) bool {
|
|||||||
return serviceIDRegexp.MatchString(id)
|
return serviceIDRegexp.MatchString(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceSpec defines the desired state of a service.
|
||||||
|
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||||
type ServiceSpec struct {
|
type ServiceSpec struct {
|
||||||
Container ContainerSpec
|
Container ContainerSpec
|
||||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||||
@@ -48,18 +48,19 @@ type ServiceSpec struct {
|
|||||||
Replicas uint `json:",omitempty"`
|
Replicas uint `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceSpec) ApplyDefaults() {
|
func (s *ServiceSpec) SetDefaults() ServiceSpec {
|
||||||
if s.Mode == "" {
|
spec := s.Clone()
|
||||||
s.Mode = ServiceModeReplicated
|
|
||||||
|
if spec.Mode == "" {
|
||||||
|
spec.Mode = ServiceModeReplicated
|
||||||
}
|
}
|
||||||
// Ensure the replicated service has at least one replica.
|
// Ensure the replicated service has at least one replica.
|
||||||
if s.Mode == ServiceModeReplicated && s.Replicas == 0 {
|
if spec.Mode == ServiceModeReplicated && spec.Replicas == 0 {
|
||||||
s.Replicas = 1
|
spec.Replicas = 1
|
||||||
}
|
}
|
||||||
|
spec.Container = spec.Container.SetDefaults()
|
||||||
|
|
||||||
if s.Container.PullPolicy == "" {
|
return spec
|
||||||
s.Container.PullPolicy = PullPolicyMissing
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceSpec) Validate() error {
|
func (s *ServiceSpec) Validate() error {
|
||||||
@@ -87,68 +88,6 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImmutableHash returns a hash of the immutable parts of the ServiceSpec that require container recreation if changed.
|
|
||||||
func (s *ServiceSpec) ImmutableHash() (string, error) {
|
|
||||||
var err error
|
|
||||||
// Serialise and sort the ports to ensure the hash is consistent.
|
|
||||||
ports := make([]string, len(s.Ports))
|
|
||||||
for i, p := range s.Ports {
|
|
||||||
ports[i], err = p.String()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("encode service port spec: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(ports)
|
|
||||||
|
|
||||||
volumes := make([]string, 0, len(s.Container.Volumes))
|
|
||||||
volumes = append(volumes, s.Container.Volumes...)
|
|
||||||
slices.Sort(volumes)
|
|
||||||
|
|
||||||
hashSpec := immutableHashSpec{
|
|
||||||
Command: s.Container.Command,
|
|
||||||
Entrypoint: s.Container.Entrypoint,
|
|
||||||
Image: s.Container.Image,
|
|
||||||
Init: s.Container.Init,
|
|
||||||
Ports: ports,
|
|
||||||
Volumes: volumes,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(hashSpec)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal immutable hash spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasher := sha256.New()
|
|
||||||
if _, err = hasher.Write(data); err != nil {
|
|
||||||
return "", fmt.Errorf("write to SHA256 hasher: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// immutableHashSpec contains only the immutable fields from ServiceSpec that require container recreation if changed.
|
|
||||||
type immutableHashSpec struct {
|
|
||||||
Command []string `json:",omitempty"`
|
|
||||||
Entrypoint []string `json:",omitempty"`
|
|
||||||
Image string
|
|
||||||
Init *bool `json:",omitempty"`
|
|
||||||
// Ports are set as labels on the container which are immutable.
|
|
||||||
// TODO: store ingress ports in the cluster store instead of as labels which will allow changing them without
|
|
||||||
// recreating the container.
|
|
||||||
Ports []string `json:",omitempty"`
|
|
||||||
Volumes []string `json:",omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals returns true if the service spec is equal to the given spec ignoring the number of replicas.
|
|
||||||
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
|
|
||||||
// TODO: ignore order of ports.
|
|
||||||
sCopy := *s
|
|
||||||
// Ignore the number of replicas when comparing.
|
|
||||||
sCopy.Replicas = 0
|
|
||||||
spec.Replicas = 0
|
|
||||||
return reflect.DeepEqual(*s, spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServiceSpec) Clone() ServiceSpec {
|
func (s *ServiceSpec) Clone() ServiceSpec {
|
||||||
spec := *s
|
spec := *s
|
||||||
|
|
||||||
@@ -161,6 +100,8 @@ func (s *ServiceSpec) Clone() ServiceSpec {
|
|||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContainerSpec defines the desired state of a container in a service.
|
||||||
|
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||||
type ContainerSpec struct {
|
type ContainerSpec struct {
|
||||||
// Command overrides the default CMD of the image to be executed when running a container.
|
// Command overrides the default CMD of the image to be executed when running a container.
|
||||||
Command []string
|
Command []string
|
||||||
@@ -176,6 +117,16 @@ type ContainerSpec struct {
|
|||||||
Volumes []string
|
Volumes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaults returns a copy of the container spec with default values set.
|
||||||
|
func (s *ContainerSpec) SetDefaults() ContainerSpec {
|
||||||
|
spec := s.Clone()
|
||||||
|
if spec.PullPolicy == "" {
|
||||||
|
spec.PullPolicy = PullPolicyMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ContainerSpec) Validate() error {
|
func (s *ContainerSpec) Validate() error {
|
||||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||||
return fmt.Errorf("invalid image: %w", err)
|
return fmt.Errorf("invalid image: %w", err)
|
||||||
@@ -184,6 +135,16 @@ func (s *ContainerSpec) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ContainerSpec) Equals(spec ContainerSpec) bool {
|
||||||
|
orig := s.SetDefaults()
|
||||||
|
spec = spec.SetDefaults()
|
||||||
|
|
||||||
|
slices.Sort(orig.Volumes)
|
||||||
|
slices.Sort(spec.Volumes)
|
||||||
|
|
||||||
|
return reflect.DeepEqual(orig, spec)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ContainerSpec) Clone() ContainerSpec {
|
func (s *ContainerSpec) Clone() ContainerSpec {
|
||||||
spec := *s
|
spec := *s
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func (cli *Client) CreateContainer(
|
|||||||
) (container.CreateResponse, error) {
|
) (container.CreateResponse, error) {
|
||||||
var resp container.CreateResponse
|
var resp container.CreateResponse
|
||||||
|
|
||||||
spec.ApplyDefaults()
|
spec = spec.SetDefaults()
|
||||||
if err := spec.Validate(); err != nil {
|
if err := spec.Validate(); err != nil {
|
||||||
return resp, fmt.Errorf("invalid service spec: %w", err)
|
return resp, fmt.Errorf("invalid service spec: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,30 +10,30 @@ const ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
|||||||
const ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
const ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||||
const ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
const ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||||
|
|
||||||
func CompareContainerToSpec(ctr api.ServiceContainer, spec api.ServiceSpec) (ContainerSpecStatus, error) {
|
func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) ContainerSpecStatus {
|
||||||
// TODO: replace the hash comparison with a more detailed comparison of ctr.ServiceSpec and spec.
|
current = current.SetDefaults()
|
||||||
specHash, err := spec.ImmutableHash()
|
new = new.SetDefaults()
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("calculate immutable hash for service spec: %w", err)
|
// Pull policy doesn't affect the container configuration.
|
||||||
|
new.Container.PullPolicy = current.Container.PullPolicy
|
||||||
|
if !current.Container.Equals(new.Container) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is the hash label is unset, there is no easy way to compare its configuration with the spec,
|
if current.Mode != new.Mode {
|
||||||
// so let's recreate as well.
|
return ContainerNeedsRecreate
|
||||||
if ctr.Config.Labels[api.LabelServiceSpecHash] != specHash {
|
}
|
||||||
return ContainerNeedsRecreate, nil
|
if current.Name != new.Name {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: compare mutable properties such as memory or CPU limits when they are implemented.
|
// TODO: compare mutable properties such as memory or CPU limits when they are implemented.
|
||||||
|
|
||||||
// TODO: remove ports check when ports are stored in the local machine store instead of as labels.
|
// TODO: change ports check to ContainerNeedsUpdate when ingress ports are stored only the machine DB instead
|
||||||
ports, err := ctr.ServicePorts()
|
// of as labels ans synced to the cluster store. Host ports changes should be handled as ContainerNeedsRecreate.
|
||||||
if err != nil {
|
if !api.PortsEqual(current.Ports, new.Ports) {
|
||||||
return "", fmt.Errorf("get service ports: %w", err)
|
return ContainerNeedsRecreate
|
||||||
}
|
}
|
||||||
|
|
||||||
if !api.PortsEqual(ports, spec.Ports) {
|
return ContainerUpToDate
|
||||||
return ContainerNeedsRecreate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return ContainerUpToDate, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,7 @@ func (s *RollingStrategy) planReplicated(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := CompareContainerToSpec(c.Container, spec)
|
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||||
if err != nil {
|
|
||||||
return plan, fmt.Errorf("compare container to spec: %w", err)
|
|
||||||
}
|
|
||||||
containerSpecStatuses[c.Container.ID] = status
|
containerSpecStatuses[c.Container.ID] = status
|
||||||
|
|
||||||
if status == ContainerUpToDate {
|
if status == ContainerUpToDate {
|
||||||
@@ -294,10 +291,7 @@ func reconcileGlobalContainer(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := CompareContainerToSpec(c.Container, spec)
|
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("compare container to spec: %w", err)
|
|
||||||
}
|
|
||||||
if status == ContainerUpToDate {
|
if status == ContainerUpToDate {
|
||||||
// The container is already running with the same spec.
|
// The container is already running with the same spec.
|
||||||
upToDate = true
|
upToDate = true
|
||||||
|
|||||||
+51
-3
@@ -1,13 +1,16 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mapset "github.com/deckarep/golang-set/v2"
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
|
func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
|
||||||
@@ -26,9 +29,54 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
||||||
status, err := deploy.CompareContainerToSpec(ctr, spec)
|
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, spec)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, deploy.ContainerUpToDate, status)
|
assert.Equal(t, deploy.ContainerUpToDate, status)
|
||||||
|
|
||||||
|
spec = spec.SetDefaults()
|
||||||
|
// Verify labels.
|
||||||
|
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
|
||||||
|
assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode])
|
||||||
|
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
|
||||||
|
|
||||||
|
// Command and Entrypoint can only be compared if they are set in the spec.
|
||||||
|
// Otherwise, the container takes them from the image.
|
||||||
|
if spec.Container.Command != nil {
|
||||||
|
assert.EqualValues(t, spec.Container.Command, ctr.Config.Cmd)
|
||||||
|
}
|
||||||
|
if spec.Container.Entrypoint != nil {
|
||||||
|
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, spec.Container.Image, ctr.Config.Image)
|
||||||
|
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
|
||||||
|
assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds)
|
||||||
|
|
||||||
|
// Compare host ports.
|
||||||
|
portBindings := make(nat.PortMap)
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
if p.Mode != api.PortModeHost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := nat.NewPort(p.Protocol, strconv.Itoa(int(p.ContainerPort)))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
binding := nat.PortBinding{HostPort: strconv.Itoa(int(p.PublishedPort))}
|
||||||
|
if p.HostIP.IsValid() {
|
||||||
|
binding.HostIP = p.HostIP.String()
|
||||||
|
}
|
||||||
|
portBindings[port] = append(portBindings[port], binding)
|
||||||
|
}
|
||||||
|
assert.Equal(t, portBindings, ctr.HostConfig.PortBindings)
|
||||||
|
|
||||||
|
assert.Equal(t, container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
MaximumRetryCount: 0,
|
||||||
|
}, ctr.HostConfig.RestartPolicy)
|
||||||
|
|
||||||
|
// Verify network settings.
|
||||||
|
assert.Len(t, ctr.NetworkSettings.Networks, 1)
|
||||||
|
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serviceContainersByMachine returns a map of machine ID to service containers on that machine.
|
// serviceContainersByMachine returns a map of machine ID to service containers on that machine.
|
||||||
|
|||||||
Reference in New Issue
Block a user