diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 7a3c270..bf21e5c 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -81,6 +81,8 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} push: true - name: Build and push (custodian) uses: docker/build-push-action@v5 @@ -90,6 +92,8 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.custodian-meta.outputs.tags }} labels: ${{ steps.custodian-meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} push: true - name: Build and push (ci) uses: docker/build-push-action@v5 @@ -99,4 +103,6 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.ci-meta.outputs.tags }} labels: ${{ steps.ci-meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} push: true \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a695985..fdd4229 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,9 @@ change here must keep working with them. directory, input and policy data through to the API, which canonicalises and hashes them. - **Evidence identity.** `policy-manager`'s `newEvidence` seed is every evidence stream's UUID, and plugins in the field compute it. Never change it. The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs. +- **Plugin library version.** `internal/pluginlib` reads the agent library a plugin binary was built with from its + Go build info. The config report lists it per plugin (`plugins[].lib-version`) as diagnostics only; nothing is + gated on it. - **Storage failure doesn't drop evidence.** If artifact storage fails, the evidence is still sent, without digests. - **OCI policy bundles.** The agent evaluates the extracted `policies/` subdirectory, and that diff --git a/Dockerfile b/Dockerfile index 3f826e9..9b49b52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ RUN go mod download COPY . . -RUN go build -o concom main.go +ARG VERSION=dev +RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go FROM gcr.io/distroless/base-debian12 AS final diff --git a/Dockerfile-ci b/Dockerfile-ci index 0c60f71..2503a22 100644 --- a/Dockerfile-ci +++ b/Dockerfile-ci @@ -8,7 +8,8 @@ RUN go mod download COPY . . -RUN go build -o concom main.go +ARG VERSION=dev +RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go FROM debian:bookworm-slim AS final diff --git a/Dockerfile-custodian b/Dockerfile-custodian index 9e1ef0d..57df359 100644 --- a/Dockerfile-custodian +++ b/Dockerfile-custodian @@ -8,7 +8,8 @@ RUN go mod download COPY . . -RUN go build -o concom main.go +ARG VERSION=dev +RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go # 0.9.49.0 diff --git a/README.md b/README.md index b7a01bd..3864fab 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,17 @@ agent_evidence: enabled: true emit_on_run_completion: true interval: 1h + +remote_config: # Optional: set locally only; see docs/configuration.md#remote-configuration + mode: report # off | report | apply_safe | apply_all; defaults to report with api.auth, off without + poll_interval: 60s + trusted_sources: [] + overridable_config_flags: [] + allow_local_sources: false ``` -See [configuration](./docs/configuration.md) for more information. +See [configuration](./docs/configuration.md) for more information, and +[remote configuration](./docs/configuration.md#remote-configuration) for the `remote_config` block. The agent sets the `_agent` label using the following fallback chain: `api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally a deterministic SHA-256 hash of the runtime plugin and agent diff --git a/cmd/agent.go b/cmd/agent.go index 18a10b4..577674d 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -32,7 +33,9 @@ import ( "github.com/compliance-framework/agent/internal" "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" sdktypes "github.com/compliance-framework/api/sdk/types" "github.com/coreos/go-systemd/v22/daemon" @@ -81,13 +84,48 @@ type agentEvidenceConfig struct { } // agentConfig is the RUNTIME form of the configuration, built from the declared form -// (agentconfig.Config) by toRuntime. +// (agentconfig.Config) by toRuntime. It is immutable once handed to AgentRunner.UpdateConfig, +// except for the protocol resolution AgentRunner.Run performs on its own copy and the sync +// metadata, which the reconciler may update atomically when a new revision leaves the +// effective configuration unchanged. type agentConfig struct { Daemon bool `mapstructure:"daemon"` Verbosity int32 `mapstructure:"verbosity"` ApiConfig *apiConfig `mapstructure:"api"` Plugins map[string]*agentPlugin `mapstructure:"plugins"` AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` + + // sync is what the heartbeat reports about the applied remote configuration (R11, R45). + // Read it with syncInfo; nil means the zero syncMeta. + sync *atomic.Pointer[syncMeta] + // remote is the normalized remote_config block. + remote agentconfig.RemoteConfig +} + +// syncMeta describes the applied remote configuration. +type syncMeta struct { + AppliedRevision int64 // 0 when running the file only + Digest string // agentconfig.Digest of the effective declared config + Mode string // remote_config.mode +} + +// syncInfo returns the sync metadata (safe for concurrent use with setSync). +func (ac *agentConfig) syncInfo() syncMeta { + if ac == nil || ac.sync == nil { + return syncMeta{} + } + if p := ac.sync.Load(); p != nil { + return *p + } + return syncMeta{} +} + +// setSync stores the sync metadata. The first call must happen before the config is shared. +func (ac *agentConfig) setSync(m syncMeta) { + if ac.sync == nil { + ac.sync = &atomic.Pointer[syncMeta]{} + } + ac.sync.Store(&m) } // logVerbosity maps our verbosity "increase" onto hclog's levels: our 0/1/2 = Info/Debug/Trace, @@ -318,6 +356,15 @@ func agentRunner(cmd *cobra.Command, args []string) error { ar := NewAgentRunner(WithInstanceID(id)) rc := newReconciler(cmd, configPath, store, ar, logger) + rc.instanceID = id + pluginLibs := &pluginlib.Cache{} + rc.pluginLib = func(ctx context.Context, source string) (string, error) { + binary, err := ar.downloadPlugin(ctx, source, logger) + if err != nil { + return "", err + } + return pluginLibs.Version(binary) + } rc.onStartupFailure = ar.ReportStartupFailure active, err := rc.startup(context.Background()) @@ -1579,10 +1626,7 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U ) heartbeatCtx, cancel := context.WithTimeout(ctx, time.Second*30) defer cancel() - err := client.Heartbeat.Create(heartbeatCtx, sdktypes.Heartbeat{ - UUID: staticAgentUUID, - CreatedAt: time.Now().UTC(), - }) + err := client.Heartbeat.Create(heartbeatCtx, buildHeartbeat(config, staticAgentUUID, time.Now().UTC())) if err != nil { logger.Error("Error sending heartbeat via SDK", "error", err, "uuid", staticAgentUUID.String()) return err @@ -1591,6 +1635,19 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U return nil } +// buildHeartbeat builds the heartbeat body. When remote configuration is not off it carries +// the applied revision (0 when running the file only, never null) and the effective digest, +// which lets the API create the instance row (R11, R45). +func buildHeartbeat(config *agentConfig, id uuid.UUID, now time.Time) sdktypes.Heartbeat { + hb := sdktypes.Heartbeat{UUID: id, CreatedAt: now} + if meta := config.syncInfo(); meta.Mode != "" && meta.Mode != agentconfig.ModeOff { + rev := meta.AppliedRevision + hb.ConfigRevision = &rev + hb.ConfigDigest = meta.Digest + } + return hb +} + type agentEvidenceCreateRequest struct { sdktypes.Evidence BackMatter *oscalTypes_1_1_3.BackMatter `json:"back-matter,omitempty"` diff --git a/cmd/config.go b/cmd/config.go index eccbc86..aa237ba 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -24,6 +24,9 @@ import ( type baseSnapshot struct { declared agentconfig.Config // file ⊕ CLI flags ⊕ bound env raw []byte // exact bytes read (one read per load) + // envSourced are the JSON pointers of plugin leaves whose value came from a CCF_* env + // variable (R25). They are masked in reports and in the digest. + envSourced []string // warnings are tolerated file-origin problems (R34): reported, never fatal. warnings []agentconfig.FieldError // skip holds the plugins dropped from the runtime because of a tolerated problem. @@ -32,6 +35,15 @@ type baseSnapshot struct { fingerprint string } +// redactOpts is the single source of the masking options used for the reported base and +// effective documents AND for the effective digest (R55). +func (b *baseSnapshot) redactOpts() []agentconfig.RedactOption { + if b == nil || len(b.envSourced) == 0 { + return nil + } + return []agentconfig.RedactOption{agentconfig.WithMaskedPointers(b.envSourced...)} +} + // toleratedFileRules are the validation rules whose failure is non-fatal when the value comes // from the local file (R34). Today a bad file schedule only logs "Error adding plugin // schedule" and the plugin never runs; everything else that fails validation fails startup. @@ -83,6 +95,9 @@ func bindAgentEnv(config *viper.Viper) error { for key, envVar := range map[string]string{ "api.auth.client_id": "CCF_API_AUTH_CLIENT_ID", "api.auth.client_secret": "CCF_API_AUTH_CLIENT_SECRET", + // remote_config is set locally only (file, host env, CLI) (R30). Binding the mode lets + // Helm set it even when the file omits the block (G2.1). + "remote_config.mode": "CCF_REMOTE_CONFIG_MODE", } { if err := config.BindEnv(key, envVar); err != nil { return err @@ -177,6 +192,24 @@ func checkExplicitZeroProtocol(v *viper.Viper, declared agentconfig.Config) erro return fmt.Errorf("plugin %s has unsupported protocol_version=0; supported values are %d and %d", names[0], DefaultProtocolVersion, RunnerV2ProtocolVersion) } +// envSourcedPointers returns the JSON pointers of plugin leaves whose value viper took from a +// CCF_* environment variable (R25). AutomaticEnv only overrides keys viper already knows (the +// file's keys), so checking the file's keys is exhaustive. +func envSourcedPointers(v *viper.Viper) []string { + var out []string + for _, key := range v.AllKeys() { + if !strings.HasPrefix(key, "plugins.") { + continue + } + envName := "CCF_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + if _, ok := os.LookupEnv(envName); ok { + out = append(out, agentconfig.Pointer(strings.Split(key, ".")...)) + } + } + slices.Sort(out) + return out +} + // loadBase reads and validates the local configuration. It builds a fresh viper per call // (R32). A returned error means the file is unusable (fatal at startup; keep last-known-good // on reload). Tolerated file problems (R34) are returned as warnings and skipped plugins. @@ -203,8 +236,9 @@ func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte) (*baseSnapsho } base := &baseSnapshot{ - declared: declared, - raw: raw, + declared: declared, + raw: raw, + envSourced: envSourcedPointers(v), } part := partitionByOrigin(declared.Validate()) if len(part.fatal) > 0 { @@ -212,7 +246,7 @@ func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte) (*baseSnapsho } base.warnings = part.warnings base.skip = part.skip - base.fingerprint = agentconfig.Digest(declared) + base.fingerprint = agentconfig.Digest(declared, base.redactOpts()...) return base, nil } @@ -263,6 +297,7 @@ func toRuntime(c agentconfig.Config, skip map[string]string) (*agentConfig, erro Daemon: c.Daemon, Verbosity: c.Verbosity, Plugins: map[string]*agentPlugin{}, + remote: c.EffectiveRemoteConfig(), } if c.API != nil { out.ApiConfig = &apiConfig{Url: c.API.URL} diff --git a/cmd/config_test.go b/cmd/config_test.go index 38ba437..d171cb8 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -65,6 +65,26 @@ func TestLoadBase_WeakDecodingUnchanged(t *testing.T) { } } +func TestEnvSourcedPointers(t *testing.T) { + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "from-env") + base := mustLoadBase(t, "yaml", ` +api: + url: http://localhost:8080 +plugins: + github: + source: ./plugin-github + config: + token: from-file + org: acme +`) + if want := []string{"/plugins/github/config/token"}; !reflect.DeepEqual(base.envSourced, want) { + t.Fatalf("envSourced = %v, want %v", base.envSourced, want) + } + if got := base.declared.Plugins["github"].Config["token"]; got != "from-env" { + t.Fatalf("expected env value to win, got %q", got) + } +} + func TestLoadBase_BadFileScheduleIsTolerated(t *testing.T) { base := mustLoadBase(t, "yaml", ` api: diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 273237c..170111b 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -5,21 +5,35 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" "math/rand" + "strings" "sync" "sync/atomic" "time" "github.com/compliance-framework/agent/internal/agentstate" "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" "github.com/fsnotify/fsnotify" + "github.com/google/uuid" "github.com/hashicorp/go-hclog" "github.com/spf13/cobra" "github.com/spf13/viper" ) var ( + // remoteRequestTimeout bounds one config report. + remoteRequestTimeout = 30 * time.Second + // remoteAuthBackoff is the retry delay after a 404 (API without the feature) or a 401/403 + // on a config route (R8, R36). + remoteAuthBackoff = 10 * time.Minute + // reportConflictBackoff is the report pause after a 409 (per-agent instance cap, R36). + reportConflictBackoff = time.Hour + // reportResendInterval resends an unchanged report in case the API pruned or lost it. + reportResendInterval = 24 * time.Hour // failedRetryMin / failedRetryMax bound the retry of a failed/* revision. failedRetryMin = time.Minute failedRetryMax = 10 * time.Minute @@ -34,11 +48,15 @@ var ( // once built. type candidate struct { base *baseSnapshot - declared agentconfig.Config // the declared config the runtime was built from + declared agentconfig.Config // reported and digested runtime *agentConfig // resolved, enabled-only, skipped plugins removed + digest string // agentconfig.Digest(declared, base.redactOpts()...) (R55) // identity changes whenever anything that affects the runtime changes, including the // values the digest masks or omits (api block, secrets). It never leaves the process. identity string + warnings []agentconfig.FieldError // R34 file-origin warnings + // plugins are the runtime's plugins with their agent library versions (R76). + plugins []agentconfig.PluginReport } // applyError is why a candidate could not be prepared. Status is agentconfig.StatusRejected @@ -73,6 +91,40 @@ type prefetcher interface { Prefetch(ctx context.Context, cfg *agentConfig) error } +// configReporter is the test seam over sdk.Client.AgentConfig.Report. +type configReporter interface { + Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error +} + +// remoteAPI bundles the remote configuration calls. +type remoteAPI interface { + configReporter +} + +// sdkRemote adapts the SDK client to remoteAPI. +type sdkRemote struct { + client *sdk.Client +} + +func (s sdkRemote) Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error { + return s.client.AgentConfig.Report(ctx, instanceID, r) +} + +// newSDKRemote builds the remote configuration client from the (locked, file-only) api block. +func newSDKRemote(c agentconfig.Config) remoteAPI { + if c.API == nil { + return nil + } + cfg := &sdk.Config{BaseURL: strings.TrimSpace(c.API.URL)} + if c.API.HasAuth() { + cfg.AgentAuth = &sdk.AgentAuthConfig{ + ClientID: strings.TrimSpace(c.API.Auth.ClientID), + ClientSecret: strings.TrimSpace(c.API.Auth.ClientSecret), + } + } + return sdkRemote{client: sdk.NewClient(nil, cfg)} +} + // runFunc runs one configuration until it is cancelled (daemon) or completes (one-shot). type runFunc func(ctx context.Context, cfg *agentConfig) error @@ -92,6 +144,7 @@ type reconciler struct { store *agentstate.Store runner prefetcher logger hclog.Logger + instanceID uuid.UUID fileEvents chan struct{} runFailed chan *candidate // onStartupFailure records a startup download failure of the file-only configuration @@ -100,7 +153,12 @@ type reconciler struct { // debounce coalesces bursts of config file events (editors write in several steps). debounce time.Duration - now func() time.Time + // newRemote builds the remote client from a base (a test seam). + newRemote func(agentconfig.Config) remoteAPI + // pluginLib reads the agent library version of a prefetched plugin source (R76); + // nil leaves the plugins report empty. + pluginLib pluginLibFunc + now func() time.Time mu sync.Mutex // guards active, pending, cancelRun active *candidate @@ -108,11 +166,20 @@ type reconciler struct { cancelRun context.CancelFunc // Everything below is owned by the reconciler goroutine (startup runs before loop). - base *baseSnapshot + base *baseSnapshot + remote remoteAPI + remoteKey string + lastOutcome *applyError + warnedMode bool + + reportBackoffUntil time.Time + loggedOnce map[string]bool failedBase string failedRetryAt time.Time failedInterval time.Duration + + report reportState } func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Store, runner prefetcher, logger hclog.Logger) *reconciler { @@ -129,6 +196,8 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor runFailed: make(chan *candidate, 1), debounce: 500 * time.Millisecond, now: time.Now, + loggedOnce: map[string]bool{}, + newRemote: newSDKRemote, } return rc } @@ -137,14 +206,53 @@ func (rc *reconciler) rcfg() agentconfig.RemoteConfig { return rc.base.declared.EffectiveRemoteConfig() } -// setBase installs a new base and logs its warnings. +func isApplyMode(mode string) bool { + return mode == agentconfig.ModeApplySafe || mode == agentconfig.ModeApplyAll +} + +// setBase installs a new base: warnings are logged and the remote client is rebuilt when the +// api block changed. func (rc *reconciler) setBase(base *baseSnapshot) { rc.base = base rc.logWarnings(base.warnings) + if !rc.warnedMode && base.declared.RemoteConfig != nil { + mode := base.declared.RemoteConfig.Mode + if mode != "" && mode != agentconfig.ModeOff && !base.declared.API.HasAuth() { + rc.warnedMode = true + rc.logger.Warn("remote_config.mode needs api.auth credentials; remote configuration is off", "mode", mode) + } + } + + key := remoteKey(base.declared) + if rc.remote == nil || key != rc.remoteKey { + rc.remote = nil + if base.declared.API.HasAuth() { + rc.remote = rc.newRemote(base.declared) + } + rc.remoteKey = key + rc.reportBackoffUntil = time.Time{} + } +} + +func remoteKey(c agentconfig.Config) string { + if c.API == nil { + return "" + } + raw, _ := json.Marshal(c.API) + return string(raw) } -// startup loads the file and prepares it (R32). Only an unusable local configuration is an -// error (exit 1, as before). +// logOnce reports whether key has not been logged yet, and marks it. +func (rc *reconciler) logOnce(key string) bool { + if rc.loggedOnce[key] { + return false + } + rc.loggedOnce[key] = true + return true +} + +// startup loads the file, prepares it (R32) and reports it. Only an unusable local +// configuration is an error (exit 1, as before). func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { base, err := loadBase(rc.cmd, rc.configPath) if err != nil { @@ -159,6 +267,7 @@ func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { } return nil, aerr } + rc.maybeReport(ctx, active, rc.lastOutcome) return active, nil } @@ -190,6 +299,7 @@ func (rc *reconciler) clearFailedBackoff() { // prepare builds a candidate from a base. It never touches the running configuration. func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot) (*candidate, *applyError) { + rcfg := base.declared.EffectiveRemoteConfig() declared := base.declared runtime, err := toRuntime(declared, base.skip) if err != nil { @@ -203,11 +313,23 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot) (*candida aerr.runtime = runtime return nil, aerr } + var plugins []agentconfig.PluginReport + if rcfg.Mode != agentconfig.ModeOff { + plugins = rc.pluginReports(ctx, runtime) + } + + // The digest is over the UNRESOLVED form with the same masking as the reported effective + // config (R55): it never changes when a secret rotates. + digest := agentconfig.Digest(declared, base.redactOpts()...) + runtime.setSync(syncMeta{Digest: digest, Mode: rcfg.Mode}) return &candidate{ base: base, declared: declared, runtime: runtime, + digest: digest, identity: candidateIdentity(declared), + warnings: append([]agentconfig.FieldError{}, base.warnings...), + plugins: plugins, }, nil } @@ -237,12 +359,14 @@ func (rc *reconciler) bind(active *candidate, cancel context.CancelFunc) { } // adopt records cand, whose identity equals old's, as the running (or pending) configuration -// WITHOUT a restart: the runtime old runs is kept, and sameAsActive holds for cand's base on -// the next trigger (no re-prepare every poll). +// WITHOUT a restart: the runtime old runs is kept and only its sync metadata changes, so the +// heartbeat and report show cand's, and sameAsActive holds for cand's base on the next trigger +// (no re-prepare every poll). func (rc *reconciler) adopt(old, cand *candidate) *candidate { adopted := *cand if old.runtime != nil { adopted.runtime = old.runtime + old.runtime.setSync(cand.runtime.syncInfo()) } rc.mu.Lock() defer rc.mu.Unlock() @@ -351,7 +475,8 @@ func (rc *reconciler) pollDelay() time.Duration { } // loop is the daemon's reconcile goroutine: config file events (debounced) and the poll -// ticker, which retries a candidate whose failed backoff expired. It returns when ctx is done. +// ticker, which retries a candidate whose failed backoff expired and resends the report when +// due. It returns when ctx is done. func (rc *reconciler) loop(ctx context.Context) { var debounce <-chan time.Time poll := time.NewTimer(rc.pollDelay()) @@ -377,17 +502,19 @@ func (rc *reconciler) loop(ctx context.Context) { } // onRunFailed records that a prepared candidate failed to run (the run loop already fell back). -func (rc *reconciler) onRunFailed(_ context.Context, c *candidate) { - if c == nil { - return - } - // Back the candidate off: otherwise every poll re-prepares the new base, cancels the - // healthy configuration, fails and falls back again. - baseFingerprint := rc.base.fingerprint - if c.base != nil { - baseFingerprint = c.base.fingerprint +func (rc *reconciler) onRunFailed(ctx context.Context, c *candidate) { + aerr := failed(agentconfig.ReasonInternal, errors.New("the configuration failed to start; running the previous configuration")) + if c != nil { + // Back the candidate off: otherwise every poll re-prepares the new base, cancels the + // healthy configuration, fails and falls back again. + baseFingerprint := rc.base.fingerprint + if c.base != nil { + baseFingerprint = c.base.fingerprint + } + rc.startFailedBackoff(baseFingerprint) } - rc.startFailedBackoff(baseFingerprint) + rc.lastOutcome = aerr + rc.maybeReport(ctx, rc.current(), aerr) } // reconcile handles one trigger (G3.4). All work runs in the reconciler goroutine, so two @@ -397,6 +524,9 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { base, err := loadBase(rc.cmd, rc.configPath) if err != nil { rc.logger.Error("Config file is invalid; keeping the running configuration", "error", err) + aerr := failed(agentconfig.ReasonInvalidConfig, fmt.Errorf("config file: %w", err)) + rc.lastOutcome = aerr + rc.maybeReport(ctx, rc.current(), aerr) return } rc.setBase(base) @@ -404,6 +534,7 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { active := rc.current() if rc.sameAsActive(active) || rc.inFailedBackoff() { + rc.maybeReport(ctx, active, rc.lastOutcome) return } @@ -411,16 +542,20 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { if aerr != nil { rc.logger.Warn("Could not apply the configuration; keeping the running configuration", "status", aerr.Status, "reason", aerr.Reason, "error", aerr.Err) rc.startFailedBackoff(rc.base.fingerprint) + rc.lastOutcome = aerr + rc.maybeReport(ctx, active, aerr) return } rc.clearFailedBackoff() + rc.lastOutcome = nil if active != nil && active.identity == cand.identity { rc.logger.Debug("Trigger did not change the effective configuration; recording it without a restart") - rc.adopt(active, cand) + rc.maybeReport(ctx, rc.adopt(active, cand), rc.lastOutcome) return } rc.logger.Info("Applying the new configuration") rc.swap(cand) + rc.maybeReport(ctx, cand, rc.lastOutcome) } // sameAsActive reports whether the active candidate already runs this base. @@ -429,6 +564,32 @@ func (rc *reconciler) sameAsActive(active *candidate) bool { bytes.Equal(active.base.raw, rc.base.raw) && active.base.fingerprint == rc.base.fingerprint } +// handleRemoteError applies the R8/R36 error table to a config route error. +func (rc *reconciler) handleRemoteError(op string, err error, backoff *time.Time) { + var statusErr *sdk.APIStatusError + switch { + case errors.Is(err, sdk.ErrRemoteConfigUnsupported): + if rc.logOnce(op + ":unsupported") { + rc.logger.Info("The API does not support remote agent configuration; running on the cached overlay or the file", "op", op, "retry_in", remoteAuthBackoff) + } + *backoff = rc.now().Add(remoteAuthBackoff) + case errors.Is(err, sdk.ErrAgentAuthRequired): + rc.logger.Error("Remote configuration requires api.auth credentials", "op", op) + *backoff = rc.now().Add(remoteAuthBackoff) + case errors.As(err, &statusErr) && (statusErr.StatusCode == 401 || statusErr.StatusCode == 403): + if rc.logOnce(fmt.Sprintf("%s:%d", op, statusErr.StatusCode)) { + msg := "The API rejected the agent's credentials for remote configuration" + if statusErr.StatusCode == 403 { + msg = "The agent's service account lacks the agent:sync permission for remote configuration" + } + rc.logger.Error(msg, "op", op, "status", statusErr.StatusCode, "retry_in", remoteAuthBackoff) + } + *backoff = rc.now().Add(remoteAuthBackoff) + default: + rc.logger.Warn("Remote configuration request failed; retrying on the next poll", "op", op, "error", err) + } +} + func (rc *reconciler) logWarnings(warnings []agentconfig.FieldError) { for _, w := range warnings { if isToleratedFileRule(w) { diff --git a/cmd/remote_test.go b/cmd/remote_test.go new file mode 100644 index 0000000..7d90909 --- /dev/null +++ b/cmd/remote_test.go @@ -0,0 +1,380 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" + "github.com/google/uuid" +) + +// fakeRemote is a scripted API for the remote configuration routes. +type fakeRemote struct { + mu sync.Mutex + overlay json.RawMessage // current overlay; nil = no document (404 if unsupported) + revision int64 + etag string + getErr error + reportErr func(n int, r agentconfig.Report) error + gets []string + reports []agentconfig.Report +} + +func (f *fakeRemote) Get(_ context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.gets = append(f.gets, ifNoneMatch) + if f.getErr != nil { + return nil, f.getErr + } + if f.overlay == nil { + return nil, sdk.ErrRemoteConfigUnsupported + } + if ifNoneMatch != "" && ifNoneMatch == f.etag { + return &sdk.AgentConfigResult{NotModified: true, ETag: f.etag}, nil + } + return &sdk.AgentConfigResult{ + Document: &agentconfig.OverlayDocument{Revision: f.revision, Overlay: f.overlay}, + ETag: f.etag, + }, nil +} + +func (f *fakeRemote) Report(_ context.Context, _ uuid.UUID, r agentconfig.Report) error { + f.mu.Lock() + defer f.mu.Unlock() + f.reports = append(f.reports, r) + if f.reportErr != nil { + return f.reportErr(len(f.reports), r) + } + return nil +} + +// publish sets a new overlay revision with an opaque ETag. +func (f *fakeRemote) publish(rev int64, overlay string) { + f.mu.Lock() + defer f.mu.Unlock() + f.revision = rev + f.overlay = json.RawMessage(overlay) + f.etag = fmt.Sprintf(`"r%d-%s"`, rev, uuid.New()) +} + +func (f *fakeRemote) lastReport(t *testing.T) agentconfig.Report { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.reports) == 0 { + t.Fatal("no report was sent") + } + return f.reports[len(f.reports)-1] +} + +func (f *fakeRemote) reportCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.reports) +} + +func (f *fakeRemote) getCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.gets) +} + +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +const remoteBaseConfig = ` +daemon: true +api: + url: http://api.test + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +remote_config: + mode: %MODE% + trusted_sources: ["ghcr.io/trusted/*"] + overridable_config_flags: [%FLAGS%] +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + schedule: "* * * * *" + config: + host: localhost + token: t0ken +` + +type remoteHarness struct { + rc *reconciler + remote *fakeRemote + pf *fakePrefetcher + clock *fakeClock + path string + dir string +} + +func remoteConfig(mode, flags string) string { + return strings.NewReplacer("%MODE%", mode, "%FLAGS%", flags).Replace(remoteBaseConfig) +} + +func newRemoteHarness(t *testing.T, content string) *remoteHarness { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + h := &remoteHarness{ + remote: &fakeRemote{}, + pf: &fakePrefetcher{}, + clock: &fakeClock{now: time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)}, + path: path, + dir: dir, + } + h.rc = h.newReconciler() + return h +} + +// newReconciler builds a reconciler on the harness's files (a "restart"). +func (h *remoteHarness) newReconciler() *reconciler { + rc := newReconciler(AgentCmd(), h.path, agentstate.Open(filepath.Join(h.dir, "state"), nil), h.pf, nil) + rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } + rc.now = h.clock.Now + return rc +} + +func (h *remoteHarness) writeConfig(t *testing.T, content string) { + t.Helper() + if err := os.WriteFile(h.path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustStartup(t *testing.T, rc *reconciler) *candidate { + t.Helper() + active, err := rc.startup(context.Background()) + if err != nil { + t.Fatalf("startup: %v", err) + } + rc.bind(active, func() {}) + return active +} + +// poll runs one poll trigger and returns the candidate now running (the pending swap, if any). +func (h *remoteHarness) poll(t *testing.T) *candidate { + t.Helper() + h.rc.reconcile(context.Background(), triggerPoll) + if next := h.rc.takePending(); next != nil { + h.rc.bind(next, func() {}) + } + return h.rc.current() +} + +func TestStartupReport_RedactsAndDescribes(t *testing.T) { + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "env-secret") + h := newRemoteHarness(t, remoteConfig("apply_safe", "")+` + github: + source: ghcr.io/compliance-framework/plugin-github:v1 + enabled: false + config: + token: from-file + org: "${env:GITHUB_ORG}" + endpoint: https://bot:hunter2@git.example +`) + h.remote.publish(0, `{}`) + mustStartup(t, h.rc) + + r := h.remote.lastReport(t) + for _, doc := range []json.RawMessage{r.Base, r.Effective} { + s := string(doc) + if strings.Contains(s, "s3cret") || strings.Contains(s, "client_secret") { + t.Fatalf("client secret leaked: %s", s) + } + if strings.Contains(s, "t0ken") || strings.Contains(s, "env-secret") { + t.Fatalf("token leaked: %s", s) + } + if !strings.Contains(s, `"org":"${env:GITHUB_ORG}"`) { + t.Fatalf("placeholder must be reported as written: %s", s) + } + if strings.Contains(s, "acme") { + t.Fatalf("resolved env value leaked: %s", s) + } + if strings.Contains(s, "hunter2") { + t.Fatalf("a password in a URL must be masked by value: %s", s) + } + if !strings.Contains(s, `"github":{`) || !strings.Contains(s, `"enabled":false`) { + t.Fatalf("disabled plugin must be reported: %s", s) + } + } + var eff agentconfig.Config + if err := json.Unmarshal(r.Effective, &eff); err != nil { + t.Fatal(err) + } + if got := eff.Plugins["github"].Config["token"]; got != agentconfig.MaskedValue { + t.Fatalf("env-sourced token must be %q, got %q", agentconfig.MaskedValue, got) + } + raw, _ := json.Marshal(r) + if !strings.Contains(string(raw), `"remote-config":{"mode":"apply_safe","poll_interval":"60s","trusted_sources":["ghcr.io/trusted/*"]`) { + t.Fatalf("remote-config must be snake_case inside: %s", raw) + } + if !r.Daemon || r.Status != agentconfig.StatusApplied || r.Mode != agentconfig.ModeApplySafe { + t.Fatalf("unexpected report header %+v", r) + } + // R55: the digest is recomputable from the reported effective config, and it does not + // change when the env-sourced value rotates. + if got := agentconfig.Digest(eff, agentconfig.WithMaskedPointers("/plugins/github/config/token")); got != r.EffectiveDigest { + t.Fatalf("effective-digest %s != Digest(reported effective) %s", r.EffectiveDigest, got) + } + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "rotated") + rotated := h.newReconciler() + if active := mustStartup(t, rotated); active.digest != r.EffectiveDigest { + t.Fatalf("digest changed when the env value rotated: %s vs %s", active.digest, r.EffectiveDigest) + } +} + +func TestModes_ReportAndOff(t *testing.T) { + t.Run("report mode never fetches", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("report", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + active := mustStartup(t, h.rc) + h.poll(t) + if h.remote.getCount() != 0 { + t.Fatalf("report mode must never fetch, got %d gets", h.remote.getCount()) + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusNotApplicable || r.AppliedRevision != nil { + t.Fatalf("report mode: %+v", r) + } + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision == nil || *hb.ConfigRevision != 0 || hb.ConfigDigest == "" { + t.Fatalf("report mode heartbeat must carry revision 0 and a digest: %+v", hb) + } + }) + t.Run("off sends nothing", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("off", "")) + active := mustStartup(t, h.rc) + h.poll(t) + if h.remote.reportCount() != 0 || h.remote.getCount() != 0 { + t.Fatalf("off must not report or fetch: %d reports, %d gets", h.remote.reportCount(), h.remote.getCount()) + } + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision != nil || hb.ConfigDigest != "" { + t.Fatalf("off heartbeat must not carry config fields: %+v", hb) + } + }) + t.Run("no auth forces off", func(t *testing.T) { + h := newRemoteHarness(t, ` +api: + url: http://api.test +remote_config: + mode: apply_all +`) + active := mustStartup(t, h.rc) + if active.runtime.remote.Mode != agentconfig.ModeOff || h.remote.reportCount() != 0 { + t.Fatalf("expected off without auth, got %q (%d reports)", active.runtime.remote.Mode, h.remote.reportCount()) + } + }) +} + +func TestHeartbeat_FileOnlyApplySafe(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + active := mustStartup(t, h.rc) // the fake answers 404: file only + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision == nil || *hb.ConfigRevision != 0 || hb.ConfigDigest != active.digest { + t.Fatalf("heartbeat: %+v (digest %s)", hb, active.digest) + } +} + +func TestRemoteErrors_Backoffs(t *testing.T) { + t.Run("413 resends truncated", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(0, `{}`) + h.remote.reportErr = func(n int, r agentconfig.Report) error { + if !r.Truncated { + return &sdk.APIStatusError{StatusCode: 413} + } + return nil + } + mustStartup(t, h.rc) + if h.remote.reportCount() != 2 || !h.remote.lastReport(t).Truncated { + t.Fatalf("expected a truncated resend, got %d reports", h.remote.reportCount()) + } + }) +} + +func TestReport_OversizedIsTruncated(t *testing.T) { + // config is a declared document of about n bytes. + config := func(n int) json.RawMessage { + return marshalRaw(agentconfig.Config{Plugins: map[string]*agentconfig.Plugin{ + "ssh": {Source: "ghcr.io/x/ssh:v1", Config: map[string]string{"blob": strings.Repeat("x", n)}}, + }}) + } + // A report under the target is sent whole. + report := agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(1 << 19), Effective: config(1 << 19)} + body, _, err := fitReport(&report, false) + if err != nil { + t.Fatal(err) + } + if report.Truncated || len(body) > reportTargetBytes || string(report.Base) == "{}" { + t.Fatalf("expected the report kept whole, got truncated=%v size=%d", report.Truncated, len(body)) + } + + // An oversized one drops base. + report = agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(2 << 20), Effective: config(2 << 20)} + body, _, err = fitReport(&report, false) + if err != nil { + t.Fatal(err) + } + if !report.Truncated || len(body) > agentconfig.MaxReportBytes || string(report.Base) != "{}" { + t.Fatalf("expected base dropped and a report under the limit, got truncated=%v size=%d", report.Truncated, len(body)) + } +} + +func TestReport_FileWarningStatusApplied(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")+` + bad: + source: ./plugin-bad + schedule: "not a cron" +`) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied || len(r.Warnings) != 1 || r.Warnings[0].Path != "/plugins/bad/schedule" { + t.Fatalf("expected an applied report with one warning, got %+v", r) + } +} + +func TestSetAgentVersion(t *testing.T) { + defer SetAgentVersion("dev") + SetAgentVersion("v1.2.3") + if agentVersion != "v1.2.3" { + t.Fatalf("got %q", agentVersion) + } + SetAgentVersion("") + if agentVersion == "" { + t.Fatal("empty version must fall back") + } +} + +// --- G3: pull and apply --- diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 0000000..968681c --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "maps" + "net/http" + "os" + "runtime/debug" + "slices" + "strings" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" +) + +// reportTargetBytes is the size the agent aims for; the API limit is agentconfig.MaxReportBytes. +const reportTargetBytes = 3*(1<<20) + (1 << 19) // 3.5 MiB + +// agentVersion is the agent build version reported to the API (see SetAgentVersion). +var agentVersion = "dev" + +// SetAgentVersion sets the version reported in config reports. main passes the goreleaser +// ldflag value; "dev" (or empty) falls back to the module version for `go install` builds. +func SetAgentVersion(v string) { + v = strings.TrimSpace(v) + if v == "" || v == "dev" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + v = info.Main.Version + } else { + v = "dev" + } + } + agentVersion = v +} + +// pluginLibFunc returns the agent library version the binary of a plugin source was built +// with ("" when unknown). The source has been prefetched. +type pluginLibFunc func(ctx context.Context, source string) (string, error) + +// pluginReports lists the plugins of runtime with the agent library each was built with (R76), +// read from the plugin binary's build info: diagnostics for the UI. A version that cannot be +// read is reported as unknown (empty). Without a pluginLib function it reports nothing. +func (rc *reconciler) pluginReports(ctx context.Context, runtime *agentConfig) []agentconfig.PluginReport { + if rc.pluginLib == nil || runtime == nil { + return nil + } + var reports []agentconfig.PluginReport + for _, name := range slices.Sorted(maps.Keys(runtime.Plugins)) { + p := runtime.Plugins[name] + version, err := rc.pluginLib(ctx, p.Source) + if err != nil { + version = "" + if rc.logOnce("plugin-lib\x00" + p.Source + "\x00" + err.Error()) { + rc.logger.Warn("Could not read the agent library version of a plugin; reporting it as unknown", "plugin", name, "source", p.Source, "error", err) + } + } + reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version}) + } + return reports +} + +// reportState is the reconciler's report bookkeeping. +type reportState struct { + fingerprint string // sha256 of the last report sent successfully + sentAt time.Time // when it was sent + sendFailed bool // the last attempt failed; retry on the next tick +} + +// maybeReport sends a config report for active when it differs from the last one sent, after a +// send error, or when the last one is older than 24h (G2.2). Mode off never reports. +func (rc *reconciler) maybeReport(ctx context.Context, active *candidate, outcome *applyError) { + if active == nil || rc.remote == nil { + return + } + rcfg := rc.rcfg() + if rcfg.Mode == agentconfig.ModeOff { + return + } + if rc.now().Before(rc.reportBackoffUntil) { + return + } + report := rc.buildReport(active, outcome, rcfg) + body, fingerprint, err := fitReport(&report, false) + if err != nil { + rc.logger.Error("Could not encode the config report", "error", err) + return + } + if !rc.report.sendFailed && fingerprint == rc.report.fingerprint && rc.now().Sub(rc.report.sentAt) < reportResendInterval { + return + } + if len(body) > agentconfig.MaxReportBytes { + rc.logger.Warn("Config report exceeds the API limit even after truncation", "bytes", len(body)) + } + + err = rc.sendReport(ctx, report) + var statusErr *sdk.APIStatusError + if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusRequestEntityTooLarge { + rc.logger.Warn("The API rejected the config report as too large; resending it truncated") + if _, _, ferr := fitReport(&report, true); ferr == nil { + err = rc.sendReport(ctx, report) + } + } + switch { + case err == nil: + rc.report = reportState{fingerprint: fingerprint, sentAt: rc.now()} + case errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusConflict: + rc.report.sendFailed = true + rc.reportBackoffUntil = rc.now().Add(reportConflictBackoff) + rc.logger.Warn("The API refused the config report: the agent's instance cap is reached; pausing reports", "retry_in", reportConflictBackoff, "error", err) + default: + rc.report.sendFailed = true + rc.handleRemoteError("report", err, &rc.reportBackoffUntil) + } +} + +func (rc *reconciler) sendReport(ctx context.Context, report agentconfig.Report) error { + reportCtx, cancel := context.WithTimeout(ctx, remoteRequestTimeout) + defer cancel() + return rc.remote.Report(reportCtx, rc.instanceID, report) +} + +// buildReport fills the wire report for the active candidate and the last outcome (G2.2). +// base and effective are the UNRESOLVED forms, redacted with the same masked pointers the +// digest uses (R24, R25, R55). +func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg agentconfig.RemoteConfig) agentconfig.Report { + hostname, _ := os.Hostname() + opts := active.base.redactOpts() + report := agentconfig.Report{ + Hostname: truncateString(hostname, 255), + AgentVersion: truncateString(agentVersion, 64), + Mode: rcfg.Mode, + Daemon: active.runtime.Daemon, + Base: marshalRaw(agentconfig.Redact(active.base.declared, opts...)), + Effective: marshalRaw(agentconfig.Redact(active.declared, opts...)), + EffectiveDigest: active.digest, + Warnings: active.warnings, + RemoteConfig: &rcfg, + Plugins: active.plugins, + } + switch { + case !isApplyMode(rcfg.Mode): + report.Status = agentconfig.StatusNotApplicable + case outcome != nil: + report.Status = outcome.Status + report.Reason = outcome.Reason + msg := outcome.Reason + if outcome.Err != nil { + msg = outcome.Err.Error() + } + report.Error = &msg + default: + report.Status = agentconfig.StatusApplied + } + return report +} + +func marshalRaw(c agentconfig.Config) json.RawMessage { + raw, err := json.Marshal(c) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +func truncateString(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// fitReport encodes the report, shrinking it to reportTargetBytes when needed (or always when +// force is set, for a resend after a 413): base is dropped, which sets Truncated. It returns the body and its fingerprint. +func fitReport(report *agentconfig.Report, force bool) ([]byte, string, error) { + body, err := json.Marshal(report) + if err != nil { + return nil, "", err + } + steps := []func(*agentconfig.Report){dropReportBase} + for _, step := range steps { + if !force && len(body) <= reportTargetBytes { + break + } + step(report) + report.Truncated = true + if body, err = json.Marshal(report); err != nil { + return nil, "", err + } + } + sum := sha256.Sum256(body) + return body, hex.EncodeToString(sum[:]), nil +} + +func dropReportBase(report *agentconfig.Report) { + report.Base = json.RawMessage(`{}`) +} diff --git a/docs/configuration.md b/docs/configuration.md index 5a271af..cca9f12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,11 +180,45 @@ cannot contain dots. ## Tolerated file problems A plugin `schedule` in the file that does not parse does not stop the agent: that plugin is skipped, the others run, -and the problem is logged as a warning (R34). A few other file values that always loaded are also only +and the problem is logged and reported as a warning (R34). A few other file values that always loaded are also only warnings, and are kept unchanged: a negative `verbosity` (`-1` logs WARN and above) and a literal `${env:...}` outside `plugins.*.config`. Every other invalid value in the file (for example a missing `api.url`) still fails startup, and on a live reload the agent keeps running its last good configuration. +## Remote configuration + +An agent with `api.auth` credentials reports the configuration it runs to the API. The `remote_config` block +controls it. It is **set locally only** (file, host environment, CLI flags), never remotely (R30): + +```yaml +remote_config: + mode: report # off | report | apply_safe | apply_all + poll_interval: 60s # at least 15s + trusted_sources: [] # glob list of plugin/policy sources an overlay may introduce + overridable_config_flags: [] # glob list of plugins.*.config keys an overlay may change + allow_local_sources: false +``` + +Defaults (R29): `mode` is `report` when `api.auth` is set and `off` otherwise (no credentials always forces +`off`); `poll_interval` is `60s`; `trusted_sources` and `overridable_config_flags` are empty; +`allow_local_sources` is `false`. `CCF_REMOTE_CONFIG_MODE` sets the mode even when the file has no +`remote_config` block. + +| Mode | Behaviour | +|---|---| +| `off` | No report, no fetch. The heartbeat carries no configuration fields. | +| `report` | The agent reports its configuration (status `not-applicable`) but never fetches an overlay. | + +When a configuration report is too large for the API, the agent drops its `base` document and marks it truncated; +the effective document and digest are kept. + +### Plugin library versions (R76) + +The report lists the instance's plugins as `plugins[]` (`name`, `source`, `lib-version`), where `lib-version` is +the version of this agent library the plugin binary was built with, read from its Go build info without starting it. +It is empty when unknown (a `replace`d or `(devel)` build, or a binary without build info). It is diagnostic only: +nothing is gated on it. + ## State directory and instance ID Each agent instance keeps state in `.compliance-framework/state//`, relative to the working directory, where diff --git a/main.go b/main.go index 01807b6..c560f83 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,13 @@ import ( "os" ) +// version is set with -X main.version=...: by goreleaser's default ldflags and +// by the Dockerfiles' VERSION build arg. +var version = "dev" + func main() { + cmd.SetAgentVersion(version) + var rootCmd = &cobra.Command{ Use: "cf", Short: "cf manages policies for the compliance framework",