diff --git a/pkg/client/compose/deploy.go b/pkg/client/compose/deploy.go index 7d9e8b46..39dac39d 100644 --- a/pkg/client/compose/deploy.go +++ b/pkg/client/compose/deploy.go @@ -15,11 +15,12 @@ import ( "github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy/operation" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler" + "golang.org/x/sync/errgroup" ) type Client interface { - api.DNSClient deploy.Client + ListServices(ctx context.Context) ([]api.Service, error) } type Deployment struct { @@ -27,7 +28,7 @@ type Deployment struct { Project *types.Project SpecResolver *deploy.ServiceSpecResolver Strategy deploy.Strategy - state *scheduler.ClusterState + planning *planningState plan *Plan } @@ -36,29 +37,82 @@ func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*De } func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.Project, strategy deploy.Strategy) (*Deployment, error) { - state, err := scheduler.InspectClusterState(ctx, cli) + planning, err := loadPlanningState(ctx, cli) if err != nil { - return nil, fmt.Errorf("inspect cluster state: %w", err) - } - - domain, err := cli.GetDomain(ctx) - if err != nil && !errors.Is(err, api.ErrNotFound) { - return nil, fmt.Errorf("get cluster domain: %w", err) - } - resolver := &deploy.ServiceSpecResolver{ - // If the domain is not found (not reserved), an empty domain is used for the resolver. - ClusterDomain: domain, + return nil, fmt.Errorf("load planning state: %w", err) } return &Deployment{ Client: cli, Project: project, - SpecResolver: resolver, + SpecResolver: planning.resolver, Strategy: strategy, - state: state, + planning: planning, }, nil } +type planningState struct { + clusterState *scheduler.ClusterState + resolver *deploy.ServiceSpecResolver + services map[string][]api.Service +} + +func loadPlanningState(ctx context.Context, cli Client) (*planningState, error) { + state := &planningState{} + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + clusterState, err := scheduler.InspectClusterState(gctx, cli) + if err != nil { + return fmt.Errorf("inspect cluster state: %w", err) + } + state.clusterState = clusterState + return nil + }) + + g.Go(func() error { + services, err := cli.ListServices(gctx) + if err != nil { + return fmt.Errorf("list services: %w", err) + } + + state.services = make(map[string][]api.Service, len(services)) + for _, svc := range services { + state.services[svc.Name] = append(state.services[svc.Name], svc) + } + return nil + }) + + g.Go(func() error { + domain, err := cli.GetDomain(gctx) + if err != nil && !errors.Is(err, api.ErrNotFound) { + return fmt.Errorf("get domain: %w", err) + } + state.resolver = &deploy.ServiceSpecResolver{ + // If the domain is not found (not reserved), an empty domain is used for the resolver. + ClusterDomain: domain, + } + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + return state, nil +} + +func (s *planningState) currentService(name string) (*api.Service, error) { + matches := s.services[name] + switch len(matches) { + case 0: + return nil, nil + case 1: + return &matches[0], nil + default: + return nil, fmt.Errorf("multiple services found with name '%s', use the service ID instead", name) + } +} + func (d *Deployment) Plan(ctx context.Context) (Plan, error) { if d.plan != nil { return *d.plan, nil @@ -85,7 +139,7 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) { } // Check external volumes and plan the creation of missing volumes before deploying services. - // Updates the cluster state (d.state) with the scheduled volumes. + // Updates the planning cluster state with the scheduled volumes. volumeOps, err := d.planVolumes(serviceSpecs) if err != nil { return plan, err @@ -94,9 +148,12 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) { for _, spec := range serviceSpecs { // TODO: properly handle depends_on conditions in the service deployment plan as the first operation. - // Pass the updated cluster state with the scheduled volumes to the deployment. - deployment := deploy.NewDeploymentWithClusterState(d.Client, spec, d.Strategy, d.state) - servicePlan, err := deployment.Plan(ctx) + currentService, err := d.planning.currentService(spec.Name) + if err != nil { + return plan, fmt.Errorf("find current service '%s': %w", spec.Name, err) + } + + servicePlan, err := deploy.PlanService(d.planning.clusterState, currentService, spec, d.SpecResolver, d.Strategy) if err != nil { return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err) } @@ -134,7 +191,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.C // TODO: The scheduler should ideally work with the resolved service specs to correctly identify eligible machines. // Figure out where the best place to resolve the specs is. - volumeScheduler, err := scheduler.NewVolumeScheduler(d.state, serviceSpecs) + volumeScheduler, err := scheduler.NewVolumeScheduler(d.planning.clusterState, serviceSpecs) if err != nil { return nil, fmt.Errorf("init volume scheduler: %w", err) } @@ -148,7 +205,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.C for machineID, volumes := range scheduledVolumes { for _, v := range volumes { machineName := machineID - if m, ok := d.state.Machine(machineID); ok { + if m, ok := d.planning.clusterState.Machine(machineID); ok { machineName = m.Info.Name } @@ -174,7 +231,7 @@ func (d *Deployment) checkExternalVolumesExist() error { var notFound []string for _, name := range externalNames { - if !slices.ContainsFunc(d.state.Machines, func(m *scheduler.Machine) bool { + if !slices.ContainsFunc(d.planning.clusterState.Machines, func(m *scheduler.Machine) bool { return slices.ContainsFunc(m.Volumes, func(vol volume.Volume) bool { return vol.Name == name }) diff --git a/pkg/client/compose/deploy_planning_state_test.go b/pkg/client/compose/deploy_planning_state_test.go new file mode 100644 index 00000000..31554242 --- /dev/null +++ b/pkg/client/compose/deploy_planning_state_test.go @@ -0,0 +1,165 @@ +package compose + +import ( + "context" + "fmt" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/volume" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client/deploy" + "github.com/psviderski/uncloud/pkg/client/deploy/scheduler" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeploymentPlanUsesPlanningStateForCurrentServices(t *testing.T) { + project, err := LoadProjectFromContent(context.Background(), composeYAML(20)) + require.NoError(t, err) + + strategy := &recordingStrategy{} + fake := &composePlanningClient{ + services: []api.Service{ + {ID: "svc-01", Name: "svc01", Mode: api.ServiceModeReplicated}, + {ID: "svc-10", Name: "svc10", Mode: api.ServiceModeReplicated}, + }, + domain: "example.uncld.dev", + } + + deployment, err := NewDeploymentWithStrategy(context.Background(), fake, project, strategy) + require.NoError(t, err) + _, err = deployment.Plan(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 1, fake.listServicesCalls) + assert.Equal(t, 1, fake.getDomainCalls) + assert.Equal(t, 0, fake.inspectServiceCalls) + require.Len(t, strategy.calls, 20) + + seen := make(map[string]*api.Service) + for _, call := range strategy.calls { + seen[call.spec.Name] = call.svc + } + require.NotNil(t, seen["svc01"]) + assert.Equal(t, "svc-01", seen["svc01"].ID) + require.NotNil(t, seen["svc10"]) + assert.Equal(t, "svc-10", seen["svc10"].ID) + assert.Nil(t, seen["svc02"]) +} + +func TestDeploymentPlanErrorsOnDuplicateCurrentServiceNames(t *testing.T) { + project, err := LoadProjectFromContent(context.Background(), composeYAML(1)) + require.NoError(t, err) + + fake := &composePlanningClient{ + services: []api.Service{ + {ID: "svc-a", Name: "svc01"}, + {ID: "svc-b", Name: "svc01"}, + }, + } + deployment, err := NewDeploymentWithStrategy(context.Background(), fake, project, &recordingStrategy{}) + require.NoError(t, err) + + _, err = deployment.Plan(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple services found with name 'svc01'") + assert.Equal(t, 0, fake.inspectServiceCalls) +} + +func composeYAML(count int) string { + out := "services:\n" + for i := 1; i <= count; i++ { + name := fmt.Sprintf("svc%02d", i) + out += fmt.Sprintf(" %s:\n image: nginx:%d\n", name, i) + } + return out +} + +type recordingStrategy struct { + calls []recordedPlanCall +} + +type recordedPlanCall struct { + svc *api.Service + spec api.ServiceSpec +} + +func (s *recordingStrategy) Type() string { + return "recording" +} + +func (s *recordingStrategy) Plan( + _ *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec, +) (deploy.ServicePlan, error) { + s.calls = append(s.calls, recordedPlanCall{ + svc: svc, + spec: spec, + }) + serviceID := spec.Name + if svc != nil { + serviceID = svc.ID + } + return deploy.ServicePlan{ + ServiceID: serviceID, + ServiceName: spec.Name, + Spec: spec, + }, nil +} + +type composePlanningClient struct { + api.Client + + services []api.Service + domain string + + listServicesCalls int + getDomainCalls int + inspectServiceCalls int +} + +func (c *composePlanningClient) ListServices(context.Context) ([]api.Service, error) { + c.listServicesCalls++ + return c.services, nil +} + +func (c *composePlanningClient) ListMachines(context.Context, *api.MachineFilter) (api.MachineMembersList, error) { + return api.MachineMembersList{ + { + Machine: &pb.MachineInfo{ + Id: "machine-1", + Name: "machine-1", + }, + State: pb.MachineMember_UP, + }, + }, nil +} + +func (c *composePlanningClient) ListVolumes(context.Context, *api.VolumeFilter) ([]api.MachineVolume, error) { + return nil, nil +} + +func (c *composePlanningClient) GetDomain(context.Context) (string, error) { + c.getDomainCalls++ + return c.domain, nil +} + +func (c *composePlanningClient) InspectService(context.Context, string) (api.Service, error) { + c.inspectServiceCalls++ + return api.Service{}, api.ErrNotFound +} + +func (c *composePlanningClient) CreateVolume( + context.Context, string, volume.CreateOptions, +) (api.MachineVolume, error) { + return api.MachineVolume{}, nil +} + +func (c *composePlanningClient) RemoveVolume(context.Context, string, string, bool) error { + return nil +} + +func (c *composePlanningClient) StopService(context.Context, string, container.StopOptions) error { + return nil +} diff --git a/pkg/client/deploy/deploy.go b/pkg/client/deploy/deploy.go index 73e3ea0d..5c6d813f 100644 --- a/pkg/client/deploy/deploy.go +++ b/pkg/client/deploy/deploy.go @@ -33,8 +33,7 @@ type Deployment struct { Strategy Strategy cli Client plan *ServicePlan - // state is an optional current and planned cluster state used for scheduling decisions. - state *scheduler.ClusterState + state *scheduler.ClusterState } type ServicePlan struct { @@ -310,57 +309,105 @@ func (d *Deployment) Plan(ctx context.Context) (ServicePlan, error) { ClusterDomain: clusterDomain, } - resolvedSpec, err := specResolver.Resolve(d.Spec) - if err != nil { - return ServicePlan{}, fmt.Errorf("resolve service spec: %w", err) - } - - if d.state == nil { - d.state, err = scheduler.InspectClusterState(ctx, d.cli) + state := d.state + if state == nil { + state, err = scheduler.InspectClusterState(ctx, d.cli) if err != nil { return ServicePlan{}, fmt.Errorf("inspect cluster state: %w", err) } + d.state = state } - plan, err := d.Strategy.Plan(d.state, d.Service, resolvedSpec) + plan, err := PlanService(state, d.Service, d.Spec, specResolver, d.Strategy) if err != nil { - return ServicePlan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err) + return ServicePlan{}, err } d.plan = &plan return plan, nil } +func PlanService( + state *scheduler.ClusterState, + currentService *api.Service, + spec api.ServiceSpec, + resolver *ServiceSpecResolver, + strategy Strategy, +) (ServicePlan, error) { + if strategy == nil { + strategy = &RollingStrategy{} + } + if err := spec.Validate(); err != nil { + return ServicePlan{}, fmt.Errorf("invalid service spec: %w", err) + } + if err := ValidateServiceSpecForCurrent(spec, currentService); err != nil { + return ServicePlan{}, fmt.Errorf("invalid deployment: %w", err) + } + if resolver == nil { + resolver = &ServiceSpecResolver{} + } + + resolvedSpec, err := resolver.Resolve(spec) + if err != nil { + return ServicePlan{}, fmt.Errorf("resolve service spec: %w", err) + } + plan, err := strategy.Plan(state, currentService, resolvedSpec) + if err != nil { + return ServicePlan{}, fmt.Errorf("create plan using %s strategy: %w", strategy.Type(), err) + } + return plan, nil +} + +func (d *Deployment) loadCurrentService(ctx context.Context) error { + if d.Service != nil || d.Spec.Name == "" { + return nil + } + + svc, err := d.cli.InspectService(ctx, d.Spec.Name) + if err == nil { + d.Service = &svc + return nil + } + if errors.Is(err, api.ErrNotFound) { + return nil + } + return fmt.Errorf("inspect service: %w", err) +} + // Validate checks if the deployment specification is valid. func (d *Deployment) Validate(ctx context.Context) error { if err := d.Spec.Validate(); err != nil { return fmt.Errorf("invalid service spec: %w", err) } - if d.Service == nil && d.Spec.Name != "" { - svc, err := d.cli.InspectService(ctx, d.Spec.Name) - if err == nil { - d.Service = &svc - } else if !errors.Is(err, api.ErrNotFound) { - return fmt.Errorf("inspect service: %w", err) - } + if err := d.loadCurrentService(ctx); err != nil { + return fmt.Errorf("load current service: %w", err) } + + return d.validateLoaded() +} + +func (d *Deployment) validateLoaded() error { + return ValidateServiceSpecForCurrent(d.Spec, d.Service) +} + +func ValidateServiceSpecForCurrent(spec api.ServiceSpec, currentService *api.Service) error { // d.Service is nil if the service doesn't exist yet (first deployment). - if d.Service == nil { + if currentService == nil { return nil } - if d.Service.Name != d.Spec.Name { + if currentService.Name != spec.Name { return errors.New("service name cannot be changed") } // Resolve the default mode if not specified. - mode := d.Spec.Mode + mode := spec.Mode if mode == "" { mode = api.ServiceModeReplicated } - if mode != d.Service.Mode { + if mode != currentService.Mode { return errors.New("service mode cannot be changed") } diff --git a/pkg/client/service.go b/pkg/client/service.go index c2e48b44..8298a77f 100644 --- a/pkg/client/service.go +++ b/pkg/client/service.go @@ -4,13 +4,13 @@ import ( "context" "errors" "fmt" - "slices" "sync" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/volume" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb" + machinedocker "github.com/psviderski/uncloud/internal/machine/docker" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "google.golang.org/grpc/codes" @@ -90,9 +90,17 @@ func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (api.Ru func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Service, error) { var svc api.Service + inventory, err := cli.loadServiceInventory(ctx, nameOrID) + if err != nil { + return svc, err + } + return inventory.find(nameOrID) +} + +func (cli *Client) loadServiceInventory(ctx context.Context, serviceNameOrID string) (serviceInventory, error) { machines, err := cli.ListMachines(ctx, nil) if err != nil { - return svc, fmt.Errorf("list machines: %w", err) + return serviceInventory{}, fmt.Errorf("list machines: %w", err) } // Broadcast the container list request to all available machines. @@ -109,82 +117,54 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser // List all service containers including stopped ones and deployment hooks. opts := container.ListOptions{All: true} - machineContainers, err := cli.Docker.ListServiceContainers(listCtx, nameOrID, opts) + machineContainers, err := cli.Docker.ListServiceContainers(listCtx, serviceNameOrID, opts) if err != nil { - return svc, fmt.Errorf("list containers: %w", err) + return serviceInventory{}, fmt.Errorf("list containers: %w", err) } - // Collect all containers on all machines that belong to the specified service. - foundByID := false - var containers []api.MachineServiceContainer - for _, mc := range machineContainers { - // NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed. - if mc.Metadata == nil { - tui.PrintWarning("metadata is missing in response from unknown server") - continue - } - - if mc.Metadata.Error != "" { - // TODO: return failed machines in the response. - tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s", - mc.Metadata.MachineName, mc.Metadata.Error)) - continue - } - - // Collect both regular and hook containers for the service. - for _, ctr := range append(mc.Containers, mc.HookContainers...) { - containers = append(containers, api.MachineServiceContainer{ - MachineID: mc.Metadata.MachineId, - MachineName: mc.Metadata.MachineName, - Container: ctr, - }) + inventory := serviceInventoryFromMachineContainers(machineContainers) + inventory.printWarnings() + return inventory, nil +} - if ctr.ServiceID() == nameOrID { - foundByID = true - } - } - } +type serviceInventory struct { + servicesByID map[string]api.Service + warnings []string +} - if len(containers) == 0 { - return svc, api.ErrNotFound +func (i serviceInventory) find(nameOrID string) (api.Service, error) { + if svc, ok := i.servicesByID[nameOrID]; ok { + return svc, nil } - // Containers from different services may share the same service name (distributed and eventually consistent store - // may not prevent this), or a service name might match another service's ID. In these cases, matching by ID takes - // priority over matching by name. - if foundByID { - containers = slices.DeleteFunc(containers, func(mc api.MachineServiceContainer) bool { - return mc.Container.ServiceID() != nameOrID - }) - } else { - // Matched only by name but there could be multiple services with the same name. - serviceID := containers[0].Container.ServiceID() - for _, mc := range containers[1:] { - if mc.Container.ServiceID() != serviceID { - return svc, fmt.Errorf("multiple services found with name '%s', use the service ID instead", nameOrID) - } + var matches []api.Service + for _, svc := range i.servicesByID { + if svc.Name == nameOrID { + matches = append(matches, svc) } } - - // Partition containers into regular service containers and hook containers. - var serviceContainers, hookContainers []api.MachineServiceContainer - for _, mc := range containers { - if mc.Container.IsHook() { - hookContainers = append(hookContainers, mc) - } else { - serviceContainers = append(serviceContainers, mc) - } + switch len(matches) { + case 0: + return api.Service{}, api.ErrNotFound + case 1: + return matches[0], nil + default: + return api.Service{}, fmt.Errorf("multiple services found with name '%s', use the service ID instead", nameOrID) } +} - svc = api.Service{ - ID: containers[0].Container.ServiceID(), - Name: containers[0].Container.ServiceName(), - Mode: containers[0].Container.ServiceMode(), - Containers: serviceContainers, - HookContainers: hookContainers, +func (i serviceInventory) services() []api.Service { + services := make([]api.Service, 0, len(i.servicesByID)) + for _, svc := range i.servicesByID { + services = append(services, svc) } + return services +} - return svc, nil +func (i serviceInventory) printWarnings() { + for _, warning := range i.warnings { + tui.PrintWarning(warning) + } } // InspectServiceFromStore returns detailed information about a service and its containers from the distributed store. @@ -320,67 +300,71 @@ func (cli *Client) StartService(ctx context.Context, id string) error { // ListServices returns a list of all services and their containers. func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) { - machines, err := cli.ListMachines(ctx, nil) + inventory, err := cli.loadServiceInventory(ctx, "") if err != nil { - return nil, fmt.Errorf("list machines: %w", err) - } - - // Broadcast the container list request to all available machines. - md := metadata.New(nil) - for _, m := range machines { - if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT { - md.Append("machines", m.Machine.Id) - } else { - tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine '%s' (state is %s). "+ - "The results may be incomplete.", m.Machine.Name, m.State.String())) - } + return nil, err } - listCtx := metadata.NewOutgoingContext(ctx, md) + return inventory.services(), nil +} - // List all containers including stopped ones. - opts := container.ListOptions{All: true} - machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts) - if err != nil { - return nil, fmt.Errorf("list containers: %w", err) +func serviceInventoryFromMachineContainers( + machineContainers []machinedocker.MachineServiceContainers, +) serviceInventory { + inventory := serviceInventory{ + servicesByID: make(map[string]api.Service), } - // TODO: optimise by extracting services from the list of all containers instead of inspecting each service. - // Most of the code can be reused in both InspectService and ListServices. - servicesByID := make(map[string]api.Service) for _, mc := range machineContainers { // NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed. if mc.Metadata == nil { - tui.PrintWarning("metadata is missing in response from unknown server") + inventory.warnings = append(inventory.warnings, "metadata is missing in response from unknown server") continue } if mc.Metadata.Error != "" { // TODO: return failed machines in the response. - tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s", - mc.Metadata.MachineName, mc.Metadata.Error)) + inventory.warnings = append(inventory.warnings, fmt.Sprintf( + "failed to list containers on machine '%s': %s", mc.Metadata.MachineName, mc.Metadata.Error, + )) continue } - for _, ctr := range append(mc.Containers, mc.HookContainers...) { - if _, ok := servicesByID[ctr.ServiceID()]; ok { - continue - } + for _, ctr := range mc.Containers { + inventory.addContainer(mc.Metadata.MachineId, mc.Metadata.MachineName, ctr, false) + } + for _, ctr := range mc.HookContainers { + inventory.addContainer(mc.Metadata.MachineId, mc.Metadata.MachineName, ctr, true) + } + } - svc, err := cli.InspectService(ctx, ctr.ServiceID()) - if err != nil { - if errors.Is(err, api.ErrNotFound) { - continue - } - return nil, fmt.Errorf("inspect service: %w", err) - } + return inventory +} - servicesByID[ctr.ServiceID()] = svc +func (i *serviceInventory) addContainer( + machineID string, + machineName string, + ctr api.ServiceContainer, + hook bool, +) { + serviceID := ctr.ServiceID() + svc := i.servicesByID[serviceID] + if svc.ID == "" { + svc = api.Service{ + ID: serviceID, + Name: ctr.ServiceName(), + Mode: ctr.ServiceMode(), } } - services := make([]api.Service, 0, len(servicesByID)) - for _, svc := range servicesByID { - services = append(services, svc) + mc := api.MachineServiceContainer{ + MachineID: machineID, + MachineName: machineName, + Container: ctr, + } + if hook { + svc.HookContainers = append(svc.HookContainers, mc) + } else { + svc.Containers = append(svc.Containers, mc) } - return services, nil + i.servicesByID[serviceID] = svc } diff --git a/pkg/client/service_test.go b/pkg/client/service_test.go new file mode 100644 index 00000000..ad8d7565 --- /dev/null +++ b/pkg/client/service_test.go @@ -0,0 +1,121 @@ +package client + +import ( + "testing" + + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/psviderski/uncloud/internal/machine/api/pb" + machinedocker "github.com/psviderski/uncloud/internal/machine/docker" + "github.com/psviderski/uncloud/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceInventoryFromMachineContainers(t *testing.T) { + inventory := serviceInventoryFromMachineContainers( + []machinedocker.MachineServiceContainers{ + { + Metadata: &pb.Metadata{MachineId: "machine-1", MachineName: "machine-one"}, + Containers: []api.ServiceContainer{ + testServiceContainer("web-1", "svc-web", "web", api.ServiceModeReplicated, false), + testServiceContainer("api-1", "svc-api", "api", api.ServiceModeGlobal, false), + }, + HookContainers: []api.ServiceContainer{ + testServiceContainer("web-hook-1", "svc-web", "web", api.ServiceModeReplicated, true), + }, + }, + { + Metadata: &pb.Metadata{MachineId: "machine-2", MachineName: "machine-two"}, + Containers: []api.ServiceContainer{ + testServiceContainer("web-2", "svc-web", "web", api.ServiceModeReplicated, false), + testServiceContainer("web-alt-1", "svc-web-alt", "web", api.ServiceModeReplicated, false), + }, + }, + { + Metadata: &pb.Metadata{MachineId: "machine-3", MachineName: "machine-three", Error: "unavailable"}, + }, + }, + ) + servicesByID := inventory.servicesByID + + require.Len(t, servicesByID, 3) + + web := servicesByID["svc-web"] + assert.Equal(t, "web", web.Name) + assert.Equal(t, api.ServiceModeReplicated, web.Mode) + require.Len(t, web.Containers, 2) + assert.Equal(t, "machine-1", web.Containers[0].MachineID) + assert.Equal(t, "machine-one", web.Containers[0].MachineName) + assert.Equal(t, "machine-2", web.Containers[1].MachineID) + assert.Equal(t, "machine-two", web.Containers[1].MachineName) + require.Len(t, web.HookContainers, 1) + assert.Equal(t, "web-hook-1", web.HookContainers[0].Container.ID) + + apiSvc := servicesByID["svc-api"] + assert.Equal(t, "api", apiSvc.Name) + assert.Equal(t, api.ServiceModeGlobal, apiSvc.Mode) + require.Len(t, apiSvc.Containers, 1) + assert.Equal(t, "machine-1", apiSvc.Containers[0].MachineID) + + duplicateName := servicesByID["svc-web-alt"] + assert.Equal(t, "web", duplicateName.Name) + require.Len(t, duplicateName.Containers, 1) + + webByID, err := inventory.find("svc-web") + require.NoError(t, err) + require.Len(t, webByID.Containers, 2) + + _, err = inventory.find("web") + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple services found with name 'web'") + + assert.Contains(t, inventory.warnings, "failed to list containers on machine 'machine-three': unavailable") +} + +func TestServiceInventoryFromMachineContainersNilMetadataWarnsAndSkips(t *testing.T) { + inventory := serviceInventoryFromMachineContainers( + []machinedocker.MachineServiceContainers{ + {Metadata: nil}, + { + Metadata: &pb.Metadata{MachineId: "machine-2", MachineName: "machine-two"}, + Containers: []api.ServiceContainer{ + testServiceContainer("web-1", "svc-web", "web", api.ServiceModeReplicated, false), + }, + }, + }, + ) + require.Contains(t, inventory.servicesByID, "svc-web") + assert.Contains(t, inventory.warnings, "metadata is missing in response from unknown server") +} + +func testServiceContainer(id, serviceID, serviceName, mode string, hook bool) api.ServiceContainer { + labels := map[string]string{ + api.LabelServiceID: serviceID, + api.LabelServiceName: serviceName, + } + if hook { + labels[api.LabelHook] = api.LabelHookPreDeploy + } + + return api.ServiceContainer{ + Container: api.Container{ + InspectResponse: dockercontainer.InspectResponse{ + ContainerJSONBase: &dockercontainer.ContainerJSONBase{ + ID: id, + Name: id, + }, + Config: &dockercontainer.Config{ + Image: "nginx:latest", + Labels: labels, + }, + }, + }, + ServiceSpec: api.ServiceSpec{ + Name: serviceName, + Mode: mode, + Container: api.ContainerSpec{ + Image: "nginx:latest", + }, + }, + } +}