Add reconciliation state foundation

This commit is contained in:
2026-06-08 18:24:12 +00:00
parent c04432e40b
commit b7db3993fb
13 changed files with 557 additions and 66 deletions

View File

@@ -38,24 +38,25 @@ type Pipeline struct {
}
type Destination struct {
ID string `yaml:"id"`
Backend string `yaml:"backend"`
Host string `yaml:"host"`
User string `yaml:"user"`
Port int `yaml:"port"`
Path string `yaml:"path"`
Endpoint string `yaml:"endpoint"`
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
Region string `yaml:"region"`
ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"`
Publish *PublishPolicy `yaml:"publish"`
Transform Transform `yaml:"transform"`
PathMap PathMapping `yaml:"path_mapping"`
Links *Links `yaml:"links"`
Transfer TransferPolicy `yaml:"transfer"`
ID string `yaml:"id"`
Backend string `yaml:"backend"`
Host string `yaml:"host"`
User string `yaml:"user"`
Port int `yaml:"port"`
Path string `yaml:"path"`
Endpoint string `yaml:"endpoint"`
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
Region string `yaml:"region"`
ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"`
Publish *PublishPolicy `yaml:"publish"`
Transform Transform `yaml:"transform"`
PathMap PathMapping `yaml:"path_mapping"`
Links *Links `yaml:"links"`
Reconciliation ReconciliationPolicy `yaml:"reconciliation"`
Transfer TransferPolicy `yaml:"transfer"`
}
type Backend struct {
@@ -118,6 +119,10 @@ type Links struct {
Primary string `yaml:"primary"`
}
type ReconciliationPolicy struct {
Mode string `yaml:"mode"`
}
type TransferPolicy struct {
OnDestinationSame string `yaml:"on_destination_same"`
OnDestinationOlder string `yaml:"on_destination_older"`

View File

@@ -42,6 +42,11 @@ const (
LinkPrimarySource = "source"
)
const (
ReconciliationModeReplace = "replace"
ReconciliationModeMerge = "merge"
)
const DefaultS3Region = "us-east-1"
const (
@@ -79,6 +84,9 @@ func ApplyDefaults(cfg *Config) {
if destination.Links != nil && destination.Links.Primary == "" {
destination.Links.Primary = LinkPrimaryAuto
}
if destination.Reconciliation.Mode == "" {
destination.Reconciliation.Mode = ReconciliationModeReplace
}
if destination.Transfer.OnDestinationSame == "" {
destination.Transfer.OnDestinationSame = TransferActionSkip
}

View File

@@ -33,6 +33,9 @@ pipelines:
if got, want := destination.Transfer.OnDestinationOlder, TransferActionReplace; got != want {
t.Fatalf("transfer default = %q, want %q", got, want)
}
if got, want := destination.Reconciliation.Mode, ReconciliationModeReplace; got != want {
t.Fatalf("reconciliation mode default = %q, want %q", got, want)
}
if cfg.Secrets.Directory != "" {
t.Fatalf("secrets.directory = %q, want empty", cfg.Secrets.Directory)
}
@@ -164,6 +167,35 @@ pipelines:
}
}
func TestLoadFileAcceptsExplicitReconciliationModes(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
- id: reports
source:
backend: local
path: /source
destinations:
- id: archive
backend: local
path: /archive
reconciliation:
mode: replace
- id: web
backend: local
path: /web
reconciliation:
mode: merge
`)
destinations := cfg.Pipelines[0].Destinations
if got, want := destinations[0].Reconciliation.Mode, ReconciliationModeReplace; got != want {
t.Fatalf("archive reconciliation mode = %q, want %q", got, want)
}
if got, want := destinations[1].Reconciliation.Mode, ReconciliationModeMerge; got != want {
t.Fatalf("web reconciliation mode = %q, want %q", got, want)
}
}
func TestLoadFileAcceptsFixedPathMapping(t *testing.T) {
cfg := loadConfig(t, `
pipelines:

View File

@@ -72,6 +72,7 @@ func Validate(cfg Config) error {
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap)
errs = validateLinks(errs, destinationContext+".links", destination.Links)
errs = validateReconciliationPolicy(errs, destinationContext+".reconciliation", destination.Reconciliation)
errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer)
}
}
@@ -314,6 +315,13 @@ func validateLinks(errs ValidationErrors, context string, links *Links) Validati
return errs
}
func validateReconciliationPolicy(errs ValidationErrors, context string, policy ReconciliationPolicy) ValidationErrors {
if policy.Mode != ReconciliationModeReplace && policy.Mode != ReconciliationModeMerge {
errs = append(errs, context+".mode must be "+ReconciliationModeReplace+" or "+ReconciliationModeMerge)
}
return errs
}
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors {
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail {
errs = append(errs, context+".on_destination_same must be skip or fail")

View File

@@ -107,6 +107,40 @@ func TestValidatePathMapping(t *testing.T) {
}
}
func TestValidateReconciliationPolicy(t *testing.T) {
tests := []struct {
name string
mode string
wantErr bool
}{
{name: "replace", mode: ReconciliationModeReplace},
{name: "merge", mode: ReconciliationModeMerge},
{name: "invalid", mode: "append", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Config{Pipelines: []Pipeline{{
ID: "reports",
Source: Backend{Backend: BackendLocal, Path: "/source"},
Destinations: []Destination{{
ID: "archive",
Backend: BackendLocal,
Path: "/destination",
Reconciliation: ReconciliationPolicy{Mode: tt.mode},
}},
}}}
ApplyDefaults(&cfg)
err := Validate(cfg)
if tt.wantErr && err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !tt.wantErr && err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
func TestValidateLinks(t *testing.T) {
tests := []struct {
name string