e391e89f3e
* Initial implementation of JobReport with SourceManager usage * Limit concurrent units * Only save the last JobReport per handle
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package sources
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// handle uniquely identifies a Source given to the manager to manage. If the
|
|
// SourceManager is connected to the API, it will be equivalent to the unique
|
|
// source ID, otherwise it behaves as a counter.
|
|
type handle int64
|
|
|
|
// SourceInitFunc is a function that takes a source and job ID and returns an
|
|
// initialized Source.
|
|
type SourceInitFunc func(ctx context.Context, sourceID int64, jobID int64) (Source, error)
|
|
|
|
type SourceManager struct {
|
|
api apiClient
|
|
// Map of handle to source initializer.
|
|
handles map[handle]SourceInitFunc
|
|
handlesLock sync.Mutex
|
|
// Map of handle to job reports.
|
|
// TODO: Manage culling and flushing to the API.
|
|
report map[handle]*JobReport
|
|
reportLock sync.Mutex
|
|
// Pool limiting the amount of concurrent sources running.
|
|
pool errgroup.Group
|
|
concurrentUnits int
|
|
// Run the sources using source unit enumeration / chunking if available.
|
|
useSourceUnits bool
|
|
// Downstream chunks channel to be scanned.
|
|
outputChunks chan *Chunk
|
|
// Set when Wait() returns.
|
|
done bool
|
|
doneErr error
|
|
}
|
|
|
|
// apiClient is an interface for optionally communicating with an external API.
|
|
type apiClient interface {
|
|
// RegisterSource lets the API know we have a source and it returns a unique source ID for it.
|
|
RegisterSource(ctx context.Context, name string, kind sourcespb.SourceType) (int64, error)
|
|
// GetJobID queries the API for an existing job or new job ID.
|
|
GetJobID(ctx context.Context, id int64) (int64, error)
|
|
}
|
|
|
|
// WithAPI adds an API client to the manager for tracking jobs and progress.
|
|
func WithAPI(api apiClient) func(*SourceManager) {
|
|
return func(mgr *SourceManager) { mgr.api = api }
|
|
}
|
|
|
|
// WithConcurrency limits the concurrent number of sources a manager can run.
|
|
func WithConcurrency(concurrency int) func(*SourceManager) {
|
|
return func(mgr *SourceManager) { mgr.pool.SetLimit(concurrency) }
|
|
}
|
|
|
|
// WithBufferedOutput sets the size of the buffer used for the Chunks() channel.
|
|
func WithBufferedOutput(size int) func(*SourceManager) {
|
|
return func(mgr *SourceManager) { mgr.outputChunks = make(chan *Chunk, size) }
|
|
}
|
|
|
|
// WithSourceUnits enables using source unit enumeration and chunking if the
|
|
// source supports it.
|
|
func WithSourceUnits() func(*SourceManager) {
|
|
return func(mgr *SourceManager) { mgr.useSourceUnits = true }
|
|
}
|
|
|
|
// WithConcurrentUnits limits the number of units to be scanned concurrently.
|
|
// The default is unlimited.
|
|
func WithConcurrentUnits(n int) func(*SourceManager) {
|
|
return func(mgr *SourceManager) { mgr.concurrentUnits = n }
|
|
}
|
|
|
|
// NewManager creates a new manager with the provided options.
|
|
func NewManager(opts ...func(*SourceManager)) *SourceManager {
|
|
mgr := SourceManager{
|
|
// Default to the headless API. Can be overwritten by the WithAPI option.
|
|
api: &headlessAPI{},
|
|
handles: make(map[handle]SourceInitFunc),
|
|
report: make(map[handle]*JobReport),
|
|
outputChunks: make(chan *Chunk),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(&mgr)
|
|
}
|
|
return &mgr
|
|
}
|
|
|
|
// Enroll informs the SourceManager to track and manage a Source.
|
|
func (s *SourceManager) Enroll(ctx context.Context, name string, kind sourcespb.SourceType, f SourceInitFunc) (handle, error) {
|
|
if s.done {
|
|
return 0, fmt.Errorf("manager is done")
|
|
}
|
|
id, err := s.api.RegisterSource(ctx, name, kind)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
handleID := handle(id)
|
|
s.handlesLock.Lock()
|
|
defer s.handlesLock.Unlock()
|
|
if _, ok := s.handles[handleID]; ok {
|
|
// TODO: smartly handle this?
|
|
return 0, fmt.Errorf("handle ID '%d' already in use", handleID)
|
|
}
|
|
s.handles[handleID] = f
|
|
return handleID, nil
|
|
}
|
|
|
|
// Run blocks until a resource is available to run the source, then
|
|
// synchronously runs it.
|
|
func (s *SourceManager) Run(ctx context.Context, handle handle) error {
|
|
// Do preflight checks before waiting on the pool.
|
|
if err := s.preflightChecks(ctx, handle); err != nil {
|
|
return err
|
|
}
|
|
ch := make(chan error)
|
|
s.pool.Go(func() error {
|
|
defer common.Recover(ctx)
|
|
report, err := s.run(ctx, handle)
|
|
if report != nil {
|
|
s.reportLock.Lock()
|
|
s.report[handle] = report
|
|
s.reportLock.Unlock()
|
|
}
|
|
if err != nil {
|
|
ch <- err
|
|
return nil
|
|
}
|
|
ch <- report.Errors()
|
|
return nil
|
|
})
|
|
return <-ch
|
|
}
|
|
|
|
// ScheduleRun blocks until a resource is available to run the source, then
|
|
// asynchronously runs it. Error information is stored and returned by Wait().
|
|
func (s *SourceManager) ScheduleRun(ctx context.Context, handle handle) error {
|
|
// Do preflight checks before waiting on the pool.
|
|
if err := s.preflightChecks(ctx, handle); err != nil {
|
|
return err
|
|
}
|
|
s.pool.Go(func() error {
|
|
defer common.Recover(ctx)
|
|
// The error is already saved in the report, so we can ignore
|
|
// it here.
|
|
report, _ := s.run(ctx, handle)
|
|
if report != nil {
|
|
s.reportLock.Lock()
|
|
s.report[handle] = report
|
|
s.reportLock.Unlock()
|
|
}
|
|
return nil
|
|
})
|
|
// TODO: Maybe wait for a signal here that initialization was successful?
|
|
return nil
|
|
}
|
|
|
|
// Chunks returns the read only channel of all the chunks produced by all of
|
|
// the sources managed by this manager.
|
|
func (s *SourceManager) Chunks() <-chan *Chunk {
|
|
return s.outputChunks
|
|
}
|
|
|
|
// Wait blocks until all running sources are completed and returns an error if
|
|
// any of the sources had fatal errors. It also closes the channel returned by
|
|
// Chunks(). The manager should not be reused after calling this method.
|
|
func (s *SourceManager) Wait() error {
|
|
// Check if the manager has been Waited.
|
|
if s.done {
|
|
return s.doneErr
|
|
}
|
|
defer close(s.outputChunks)
|
|
defer func() { s.done = true }()
|
|
|
|
// We are only using the errgroup for limiting concurrency.
|
|
// TODO: Maybe switch to using a semaphore.Weighted.
|
|
_ = s.pool.Wait()
|
|
|
|
// Aggregate all errors from all job reports.
|
|
// TODO: This should probably only be the fatal errors. We'll also need
|
|
// to rewrite this for when the reports start getting culled.
|
|
s.reportLock.Lock()
|
|
defer s.reportLock.Unlock()
|
|
errs := make([]error, 0, len(s.report))
|
|
for _, report := range s.report {
|
|
errs = append(errs, report.Errors())
|
|
}
|
|
s.doneErr = errors.Join(errs...)
|
|
return s.doneErr
|
|
}
|
|
|
|
// Report retrieves a scan report for a given handle. If no report exists or
|
|
// the Source has not finished, nil will be returned.
|
|
func (s *SourceManager) Report(handle handle) *JobReport {
|
|
s.reportLock.Lock()
|
|
defer s.reportLock.Unlock()
|
|
return s.report[handle]
|
|
}
|
|
|
|
// preflightChecks is a helper method to check the Manager or the context isn't
|
|
// done and that the handle is valid.
|
|
func (s *SourceManager) preflightChecks(ctx context.Context, handle handle) error {
|
|
// Check if the manager has been Waited.
|
|
if s.done {
|
|
return fmt.Errorf("manager is done")
|
|
}
|
|
// Check the handle is valid.
|
|
if _, ok := s.getInitFunc(handle); !ok {
|
|
return fmt.Errorf("unrecognized handle")
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// run is a helper method to sychronously run the source. It does not check for
|
|
// acquired resources. Possible return values are:
|
|
//
|
|
// - *JobReport, nil
|
|
// Successfully ran the source, but the report could have errors.
|
|
//
|
|
// - *JobReport, error
|
|
// There was an error calling Init or Chunks. This sort of error indicates
|
|
// a fatal error and is also recorded in the report.
|
|
//
|
|
// - nil, error:
|
|
// There was an error from the API or the handle is invalid. The latter of
|
|
// which should never happen due to the preflightChecks.
|
|
func (s *SourceManager) run(ctx context.Context, handle handle) (*JobReport, error) {
|
|
jobID, err := s.api.GetJobID(ctx, int64(handle))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
initFunc, ok := s.getInitFunc(handle)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unrecognized handle")
|
|
}
|
|
// Create a report for this run.
|
|
report := &JobReport{
|
|
SourceID: int64(handle),
|
|
JobID: jobID,
|
|
StartTime: time.Now(),
|
|
}
|
|
defer func() { report.EndTime = time.Now() }()
|
|
|
|
// Initialize the source.
|
|
source, err := initFunc(ctx, jobID, int64(handle))
|
|
if err != nil {
|
|
report.AddError(err)
|
|
return report, err
|
|
}
|
|
// Check for the preferred method of tracking source units.
|
|
if enumChunker, ok := source.(SourceUnitEnumChunker); ok && s.useSourceUnits {
|
|
return s.runWithUnits(ctx, handle, enumChunker, report)
|
|
}
|
|
return s.runWithoutUnits(ctx, handle, source, report)
|
|
}
|
|
|
|
// runWithoutUnits is a helper method to run a Source. It has coarse-grained
|
|
// job reporting.
|
|
func (s *SourceManager) runWithoutUnits(ctx context.Context, handle handle, source Source, report *JobReport) (*JobReport, error) {
|
|
// Introspect on the chunks we get from the Chunks method.
|
|
ch := make(chan *Chunk)
|
|
var wg sync.WaitGroup
|
|
// Consume chunks and export chunks.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for chunk := range ch {
|
|
atomic.AddUint64(&report.TotalChunks, 1)
|
|
_ = common.CancellableWrite(ctx, s.outputChunks, chunk)
|
|
}
|
|
}()
|
|
// Don't return from this function until the goroutine has finished
|
|
// outputting chunks to the downstream channel. Closing the channel
|
|
// will stop the goroutine, so that needs to happen first in the defer
|
|
// stack.
|
|
defer wg.Wait()
|
|
defer close(ch)
|
|
if err := source.Chunks(ctx, ch); err != nil {
|
|
report.AddError(err)
|
|
return report, err
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
// runWithUnits is a helper method to run a Source that is also a
|
|
// SourceUnitEnumChunker. This allows better introspection of what is getting
|
|
// scanned and any errors encountered.
|
|
func (s *SourceManager) runWithUnits(ctx context.Context, handle handle, source SourceUnitEnumChunker, report *JobReport) (*JobReport, error) {
|
|
reporter := &mgrUnitReporter{
|
|
unitCh: make(chan SourceUnit),
|
|
}
|
|
// Produce units.
|
|
go func() {
|
|
// TODO: Catch panics and add to report.
|
|
defer close(reporter.unitCh)
|
|
if err := source.Enumerate(ctx, reporter); err != nil {
|
|
report.AddError(err)
|
|
}
|
|
}()
|
|
var wg sync.WaitGroup
|
|
// TODO: Maybe switch to using a semaphore.Weighted.
|
|
var unitPool errgroup.Group
|
|
if s.concurrentUnits != 0 {
|
|
// Negative values indicated no limit.
|
|
unitPool.SetLimit(s.concurrentUnits)
|
|
}
|
|
for unit := range reporter.unitCh {
|
|
reporter := &mgrChunkReporter{
|
|
unitID: unit.SourceUnitID(),
|
|
chunkCh: make(chan *Chunk),
|
|
}
|
|
unit := unit
|
|
// Consume units and produce chunks.
|
|
unitPool.Go(func() error {
|
|
// TODO: Catch panics and add to report.
|
|
defer close(reporter.chunkCh)
|
|
if err := source.ChunkUnit(ctx, unit, reporter); err != nil {
|
|
report.AddError(err)
|
|
}
|
|
return nil
|
|
})
|
|
// Consume chunks and export chunks.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for chunk := range reporter.chunkCh {
|
|
// TODO: Introspect on the chunks we got from this unit.
|
|
atomic.AddUint64(&report.TotalChunks, 1)
|
|
_ = common.CancellableWrite(ctx, s.outputChunks, chunk)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// TODO: Return fatal errors.
|
|
return report, nil
|
|
}
|
|
|
|
// getInitFunc is a helper method for safe concurrent access to the
|
|
// map[handle]SourceInitFunc map.
|
|
func (s *SourceManager) getInitFunc(handle handle) (SourceInitFunc, bool) {
|
|
s.handlesLock.Lock()
|
|
defer s.handlesLock.Unlock()
|
|
f, ok := s.handles[handle]
|
|
return f, ok
|
|
}
|
|
|
|
// headlessAPI implements the apiClient interface locally.
|
|
type headlessAPI struct {
|
|
// Counters for assigning handle and job IDs.
|
|
sourceIDCounter int64
|
|
jobIDCounter int64
|
|
}
|
|
|
|
func (api *headlessAPI) RegisterSource(ctx context.Context, name string, kind sourcespb.SourceType) (int64, error) {
|
|
return atomic.AddInt64(&api.sourceIDCounter, 1), nil
|
|
}
|
|
|
|
func (api *headlessAPI) GetJobID(ctx context.Context, id int64) (int64, error) {
|
|
return atomic.AddInt64(&api.jobIDCounter, 1), nil
|
|
}
|
|
|
|
// mgrUnitReporter implements the UnitReporter interface.
|
|
type mgrUnitReporter struct {
|
|
unitCh chan SourceUnit
|
|
unitErrs []error
|
|
unitErrsLock sync.Mutex
|
|
}
|
|
|
|
func (s *mgrUnitReporter) UnitOk(ctx context.Context, unit SourceUnit) error {
|
|
return common.CancellableWrite(ctx, s.unitCh, unit)
|
|
}
|
|
|
|
func (s *mgrUnitReporter) UnitErr(ctx context.Context, err error) error {
|
|
s.unitErrsLock.Lock()
|
|
defer s.unitErrsLock.Unlock()
|
|
s.unitErrs = append(s.unitErrs, err)
|
|
return nil
|
|
}
|
|
|
|
// mgrChunkReporter implements the ChunkReporter interface.
|
|
type mgrChunkReporter struct {
|
|
unitID string
|
|
chunkCh chan *Chunk
|
|
chunkErrs []error
|
|
chunkErrsLock sync.Mutex
|
|
}
|
|
|
|
func (s *mgrChunkReporter) ChunkOk(ctx context.Context, chunk Chunk) error {
|
|
return common.CancellableWrite(ctx, s.chunkCh, &chunk)
|
|
}
|
|
|
|
func (s *mgrChunkReporter) ChunkErr(ctx context.Context, err error) error {
|
|
s.chunkErrsLock.Lock()
|
|
defer s.chunkErrsLock.Unlock()
|
|
s.chunkErrs = append(s.chunkErrs, err)
|
|
return nil
|
|
}
|