internal/servicedeployer/compose.go (258 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License; // you may not use this file except in compliance with the Elastic License. package servicedeployer import ( "context" "fmt" "math" "os" "path/filepath" "time" "github.com/elastic/elastic-package/internal/builder" "github.com/elastic/elastic-package/internal/compose" "github.com/elastic/elastic-package/internal/docker" "github.com/elastic/elastic-package/internal/files" "github.com/elastic/elastic-package/internal/logger" "github.com/elastic/elastic-package/internal/profile" "github.com/elastic/elastic-package/internal/stack" ) // DockerComposeServiceDeployer knows how to deploy a service defined via // a Docker Compose file. type DockerComposeServiceDeployer struct { profile *profile.Profile ymlPaths []string variant ServiceVariant deployIndependentAgent bool runTearDown bool runTestsOnly bool } type DockerComposeServiceDeployerOptions struct { Profile *profile.Profile YmlPaths []string Variant ServiceVariant DeployIndependentAgent bool RunTearDown bool RunTestsOnly bool } type dockerComposeDeployedService struct { svcInfo ServiceInfo shutdownTimeout time.Duration ymlPaths []string project string variant ServiceVariant env []string configDir string } var _ ServiceDeployer = new(DockerComposeServiceDeployer) // NewDockerComposeServiceDeployer returns a new instance of a DockerComposeServiceDeployer. func NewDockerComposeServiceDeployer(options DockerComposeServiceDeployerOptions) (*DockerComposeServiceDeployer, error) { return &DockerComposeServiceDeployer{ profile: options.Profile, ymlPaths: options.YmlPaths, variant: options.Variant, runTearDown: options.RunTearDown, runTestsOnly: options.RunTestsOnly, deployIndependentAgent: options.DeployIndependentAgent, }, nil } // SetUp sets up the service and returns any relevant information. func (d *DockerComposeServiceDeployer) SetUp(ctx context.Context, svcInfo ServiceInfo) (DeployedService, error) { logger.Debug("setting up service using Docker Compose service deployer") service := dockerComposeDeployedService{ ymlPaths: d.ymlPaths, project: fmt.Sprintf("elastic-package-service-%s", svcInfo.Test.RunID), variant: d.variant, env: []string{ fmt.Sprintf("%s=%s", serviceLogsDirEnv, svcInfo.Logs.Folder.Local), }, } p, err := compose.NewProject(service.project, service.ymlPaths...) if err != nil { return nil, fmt.Errorf("could not create Docker Compose project for service: %w", err) } // Verify the Elastic stack network err = stack.EnsureStackNetworkUp(d.profile) if err != nil { return nil, fmt.Errorf("stack network is not ready: %w", err) } // Clean service logs if d.runTestsOnly { // service logs folder must no be deleted to avoid breaking log files written // by the service. If this is required, those files should be rotated or truncated // so the service can still write to them. logger.Debugf("Skipping removing service logs folder folder %s", svcInfo.Logs.Folder.Local) } else { err = files.RemoveContent(svcInfo.Logs.Folder.Local) if err != nil { return nil, fmt.Errorf("removing service logs failed: %w", err) } } // Boot up service if d.variant.active() { logger.Infof("Using service variant: %s", d.variant.String()) } opts := compose.CommandOptions{ Env: append( service.env, d.variant.Env...), ExtraArgs: []string{"--build", "-d"}, } serviceName := svcInfo.Name if d.runTearDown || d.runTestsOnly { logger.Debug("Skipping bringing up docker-compose custom agent project") } else { err = p.Up(ctx, opts) if err != nil { return nil, fmt.Errorf("could not boot up service using Docker Compose: %w", err) } } err = p.WaitForHealthy(ctx, opts) if err != nil { processServiceContainerLogs(context.WithoutCancel(ctx), p, compose.CommandOptions{ Env: opts.Env, }, svcInfo.Name) return nil, fmt.Errorf("service is unhealthy: %w", err) } // Added a specific alias when connecting the service to the network. // - There could be container names too long that could not be resolved by the local DNS // - Not used serviceName directly as alias container, since there could be packages defining // kibana or elasticsearch services and those DNS names are already present in the Elastic stack. // This is mainly applicable when the Elastic Agent of the stack is used for testing. // - Keep the same alias for both implementations for consistency aliasContainer := fmt.Sprintf("svc-%s", serviceName) if d.runTearDown || d.runTestsOnly { logger.Debug("Skipping connect container to network (non setup steps)") } else { aliases := []string{ aliasContainer, } if d.deployIndependentAgent { // Connect service network with agent network err = docker.ConnectToNetworkWithAlias(p.ContainerName(serviceName), svcInfo.AgentNetworkName, aliases) if err != nil { return nil, fmt.Errorf("can't attach service container to the agent network: %w", err) } } else { // Connect service network with stack network (for the purpose of metrics collection) err = docker.ConnectToNetworkWithAlias(p.ContainerName(serviceName), stack.Network(d.profile), aliases) if err != nil { return nil, fmt.Errorf("can't attach service container to the stack network: %w", err) } } } // Build service container name svcInfo.Hostname = aliasContainer logger.Debugf("adding service container %s internal ports to context", p.ContainerName(serviceName)) serviceComposeConfig, err := p.Config(ctx, compose.CommandOptions{ Env: service.env, }) if err != nil { return nil, fmt.Errorf("could not get Docker Compose configuration for service: %w", err) } s := serviceComposeConfig.Services[serviceName] svcInfo.Ports = make([]int, len(s.Ports)) for idx, port := range s.Ports { svcInfo.Ports[idx] = port.InternalPort } // Shortcut to first port for convenience if len(svcInfo.Ports) > 0 { svcInfo.Port = svcInfo.Ports[0] } svcInfo.Agent.Host.NamePrefix = "docker-fleet-agent" service.svcInfo = svcInfo return &service, nil } // Signal sends a signal to the service. func (s *dockerComposeDeployedService) Signal(ctx context.Context, signal string) error { p, err := compose.NewProject(s.project, s.ymlPaths...) if err != nil { return fmt.Errorf("could not create Docker Compose project for service: %w", err) } opts := compose.CommandOptions{ Env: append( s.env, s.variant.Env...), ExtraArgs: []string{"-s", signal}, } if s.svcInfo.Name != "" { opts.Services = append(opts.Services, s.svcInfo.Name) } err = p.Kill(ctx, opts) if err != nil { return fmt.Errorf("could not send %q signal: %w", signal, err) } return nil } // ExitCode returns true if the service is exited and its exit code. func (s *dockerComposeDeployedService) ExitCode(ctx context.Context, service string) (bool, int, error) { p, err := compose.NewProject(s.project, s.ymlPaths...) if err != nil { return false, -1, fmt.Errorf("could not create Docker Compose project for service: %w", err) } opts := compose.CommandOptions{ Env: append( s.env, s.variant.Env...), } return p.ServiceExitCode(ctx, service, opts) } // TearDown tears down the service. func (s *dockerComposeDeployedService) TearDown(ctx context.Context) error { logger.Debugf("tearing down service using Docker Compose runner") defer func() { err := files.RemoveContent(s.svcInfo.Logs.Folder.Local) if err != nil { logger.Errorf("could not remove the service logs (path: %s)", s.svcInfo.Logs.Folder.Local) } // Remove the outputs generated by the service container if err = os.RemoveAll(s.svcInfo.OutputDir); err != nil { logger.Errorf("could not remove the temporary output files %s", err) } if s.configDir != "" { // Remove the configuration dir for this service (e.g. terraform or compose scenario files) if err := os.RemoveAll(s.configDir); err != nil { logger.Errorf("could not remove the service configuration directory (path: %s) %v", s.configDir, err) } } }() p, err := compose.NewProject(s.project, s.ymlPaths...) if err != nil { return fmt.Errorf("could not create Docker Compose project for service: %w", err) } opts := compose.CommandOptions{ Env: append( s.env, s.variant.Env...), } extraArgs := []string{} // if not set "-t" , default shutdown timeout is 10 seconds // https://docs.docker.com/compose/faq/#why-do-my-services-take-10-seconds-to-recreate-or-stop if seconds := s.shutdownTimeout.Seconds(); seconds > 0 { extraArgs = append(extraArgs, "-t", fmt.Sprintf("%d", int(math.Round(seconds)))) } if err := p.Stop(ctx, compose.CommandOptions{ Env: opts.Env, ExtraArgs: extraArgs, }); err != nil { return fmt.Errorf("could not stop service using Docker Compose: %w", err) } processServiceContainerLogs(ctx, p, opts, s.svcInfo.Name) if err := p.Down(ctx, compose.CommandOptions{ Env: opts.Env, ExtraArgs: []string{"--volumes"}, // Remove associated volumes. }); err != nil { return fmt.Errorf("could not shut down service using Docker Compose: %w", err) } return nil } // Info returns the current context for the service. func (s *dockerComposeDeployedService) Info() ServiceInfo { return s.svcInfo } // SetInfo sets the current context for the service. func (s *dockerComposeDeployedService) SetInfo(ctxt ServiceInfo) error { s.svcInfo = ctxt return nil } func processServiceContainerLogs(ctx context.Context, p *compose.Project, opts compose.CommandOptions, serviceName string) { content, err := p.Logs(ctx, opts) if err != nil { logger.Errorf("can't export service logs: %v", err) return } if len(content) == 0 { logger.Info("service container hasn't written anything logs.") return } err = writeServiceContainerLogs(serviceName, content) if err != nil { logger.Errorf("can't write service container logs: %v", err) } } func writeServiceContainerLogs(serviceName string, content []byte) error { buildDir, err := builder.BuildDirectory() if err != nil { return fmt.Errorf("locating build directory failed: %w", err) } containerLogsDir := filepath.Join(buildDir, "container-logs") err = os.MkdirAll(containerLogsDir, 0755) if err != nil { return fmt.Errorf("can't create directory for service container logs (path: %s): %w", containerLogsDir, err) } containerLogsFilepath := filepath.Join(containerLogsDir, fmt.Sprintf("%s-%d.log", serviceName, time.Now().UnixNano())) logger.Infof("Write container logs to file: %s", containerLogsFilepath) err = os.WriteFile(containerLogsFilepath, content, 0644) if err != nil { return fmt.Errorf("can't write container logs to file (path: %s): %w", containerLogsFilepath, err) } return nil }