github.com/seilagamo/poc-lava-release@v0.3.3-rc3/internal/engine/engine.go (about)

     1  // Copyright 2023 Adevinta
     2  
     3  // Package engine runs Vulcan checks and retrieves the generated
     4  // reports.
     5  package engine
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"log/slog"
    11  	"net"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/adevinta/vulcan-agent/agent"
    16  	"github.com/adevinta/vulcan-agent/backend"
    17  	"github.com/adevinta/vulcan-agent/backend/docker"
    18  	agentconfig "github.com/adevinta/vulcan-agent/config"
    19  	"github.com/adevinta/vulcan-agent/jobrunner"
    20  	"github.com/adevinta/vulcan-agent/queue"
    21  	"github.com/adevinta/vulcan-agent/queue/chanqueue"
    22  	report "github.com/adevinta/vulcan-report"
    23  	types "github.com/adevinta/vulcan-types"
    24  
    25  	"github.com/seilagamo/poc-lava-release/internal/checktypes"
    26  	"github.com/seilagamo/poc-lava-release/internal/config"
    27  	"github.com/seilagamo/poc-lava-release/internal/dockerutil"
    28  	"github.com/seilagamo/poc-lava-release/internal/metrics"
    29  )
    30  
    31  // dockerInternalHost is the host used by the containers to access the
    32  // services exposed by the Docker host.
    33  const dockerInternalHost = "host.lava.internal"
    34  
    35  // Report is a collection of reports returned by Vulcan checks and
    36  // indexed by check ID.
    37  type Report map[string]report.Report
    38  
    39  // Run runs vulcan checks and returns the generated report. The check
    40  // list is based on the provided checktypes and targets. These checks
    41  // are run by a Vulcan agent, which is configured using the specified
    42  // configuration.
    43  func Run(checktypeURLs []string, targets []config.Target, cfg config.AgentConfig) (Report, error) {
    44  	srv, err := newTargetServer()
    45  	if err != nil {
    46  		return nil, fmt.Errorf("new server: %w", err)
    47  	}
    48  	defer srv.Close()
    49  
    50  	catalog, err := checktypes.NewCatalog(checktypeURLs)
    51  	if err != nil {
    52  		return nil, fmt.Errorf("get checkype catalog: %w", err)
    53  	}
    54  
    55  	metrics.Collect("checktypes", catalog)
    56  
    57  	jl, err := generateJobs(catalog, targets)
    58  	if err != nil {
    59  		return nil, fmt.Errorf("create job list: %w", err)
    60  	}
    61  
    62  	if len(jl) == 0 {
    63  		return nil, nil
    64  	}
    65  
    66  	return runAgent(jl, srv, cfg)
    67  }
    68  
    69  // summaryInterval is the time between summary logs.
    70  const summaryInterval = 15 * time.Second
    71  
    72  // runAgent creates a Vulcan agent using the specified config and uses
    73  // it to run the provided jobs.
    74  func runAgent(jobs []jobrunner.Job, srv *targetServer, cfg config.AgentConfig) (Report, error) {
    75  	alogger := newAgentLogger(slog.Default())
    76  
    77  	agentConfig, err := newAgentConfig(cfg)
    78  	if err != nil {
    79  		return nil, fmt.Errorf("get agent config: %w", err)
    80  	}
    81  
    82  	br := func(params backend.RunParams, rc *docker.RunConfig) error {
    83  		return beforeRun(params, rc, srv)
    84  	}
    85  
    86  	backend, err := docker.NewBackend(alogger, agentConfig, br)
    87  	if err != nil {
    88  		return nil, fmt.Errorf("new Docker backend: %w", err)
    89  	}
    90  
    91  	// Create a state queue and discard all messages.
    92  	stateQueue := chanqueue.New(queue.Discard())
    93  	stateQueue.StartReading(context.Background())
    94  
    95  	jobsQueue := chanqueue.New(nil)
    96  	if err := sendJobs(jobs, jobsQueue); err != nil {
    97  		return nil, fmt.Errorf("send jobs: %w", err)
    98  	}
    99  
   100  	rs := &reportStore{}
   101  
   102  	done := make(chan bool)
   103  	go func() {
   104  		for {
   105  			select {
   106  			case <-done:
   107  				return
   108  			case <-time.After(summaryInterval):
   109  				sums := rs.Summary()
   110  				if len(sums) == 0 {
   111  					slog.Info("waiting for updates")
   112  					break
   113  				}
   114  				for _, s := range sums {
   115  					slog.Info(s)
   116  				}
   117  			}
   118  		}
   119  	}()
   120  
   121  	exitCode := agent.RunWithQueues(agentConfig, rs, backend, stateQueue, jobsQueue, alogger)
   122  	if exitCode != 0 {
   123  		return nil, fmt.Errorf("run agent: exit code %v", exitCode)
   124  	}
   125  
   126  	done <- true
   127  
   128  	report, err := mkReport(rs, srv)
   129  	if err != nil {
   130  		return nil, fmt.Errorf("restore targets: %w", err)
   131  	}
   132  
   133  	return report, nil
   134  }
   135  
   136  // mkReport generates a report from the information stored in the
   137  // provided [reportStore]. It uses the provided [targetServer] to
   138  // replace the targets sent to the checks with the original targets.
   139  func mkReport(rs *reportStore, srv *targetServer) (Report, error) {
   140  	rep := make(Report)
   141  	for checkID, r := range rs.Reports() {
   142  		tm, ok := srv.TargetMap(checkID)
   143  		if !ok {
   144  			rep[checkID] = r
   145  			continue
   146  		}
   147  
   148  		tmAddrs := tm.Addrs()
   149  
   150  		slog.Info("applying target map", "check", checkID, "tm", tm, "tmAddr", tmAddrs)
   151  
   152  		r.Target = tm.OldIdentifier
   153  
   154  		var vulns []report.Vulnerability
   155  		for _, vuln := range r.Vulnerabilities {
   156  			vuln = vulnReplaceAll(vuln, tm.NewIdentifier, tm.OldIdentifier)
   157  			vuln = vulnReplaceAll(vuln, tmAddrs.NewIdentifier, tmAddrs.OldIdentifier)
   158  			vulns = append(vulns, vuln)
   159  		}
   160  		r.Vulnerabilities = vulns
   161  
   162  		rep[checkID] = r
   163  	}
   164  	return rep, nil
   165  }
   166  
   167  // vulnReplaceAll returns a copy of the vulnerability vuln with all
   168  // non-overlapping instances of old replaced by new.
   169  func vulnReplaceAll(vuln report.Vulnerability, old, new string) report.Vulnerability {
   170  	vuln.Summary = strings.ReplaceAll(vuln.Summary, old, new)
   171  	vuln.AffectedResource = strings.ReplaceAll(vuln.AffectedResource, old, new)
   172  	vuln.AffectedResourceString = strings.ReplaceAll(vuln.AffectedResourceString, old, new)
   173  	vuln.Description = strings.ReplaceAll(vuln.Description, old, new)
   174  	vuln.Details = strings.ReplaceAll(vuln.Details, old, new)
   175  	vuln.ImpactDetails = strings.ReplaceAll(vuln.ImpactDetails, old, new)
   176  
   177  	var labels []string
   178  	for _, label := range vuln.Labels {
   179  		labels = append(labels, strings.ReplaceAll(label, old, new))
   180  	}
   181  	vuln.Labels = labels
   182  
   183  	var recs []string
   184  	for _, rec := range vuln.Recommendations {
   185  		recs = append(recs, strings.ReplaceAll(rec, old, new))
   186  	}
   187  	vuln.Recommendations = recs
   188  
   189  	var refs []string
   190  	for _, ref := range vuln.References {
   191  		refs = append(refs, strings.ReplaceAll(ref, old, new))
   192  	}
   193  	vuln.References = refs
   194  
   195  	var rscs []report.ResourcesGroup
   196  	for _, rsc := range vuln.Resources {
   197  		rscs = append(rscs, rscReplaceAll(rsc, old, new))
   198  	}
   199  	vuln.Resources = rscs
   200  
   201  	var vulns []report.Vulnerability
   202  	for _, vuln := range vuln.Vulnerabilities {
   203  		vulns = append(vulns, vulnReplaceAll(vuln, old, new))
   204  	}
   205  	vuln.Vulnerabilities = vulns
   206  
   207  	return vuln
   208  }
   209  
   210  // rscReplaceAll returns a copy of the resource group rsc with all
   211  // non-overlapping instances of old replaced by new.
   212  func rscReplaceAll(rsc report.ResourcesGroup, old, new string) report.ResourcesGroup {
   213  	rsc.Name = strings.ReplaceAll(rsc.Name, old, new)
   214  
   215  	var hdrs []string
   216  	for _, hdr := range rsc.Header {
   217  		hdrs = append(hdrs, strings.ReplaceAll(hdr, old, new))
   218  	}
   219  	rsc.Header = hdrs
   220  
   221  	var rows []map[string]string
   222  	for _, r := range rsc.Rows {
   223  		row := make(map[string]string)
   224  		for k, v := range r {
   225  			k = strings.ReplaceAll(k, old, new)
   226  			v = strings.ReplaceAll(v, old, new)
   227  			row[k] = v
   228  		}
   229  		rows = append(rows, row)
   230  	}
   231  	rsc.Rows = rows
   232  
   233  	return rsc
   234  }
   235  
   236  // newAgentConfig creates a new [agentconfig.Config] based on the
   237  // provided Lava configuration.
   238  func newAgentConfig(cfg config.AgentConfig) (agentconfig.Config, error) {
   239  	listenHost, err := bridgeHost()
   240  	if err != nil {
   241  		return agentconfig.Config{}, fmt.Errorf("get listen host: %w", err)
   242  	}
   243  
   244  	parallel := cfg.Parallel
   245  	if parallel == 0 {
   246  		parallel = 1
   247  	}
   248  
   249  	ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0"))
   250  	if err != nil {
   251  		return agentconfig.Config{}, fmt.Errorf("listen: %w", err)
   252  	}
   253  
   254  	auths := []agentconfig.Auth{}
   255  	for _, r := range cfg.RegistryAuths {
   256  		auths = append(auths, agentconfig.Auth{
   257  			Server: r.Server,
   258  			User:   r.Username,
   259  			Pass:   r.Password,
   260  		})
   261  	}
   262  
   263  	acfg := agentconfig.Config{
   264  		Agent: agentconfig.AgentConfig{
   265  			ConcurrentJobs:         parallel,
   266  			MaxNoMsgsInterval:      5,   // Low as all the messages will be in the queue before starting the agent.
   267  			MaxProcessMessageTimes: 1,   // No retry.
   268  			Timeout:                180, // Default timeout of 3 minutes.
   269  		},
   270  		API: agentconfig.APIConfig{
   271  			Host:     dockerInternalHost,
   272  			Listener: ln,
   273  		},
   274  		Check: agentconfig.CheckConfig{
   275  			Vars: cfg.Vars,
   276  		},
   277  		Runtime: agentconfig.RuntimeConfig{
   278  			Docker: agentconfig.DockerConfig{
   279  				Registry: agentconfig.RegistryConfig{
   280  					PullPolicy:          cfg.PullPolicy,
   281  					BackoffMaxRetries:   5,
   282  					BackoffInterval:     5,
   283  					BackoffJitterFactor: 0.5,
   284  					Auths:               auths,
   285  				},
   286  			},
   287  		},
   288  	}
   289  	return acfg, nil
   290  }
   291  
   292  // beforeRun is called by the agent before creating each check
   293  // container.
   294  func beforeRun(params backend.RunParams, rc *docker.RunConfig, srv *targetServer) error {
   295  	// Register a host pointing to the host gateway.
   296  	rc.HostConfig.ExtraHosts = []string{dockerInternalHost + ":host-gateway"}
   297  
   298  	// Allow all checks to scan local assets.
   299  	rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_ALLOW_PRIVATE_IPS", "true")
   300  
   301  	if params.AssetType == string(types.DockerImage) {
   302  		// Due to how reachability is defined by the Vulcan
   303  		// check SDK, local Docker images would be identified
   304  		// as unreachable. So, we disable reachability checks
   305  		// for this type of assets.
   306  		rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_SKIP_REACHABILITY", "true")
   307  
   308  		// Tools like trivy require access to the Docker
   309  		// daemon to scan local Docker images. So, we share
   310  		// the Docker socket with them.
   311  		dockerHost, err := daemonHost()
   312  		if err != nil {
   313  			return fmt.Errorf("get Docker client: %w", err)
   314  		}
   315  
   316  		// Remote Docker daemons are not supported.
   317  		if dockerVol, found := strings.CutPrefix(dockerHost, "unix://"); found {
   318  			rc.HostConfig.Binds = append(rc.HostConfig.Binds, dockerVol+":/var/run/docker.sock")
   319  		}
   320  	}
   321  
   322  	// Proxy local targets and serve Git repositories.
   323  	target := config.Target{
   324  		Identifier: params.Target,
   325  		AssetType:  types.AssetType(params.AssetType),
   326  	}
   327  	if tm, err := srv.Handle(params.CheckID, target); err == nil {
   328  		if !tm.IsZero() {
   329  			rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_CHECK_TARGET", tm.NewIdentifier)
   330  			rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_CHECK_ASSET_TYPE", string(tm.NewAssetType))
   331  		}
   332  	} else {
   333  		slog.Warn("could not handle target", "target", target, "err", err)
   334  	}
   335  
   336  	return nil
   337  }
   338  
   339  // daemonHost returns the Docker daemon host.
   340  func daemonHost() (string, error) {
   341  	cli, err := dockerutil.NewAPIClient()
   342  	if err != nil {
   343  		return "", fmt.Errorf("get Docker client: %w", err)
   344  	}
   345  	defer cli.Close()
   346  
   347  	return cli.DaemonHost(), nil
   348  }
   349  
   350  // bridgeHost returns a host that points to the Docker host and is
   351  // reachable from the containers running in the default bridge.
   352  func bridgeHost() (string, error) {
   353  	cli, err := dockerutil.NewAPIClient()
   354  	if err != nil {
   355  		return "", fmt.Errorf("get Docker client: %w", err)
   356  	}
   357  	defer cli.Close()
   358  
   359  	host, err := dockerutil.BridgeHost(cli)
   360  	if err != nil {
   361  		return "", fmt.Errorf("get bridge host: %w", err)
   362  	}
   363  
   364  	return host, nil
   365  }
   366  
   367  // setenv sets the value of the variable named by the key in the
   368  // provided environment. An environment consists on a slice of strings
   369  // with the format "key=value".
   370  func setenv(env []string, key, value string) []string {
   371  	for i, ev := range env {
   372  		if strings.HasPrefix(ev, key+"=") {
   373  			env[i] = fmt.Sprintf("%s=%s", key, value)
   374  			return env
   375  		}
   376  	}
   377  	return append(env, fmt.Sprintf("%s=%s", key, value))
   378  }