Files
uncloud/pkg/api/service.go
T

330 lines
8.9 KiB
Go

package api
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"regexp"
"slices"
"github.com/distribution/reference"
"github.com/psviderski/uncloud/internal/machine/api/pb"
)
const (
ServiceModeReplicated = "replicated"
ServiceModeGlobal = "global"
// PullPolicyAlways means the image is always pulled from the registry.
PullPolicyAlways = "always"
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
// a container is started. This is the default pull policy.
// TODO: make each machine aware of the images on other machines and it possible to pull from them.
// Pull from the registry only if the image is missing on all machines.
PullPolicyMissing = "missing"
// PullPolicyNever means the image is never pulled from the registry. A service with this pull policy can only be
// deployed to machines where the image is already available.
// TODO: see the TODO above for PullPolicyMissing. Pull from other machines in the cluster if available.
PullPolicyNever = "never"
)
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
func ValidateServiceID(id string) bool {
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 {
Container ContainerSpec
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
Mode string
Name string
// Placement defines the placement constraints for the service.
Placement Placement
// Ports defines what service ports to publish to make the service accessible outside the cluster.
Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"`
// Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec
}
func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
for _, v := range s.Volumes {
if v.Name == name {
return v, true
}
}
return VolumeSpec{}, false
}
func (s *ServiceSpec) SetDefaults() ServiceSpec {
spec := s.Clone()
if spec.Mode == "" {
spec.Mode = ServiceModeReplicated
}
// Ensure the replicated service has at least one replica.
if spec.Mode == ServiceModeReplicated && spec.Replicas == 0 {
spec.Replicas = 1
}
spec.Container = spec.Container.SetDefaults()
for i, v := range spec.Volumes {
spec.Volumes[i] = v.SetDefaults()
}
return spec
}
func (s *ServiceSpec) Validate() error {
if err := s.Container.Validate(); err != nil {
return err
}
switch s.Mode {
case "", ServiceModeGlobal, ServiceModeReplicated:
default:
return fmt.Errorf("invalid mode: %q", s.Mode)
}
// TODO: validate the service name is a valid DNS label.
for _, p := range s.Ports {
if (p.Mode == "" || p.Mode == PortModeIngress) &&
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
return fmt.Errorf("unsupported protocol for ingress port %d: %s", p.ContainerPort, p.Protocol)
}
}
// TODO: validate there is no conflict between ports.
volumeNames := make(map[string]struct{})
for _, v := range s.Volumes {
if err := v.Validate(); err != nil {
return fmt.Errorf("invalid volume: %w", err)
}
if _, ok := volumeNames[v.Name]; ok {
return fmt.Errorf("duplicate volume name: '%s'", v.Name)
}
volumeNames[v.Name] = struct{}{}
}
for _, m := range s.Container.VolumeMounts {
if !slices.ContainsFunc(s.Volumes, func(v VolumeSpec) bool {
return v.Name == m.VolumeName
}) {
return fmt.Errorf("volume mount references a volume that doesn't exist in the service spec: '%s'",
m.VolumeName)
}
}
return nil
}
func (s *ServiceSpec) Clone() ServiceSpec {
spec := *s
if s.Ports != nil {
spec.Ports = make([]PortSpec, len(s.Ports))
copy(spec.Ports, s.Ports)
}
spec.Container = s.Container.Clone()
if s.Volumes != nil {
spec.Volumes = make([]VolumeSpec, len(s.Volumes))
for i, v := range s.Volumes {
spec.Volumes[i] = v.Clone()
}
}
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 {
// Command overrides the default CMD of the image to be executed when running a container.
Command []string
// Entrypoint overrides the default ENTRYPOINT of the image.
Entrypoint []string
// Env defines the environment variables to set inside the container.
Env EnvVars
Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool
// PullPolicy determines when to pull the image from the registry or use the image already available in the cluster.
// Default is PullPolicyMissing if empty.
PullPolicy string
// VolumeMounts specifies how volumes are mounted into the container filesystem.
// Each mount references a volume defined in ServiceSpec.Volumes.
VolumeMounts []VolumeMount
// Volumes is list of data volumes to mount into the container.
// TODO: replace with []VolumeMounts
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 {
if _, err := reference.ParseDockerRef(s.Image); err != nil {
return fmt.Errorf("invalid image '%s': %w", s.Image, err)
}
for _, m := range s.VolumeMounts {
if err := m.Validate(); err != nil {
return fmt.Errorf("invalid volume mount: %w", err)
}
}
return nil
}
func (s *ContainerSpec) Equals(spec ContainerSpec) bool {
orig := s.SetDefaults()
spec = spec.SetDefaults()
slices.Sort(orig.Volumes)
slices.Sort(spec.Volumes)
sortVolumeMounts(orig.VolumeMounts)
sortVolumeMounts(spec.VolumeMounts)
return reflect.DeepEqual(orig, spec)
}
func (s *ContainerSpec) Clone() ContainerSpec {
spec := *s
if s.Command != nil {
spec.Command = make([]string, len(s.Command))
copy(spec.Command, s.Command)
}
if s.Entrypoint != nil {
spec.Entrypoint = make([]string, len(s.Entrypoint))
copy(spec.Entrypoint, s.Entrypoint)
}
if s.Volumes != nil {
spec.Volumes = make([]string, len(s.Volumes))
copy(spec.Volumes, s.Volumes)
}
if s.VolumeMounts != nil {
spec.VolumeMounts = make([]VolumeMount, len(s.VolumeMounts))
copy(spec.VolumeMounts, s.VolumeMounts)
}
return spec
}
type EnvVars map[string]string
// ToSlice converts the environment variables to a slice of strings in the format "key=value".
func (e EnvVars) ToSlice() []string {
env := make([]string, 0, len(e))
for k, v := range e {
if k == "" {
continue
}
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
return env
}
type Service struct {
ID string
Name string
Mode string
Containers []MachineServiceContainer
}
type MachineServiceContainer struct {
MachineID string
Container ServiceContainer
}
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
func (s *Service) Endpoints() []string {
endpoints := make(map[string]struct{})
// Container specs may differ between containers in the same service, e.g. during a rolling update,
// so we need to collect all unique endpoints.
for _, ctr := range s.Containers {
ports, err := ctr.Container.ServicePorts()
if err != nil {
continue
}
for _, port := range ports {
protocol := ""
switch port.Protocol {
case ProtocolHTTP:
protocol = "http"
case ProtocolHTTPS:
protocol = "https"
default:
continue
}
if port.Hostname == "" {
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
continue
}
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
if port.PublishedPort != 0 {
// For non-standard ports (80/443), include the port in the URL.
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
}
}
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
endpoints[endpoint] = struct{}{}
}
}
return slices.Sorted(maps.Keys(endpoints))
}
func ServiceFromProto(s *pb.Service) (Service, error) {
var err error
containers := make([]MachineServiceContainer, len(s.Containers))
for i, sc := range s.Containers {
containers[i], err = machineContainerFromProto(sc)
if err != nil {
return Service{}, err
}
}
return Service{
ID: s.Id,
Name: s.Name,
Mode: s.Mode,
Containers: containers,
}, nil
}
func machineContainerFromProto(sc *pb.Service_Container) (MachineServiceContainer, error) {
var c Container
if err := json.Unmarshal(sc.Container, &c); err != nil {
return MachineServiceContainer{}, fmt.Errorf("unmarshal container: %w", err)
}
return MachineServiceContainer{
MachineID: sc.MachineId,
Container: ServiceContainer{Container: c},
}, nil
}