internal/agentdeployer/agent.go (348 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 agentdeployer import ( "context" "crypto/md5" "embed" "encoding/hex" "fmt" "os" "path/filepath" "strings" "time" "github.com/elastic/go-resource" "github.com/elastic/elastic-package/internal/common" "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/install" "github.com/elastic/elastic-package/internal/logger" "github.com/elastic/elastic-package/internal/profile" "github.com/elastic/elastic-package/internal/stack" ) const ( dockerTestAgentServiceName = "elastic-agent" dockerTestAgentDockerCompose = "docker-agent-base.yml" dockerTestAgentDockerfile = "Dockerfile" customScriptFilename = "script.sh" customEntrypointFilename = "custom-entrypoint.sh" defaultAgentPolicyName = "Elastic-Agent (elastic-package)" ) //go:embed _static var static embed.FS var staticSource = resource.NewSourceFS(static) // CustomAgentDeployer knows how to deploy a custom elastic-agent defined via // a Docker Compose file. type DockerComposeAgentDeployer struct { profile *profile.Profile stackVersion string policyName string agentRunID string packageName string dataStream string runTearDown bool runTestsOnly bool } type DockerComposeAgentDeployerOptions struct { Profile *profile.Profile StackVersion string PolicyName string PackageName string DataStream string RunTearDown bool RunTestsOnly bool } var _ AgentDeployer = new(DockerComposeAgentDeployer) type dockerComposeDeployedAgent struct { agentInfo AgentInfo ymlPaths []string project string env []string configDir string } var _ DeployedAgent = new(dockerComposeDeployedAgent) // NewCustomAgentDeployer returns a new instance of a deployedCustomAgent. func NewCustomAgentDeployer(options DockerComposeAgentDeployerOptions) (*DockerComposeAgentDeployer, error) { return &DockerComposeAgentDeployer{ profile: options.Profile, stackVersion: options.StackVersion, packageName: options.PackageName, dataStream: options.DataStream, policyName: options.PolicyName, runTearDown: options.RunTearDown, runTestsOnly: options.RunTestsOnly, }, nil } // SetUp sets up the service and returns any relevant information. func (d *DockerComposeAgentDeployer) SetUp(ctx context.Context, agentInfo AgentInfo) (DeployedAgent, error) { logger.Debug("setting up agent using Docker Compose agent deployer") d.agentRunID = agentInfo.Test.RunID appConfig, err := install.Configuration(install.OptionWithStackVersion(d.stackVersion)) if err != nil { return nil, fmt.Errorf("can't read application configuration: %w", err) } caCertPath, err := stack.FindCACertificate(d.profile) if err != nil { return nil, fmt.Errorf("can't locate CA certificate: %w", err) } env := append( appConfig.StackImageRefs().AsEnv(), fmt.Sprintf("%s=%s", serviceLogsDirEnv, agentInfo.Logs.Folder.Local), fmt.Sprintf("%s=%s", localCACertEnv, caCertPath), fmt.Sprintf("%s=%s", fleetPolicyEnv, d.policyName), fmt.Sprintf("%s=%s", agentHostnameEnv, d.agentHostname()), ) configDir, err := d.installDockerCompose(ctx, agentInfo) if err != nil { return nil, fmt.Errorf("could not create resources for custom agent: %w", err) } composeProjectName := fmt.Sprintf("elastic-package-agent-%s-%s", d.agentName(), agentInfo.Test.RunID) agent := dockerComposeDeployedAgent{ ymlPaths: []string{filepath.Join(configDir, dockerTestAgentDockerCompose)}, project: composeProjectName, env: env, configDir: configDir, } agentInfo.NetworkName = fmt.Sprintf("%s_default", composeProjectName) p, err := compose.NewProject(agent.project, agent.ymlPaths...) if err != nil { return nil, fmt.Errorf("could not create Docker Compose project for agent: %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 %s", agentInfo.Logs.Folder.Local) } else { err = files.RemoveContent(agentInfo.Logs.Folder.Local) if err != nil { return nil, fmt.Errorf("removing service logs failed: %w", err) } } // Service name defined in the docker-compose file agentInfo.Name = dockerTestAgentServiceName agentName := agentInfo.Name opts := compose.CommandOptions{ Env: env, ExtraArgs: []string{"--build", "-d"}, } if d.runTestsOnly || d.runTearDown { logger.Debug("Skipping bringing up docker-compose project and connect container to network (non setup steps)") } else { err = p.Up(ctx, opts) if err != nil { return nil, fmt.Errorf("could not boot up agent using Docker Compose: %w", err) } // Connect service network with stack network (for the purpose of metrics collection) err = docker.ConnectToNetwork(p.ContainerName(agentName), stack.Network(d.profile)) if err != nil { return nil, fmt.Errorf("can't attach agent container to the stack network: %w", err) } } // requires to be connected the service to the stack network err = p.WaitForHealthy(ctx, opts) if err != nil { processAgentContainerLogs(ctx, p, compose.CommandOptions{ Env: opts.Env, }, agentName) return nil, fmt.Errorf("service is unhealthy: %w", err) } // Build agent container name // For those packages that require to do requests to agent ports in their tests (e.g. ti_anomali), // using the ContainerName of the agent (p.ContainerName(agentName)) as in servicedeployer does not work, // probably because it is in another compose project in case of ti_anomali?. agentInfo.Hostname = d.agentHostname() logger.Debugf("adding service container %s internal ports to context", p.ContainerName(agentName)) serviceComposeConfig, err := p.Config(ctx, compose.CommandOptions{Env: env}) if err != nil { return nil, fmt.Errorf("could not get Docker Compose configuration for service: %w", err) } s := serviceComposeConfig.Services[agentName] agentInfo.Ports = make([]int, len(s.Ports)) for idx, port := range s.Ports { agentInfo.Ports[idx] = port.InternalPort } // Shortcut to first port for convenience if len(agentInfo.Ports) > 0 { agentInfo.Port = agentInfo.Ports[0] } agentInfo.Agent.Host.NamePrefix = agentInfo.Name agent.agentInfo = agentInfo return &agent, nil } func (d *DockerComposeAgentDeployer) agentHostname() string { return fmt.Sprintf("%s-%s", dockerTestAgentServiceName, d.agentRunID) } func (d *DockerComposeAgentDeployer) agentName() string { name := d.packageName if d.dataStream != "" && d.dataStream != "." { name = fmt.Sprintf("%s-%s", name, d.dataStream) } return name } // installDockerCompose creates the files needed to run the custom elastic agent and returns // the directory with these files. func (d *DockerComposeAgentDeployer) installDockerCompose(ctx context.Context, agentInfo AgentInfo) (string, error) { customAgentDir, err := CreateDeployerDir(d.profile, fmt.Sprintf("docker-agent-%s-%s", d.agentName(), d.agentRunID)) if err != nil { return "", fmt.Errorf("failed to create directory for custom agent files: %w", err) } hashDockerfile := []byte{} if agentInfo.Agent.ProvisioningScript.Contents != "" || agentInfo.Agent.PreStartScript.Contents != "" { err = d.installDockerfileResources(agentInfo.Agent.AgentSettings, customAgentDir) if err != nil { return "", fmt.Errorf("failed to create dockerfile resources: %w", err) } hashDockerfile, err = hashFile(filepath.Join(customAgentDir, dockerTestAgentDockerfile)) if err != nil { return "", fmt.Errorf("failed to obtain has for Elastic Agent Dockerfile: %w", err) } } config, err := stack.LoadConfig(d.profile) if err != nil { return "", fmt.Errorf("failed to load config from profile: %w", err) } enrollmentToken := "" if config.ElasticsearchAPIKey != "" { // TODO: Review if this is the correct place to get the enrollment token. kibanaClient, err := stack.NewKibanaClientFromProfile(d.profile) if err != nil { return "", fmt.Errorf("failed to create kibana client: %w", err) } enrollmentToken, err = kibanaClient.GetEnrollmentTokenForPolicyID(ctx, agentInfo.Policy.ID) if err != nil { return "", fmt.Errorf("failed to get enrollment token for policy %q: %w", agentInfo.Policy.Name, err) } } // TODO: Include these settings more explicitly in `config`. fleetURL := "https://fleet-server:8220" kibanaHost := "https://kibana:5601" stackVersion := d.stackVersion if config.Provider != stack.ProviderCompose { kibanaHost = config.KibanaHost } if url, ok := config.Parameters[stack.ParamServerlessFleetURL]; ok { fleetURL = url } if version, ok := config.Parameters[stack.ParamServerlessLocalStackVersion]; ok { stackVersion = version } agentImage, err := selectElasticAgentImage(stackVersion, agentInfo.Agent.BaseImage) if err != nil { return "", nil } resourceManager := resource.NewManager() resourceManager.AddFacter(resource.StaticFacter{ "agent_image": agentImage, "user": agentInfo.Agent.User, "capabilities": strings.Join(agentInfo.Agent.LinuxCapabilities, ","), "runtime": agentInfo.Agent.Runtime, "pid_mode": agentInfo.Agent.PidMode, "ports": strings.Join(agentInfo.Agent.Ports, ","), "dockerfile_hash": hex.EncodeToString(hashDockerfile), "stack_version": stackVersion, "fleet_url": fleetURL, "kibana_host": stack.DockerInternalHost(kibanaHost), "elasticsearch_username": config.ElasticsearchUsername, "elasticsearch_password": config.ElasticsearchPassword, "enrollment_token": enrollmentToken, }) resourceManager.RegisterProvider("file", &resource.FileProvider{ Prefix: customAgentDir, }) agentResources := []resource.Resource{ &resource.File{ Path: dockerTestAgentDockerCompose, Content: staticSource.Template("_static/docker-agent-base.yml.tmpl"), }, } results, err := resourceManager.Apply(agentResources) if err != nil { return "", fmt.Errorf("%w: %s", err, common.ProcessResourceApplyResults(results)) } return customAgentDir, nil } func selectElasticAgentImage(stackVersion, agentBaseImage string) (string, error) { appConfig, err := install.Configuration(install.OptionWithAgentBaseImage(agentBaseImage), install.OptionWithStackVersion(stackVersion)) if err != nil { return "", fmt.Errorf("can't read application configuration: %w", err) } agentImage := appConfig.StackImageRefs().ElasticAgent return agentImage, nil } func (d *DockerComposeAgentDeployer) installDockerfileResources(agentSettings AgentSettings, folder string) error { agentResources := []resource.Resource{ &resource.File{ Path: dockerTestAgentDockerfile, Content: staticSource.Template("_static/dockerfile.tmpl"), }, } if agentSettings.ProvisioningScript.Contents != "" { agentResources = append(agentResources, &resource.File{ Path: customScriptFilename, Mode: resource.FileMode(0o755), Content: resource.FileContentLiteral(agentSettings.ProvisioningScript.Contents), }) } if agentSettings.PreStartScript.Contents != "" { agentResources = append(agentResources, &resource.File{ Path: customEntrypointFilename, Mode: resource.FileMode(0o755), Content: staticSource.Template("_static/custom-entrypoint.sh.tmpl"), }) } resourceManager := resource.NewManager() resourceManager.AddFacter(resource.StaticFacter{ "provisioning_script_contents": agentSettings.ProvisioningScript.Contents, "provisioning_script_language": agentSettings.ProvisioningScript.Language, "provisioning_script_filename": customScriptFilename, "pre_start_script_contents": agentSettings.PreStartScript.Contents, "entrypoint_script_filename": customEntrypointFilename, "agent_name": d.agentName(), }) resourceManager.RegisterProvider("file", &resource.FileProvider{ Prefix: folder, }) results, err := resourceManager.Apply(agentResources) if err != nil { return fmt.Errorf("%w: %s", err, common.ProcessResourceApplyResults(results)) } return nil } func hashFile(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { return []byte{}, err } dockerfileMD5 := md5.Sum(data) return dockerfileMD5[:], nil } // ExitCode returns true if the agent is exited and its exit code. func (s *dockerComposeDeployedAgent) ExitCode(ctx context.Context) (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 agent: %w", err) } opts := compose.CommandOptions{Env: s.env} return p.ServiceExitCode(ctx, s.agentInfo.Name, opts) } // Logs returns the logs from the agent starting at the given time func (s *dockerComposeDeployedAgent) Logs(ctx context.Context, t time.Time) ([]byte, error) { p, err := compose.NewProject(s.project, s.ymlPaths...) if err != nil { return nil, fmt.Errorf("could not create Docker Compose project for agent: %w", err) } opts := compose.CommandOptions{Env: s.env} return p.Logs(ctx, opts) } // TearDown tears down the agent. func (s *dockerComposeDeployedAgent) TearDown(ctx context.Context) error { logger.Debugf("tearing down agent using Docker Compose runner") defer func() { // Remove the service logs dir for this agent if err := os.RemoveAll(s.agentInfo.Logs.Folder.Local); err != nil { logger.Errorf("could not remove the agent logs (path: %s): %v", s.agentInfo.Logs.Folder.Local, err) } // Remove the configuration dir for this agent (e.g. compose scenario files) if err := os.RemoveAll(s.configDir); err != nil { logger.Errorf("could not remove the agent 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: s.env} processAgentContainerLogs(ctx, p, opts, s.agentInfo.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 agent using Docker Compose: %w", err) } return nil } // Info returns the current context for the agent. func (s *dockerComposeDeployedAgent) Info() AgentInfo { return s.agentInfo } // SetInfo sets the current context for the agent. func (s *dockerComposeDeployedAgent) SetInfo(info AgentInfo) { s.agentInfo = info }