package gitops import ( "encoding/json" "fmt" ) // MergeAndValidate overlays the plan's SourceConfigPatch onto a copy of the // live source_config and returns the merged JSON — but only after the target // source's own Validate accepts the *merged* result. This is the hard apply // gate (review C4): // // - omitted-field-preserving: keys the file doesn't declare are untouched, so // a partial .tinyforge.yml never clears live config; // - validate-then-commit: a patch that would produce an invalid config (e.g. // deploy_strategy "blue-green" on a source that rejects it, or a bad port) // is refused as a whole — the function never returns a partial/empty config; // - pure: it does not write anything; the caller persists the returned bytes. // // validate is the matching Source.Validate (passed in to keep this package // decoupled from the source plugins). func MergeAndValidate(live json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error) { // Decode the live config into a generic map we can overlay. An empty/null // live config starts from an empty object rather than failing. merged := map[string]any{} if len(live) > 0 { if err := json.Unmarshal(live, &merged); err != nil { return nil, fmt.Errorf("gitops: decode live source_config: %w", err) } } // Overlay only the declared patch keys — everything else is preserved. for k, v := range plan.SourceConfigPatch { merged[k] = v } out, err := json.Marshal(merged) if err != nil { return nil, fmt.Errorf("gitops: encode merged source_config: %w", err) } if validate != nil { if err := validate(out); err != nil { return nil, fmt.Errorf("gitops: merged config rejected: %w", err) } } return out, nil }