2026-05-31 02:21:36 +00:00
package publish
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
2026-05-31 02:28:48 +00:00
"gitea.maximumdirect.net/eric/distributor/internal/transform"
2026-05-31 02:21:36 +00:00
)
type Action string
const (
ActionPublishNew Action = "publish_new"
ActionReplaceOlder Action = "replace_older"
ActionSkipSame Action = "skip_same"
ActionSkipDestinationNewer Action = "skip_destination_newer"
ActionFailConflict Action = "fail_conflict"
ActionFailUnmanaged Action = "fail_unmanaged"
2026-05-31 17:29:20 +00:00
ActionForceReplace Action = "force_replace"
2026-06-18 15:18:38 +00:00
ActionReplaceTakeover Action = "replace_takeover"
2026-05-31 02:21:36 +00:00
)
type Request struct {
PipelineID string
DestinationID string
SourceBundle bundle . Bundle
SourceBackend storage . Backend
DestinationBackend storage . Backend
DestinationBundlePath string
2026-06-01 21:33:12 +00:00
PathMapping string
2026-05-31 02:21:36 +00:00
Publish config . PublishPolicy
2026-05-31 02:28:48 +00:00
Transform config . Transform
2026-06-01 21:42:00 +00:00
Links * config . Links
2026-06-08 18:53:22 +00:00
State config . StatePolicy
2026-06-08 18:30:52 +00:00
Reconciliation config . ReconciliationPolicy
2026-06-18 15:18:38 +00:00
Takeover config . TakeoverPolicy
2026-05-31 03:30:08 +00:00
Transformers TransformerResolver
2026-05-31 02:21:36 +00:00
Transfer config . TransferPolicy
DistributorVersion string
2026-05-31 17:29:20 +00:00
Force bool
2026-05-31 02:21:36 +00:00
}
2026-05-31 03:30:08 +00:00
type TransformerResolver interface {
Get ( name string ) ( transform . Transformer , bool )
}
2026-05-31 02:21:36 +00:00
type Plan struct {
PipelineID string
DestinationID string
BundleID string
BundlePath string
DestinationBundlePath string
2026-06-01 21:33:12 +00:00
PathMapping string
2026-05-31 02:21:36 +00:00
Action Action
Reason string
2026-05-31 17:29:20 +00:00
Force bool
2026-06-01 21:42:00 +00:00
PrimaryURL string
2026-06-08 18:53:22 +00:00
StateMode string
OwnerScope state . OwnerScope
2026-06-08 18:30:52 +00:00
Reconciliation config . ReconciliationPolicy
2026-05-31 02:21:36 +00:00
Outputs [ ] Output
ExistingState * state . DistributorState
2026-06-08 18:53:22 +00:00
ExistingSharedRoot * state . SharedRootState
OtherOwnerOutputs [ ] state . SharedRootOutputFile
RetainedOwnerOutputs [ ] state . SharedRootOutputFile
OwnerOutputsToDelete [ ] state . SharedRootOutputFile
OwnerOutputsToWrite [ ] Output
2026-05-31 02:21:36 +00:00
}
type Output struct {
SourcePath string
DestinationPath string
2026-05-31 02:28:48 +00:00
Kind string
Transform string
2026-06-01 21:42:00 +00:00
URL string
2026-05-31 02:28:48 +00:00
Data [ ] byte
2026-05-31 02:21:36 +00:00
SHA256 string
Size int64
}
func Build ( ctx context . Context , req Request ) ( Plan , error ) {
if err := validateRequest ( req ) ; err != nil {
return Plan { } , err
}
2026-05-31 02:28:48 +00:00
outputs , err := PlanOutputs ( ctx , req )
2026-05-31 02:21:36 +00:00
if err != nil {
return Plan { } , err
}
2026-06-01 21:42:00 +00:00
outputs , primaryURL , err := PlanLinks ( req , outputs )
if err != nil {
return Plan { } , err
}
2026-05-31 02:21:36 +00:00
status , err := inspectDestination ( ctx , req . DestinationBackend , req . DestinationBundlePath )
if err != nil {
return Plan { } , err
}
2026-06-08 18:30:52 +00:00
reconciliation := normalizeReconciliation ( req . Reconciliation )
2026-06-08 18:53:22 +00:00
stateMode := normalizeState ( req . State ) . Mode
2026-06-18 15:18:38 +00:00
comparison := compareDestination ( req , status )
action , reason := actionForComparison ( comparison , req . Transfer , req . Force )
if takeoverActionAllowed ( req , status , comparison , stateMode , action ) {
action = ActionReplaceTakeover
reason = comparison . Reason
}
2026-05-31 02:21:36 +00:00
plan := Plan {
PipelineID : req . PipelineID ,
DestinationID : req . DestinationID ,
BundleID : req . SourceBundle . Manifest . ID ,
BundlePath : req . SourceBundle . RootRelativePath ,
DestinationBundlePath : req . DestinationBundlePath ,
2026-06-01 21:33:12 +00:00
PathMapping : req . PathMapping ,
2026-05-31 02:21:36 +00:00
Action : action ,
Reason : reason ,
2026-05-31 17:29:20 +00:00
Force : action == ActionForceReplace ,
2026-06-01 21:42:00 +00:00
PrimaryURL : primaryURL ,
2026-06-08 18:53:22 +00:00
StateMode : stateMode ,
OwnerScope : state . CurrentOwnerScope ( req . PipelineID , req . DestinationID ) ,
2026-06-08 18:30:52 +00:00
Reconciliation : reconciliation ,
2026-05-31 02:21:36 +00:00
Outputs : outputs ,
ExistingState : status . State ,
2026-06-08 18:53:22 +00:00
ExistingSharedRoot : status . SharedRoot ,
}
if stateMode == config . StateModeSharedRoot {
sharedDetails , err := planSharedRootOwner ( ctx , req , status , action , reconciliation , outputs )
plan . OtherOwnerOutputs = sharedDetails . OtherOwnerOutputs
plan . RetainedOwnerOutputs = sharedDetails . RetainedOwnerOutputs
plan . OwnerOutputsToDelete = sharedDetails . OwnerOutputsToDelete
plan . OwnerOutputsToWrite = sharedDetails . OwnerOutputsToWrite
if err != nil {
plan . Action = sharedDetails . Action
plan . Reason = sharedDetails . Reason
return plan , err
}
2026-05-31 02:21:36 +00:00
}
if action == ActionFailConflict || action == ActionFailUnmanaged {
return plan , fmt . Errorf ( "%s: %s" , action , reason )
}
return plan , nil
}
func validateRequest ( req Request ) error {
if req . PipelineID == "" {
return fmt . Errorf ( "pipeline id is required" )
}
if req . DestinationID == "" {
return fmt . Errorf ( "destination id is required" )
}
if req . SourceBackend == nil {
return fmt . Errorf ( "source backend is required" )
}
if req . DestinationBackend == nil {
return fmt . Errorf ( "destination backend is required" )
}
2026-05-31 03:25:27 +00:00
if err := config . ValidatePublishTransformPolicy ( req . Publish , req . Transform ) ; err != nil {
return fmt . Errorf ( "publish/transform policy: %w" , err )
2026-05-31 02:21:36 +00:00
}
2026-06-08 18:30:52 +00:00
switch normalizeReconciliation ( req . Reconciliation ) . Mode {
case config . ReconciliationModeReplace , config . ReconciliationModeMerge :
default :
return fmt . Errorf ( "reconciliation.mode must be %s or %s" , config . ReconciliationModeReplace , config . ReconciliationModeMerge )
}
2026-06-08 18:53:22 +00:00
switch normalizeState ( req . State ) . Mode {
case config . StateModeSingleOwner , config . StateModeSharedRoot :
default :
return fmt . Errorf ( "state.mode must be %s or %s" , config . StateModeSingleOwner , config . StateModeSharedRoot )
}
2026-06-18 15:18:38 +00:00
switch normalizeTakeover ( req . Takeover ) . Mode {
case config . TakeoverModeSamePipeline , config . TakeoverModeSameSource , config . TakeoverModeAnyManaged , config . TakeoverModeNever :
default :
return fmt . Errorf ( "takeover.mode must be %s, %s, %s, or %s" , config . TakeoverModeSamePipeline , config . TakeoverModeSameSource , config . TakeoverModeAnyManaged , config . TakeoverModeNever )
}
2026-05-31 02:21:36 +00:00
return nil
}
2026-06-08 18:30:52 +00:00
func normalizeReconciliation ( policy config . ReconciliationPolicy ) config . ReconciliationPolicy {
if policy . Mode == "" {
policy . Mode = config . ReconciliationModeReplace
}
return policy
}
2026-06-08 18:53:22 +00:00
func normalizeState ( policy config . StatePolicy ) config . StatePolicy {
if policy . Mode == "" {
policy . Mode = config . StateModeSingleOwner
}
return policy
}
2026-06-18 15:18:38 +00:00
func normalizeTakeover ( policy config . TakeoverPolicy ) config . TakeoverPolicy {
if policy . Mode == "" {
policy . Mode = config . TakeoverModeSamePipeline
}
return policy
}
2026-06-01 21:33:12 +00:00
func compareDestination ( req Request , status state . DestinationStatus ) state . Comparison {
2026-06-08 18:53:22 +00:00
if normalizeState ( req . State ) . Mode == config . StateModeSharedRoot {
scope := state . CurrentOwnerScope ( req . PipelineID , req . DestinationID )
comparison := state . CompareSharedRootOwner ( req . SourceBundle . Manifest , scope , status )
if req . PathMapping != config . PathMappingFixed || comparison . Outcome != state . OutcomeDifferentSourceConflict {
return comparison
}
destinationManifest , ok := sharedRootComparisonManifest ( status , scope )
if ! ok {
return comparison
}
if destinationManifest . Created . Before ( req . SourceBundle . Manifest . Created ) {
return state . Comparison { Outcome : state . OutcomeDestinationOlder , Reason : "fixed destination source is older than selected source" }
}
if destinationManifest . Created . After ( req . SourceBundle . Manifest . Created ) {
2026-06-18 15:09:18 +00:00
return state . Comparison {
Outcome : state . OutcomeDestinationNewer ,
Reason : "fixed destination source is newer than selected source" ,
Detail : state . ComparisonDetail {
Kind : state . ComparisonDetailDestinationNewer ,
CurrentSourceID : req . SourceBundle . Manifest . ID ,
DestinationSourceID : destinationManifest . ID ,
} ,
}
2026-06-08 18:53:22 +00:00
}
return comparison
}
2026-06-01 21:33:12 +00:00
comparison := state . Compare ( req . SourceBundle . Manifest , req . PipelineID , req . DestinationID , status )
return comparison
}
2026-06-08 18:53:22 +00:00
func sharedRootComparisonManifest ( status state . DestinationStatus , scope state . OwnerScope ) ( bundle . Manifest , bool ) {
if status . SharedRoot != nil {
return status . SharedRoot . SourceManifest ( scope )
}
if status . State != nil && status . State . PipelineID == scope . PipelineID && status . State . DestinationID == scope . DestinationID {
return status . State . Source . Manifest , true
}
return bundle . Manifest { } , false
}
type sharedRootPlanDetails struct {
Action Action
Reason string
OtherOwnerOutputs [ ] state . SharedRootOutputFile
RetainedOwnerOutputs [ ] state . SharedRootOutputFile
OwnerOutputsToDelete [ ] state . SharedRootOutputFile
OwnerOutputsToWrite [ ] Output
}
func planSharedRootOwner ( ctx context . Context , req Request , status state . DestinationStatus , action Action , reconciliation config . ReconciliationPolicy , outputs [ ] Output ) ( sharedRootPlanDetails , error ) {
scope := state . CurrentOwnerScope ( req . PipelineID , req . DestinationID )
details := sharedRootPlanDetails { Action : action }
if ! isWriteAction ( action ) {
details . OtherOwnerOutputs = otherOwnerOutputs ( status , scope )
return details , nil
}
plannedPaths := outputPaths ( outputs )
2026-06-08 19:02:12 +00:00
if action == ActionForceReplace {
details . OwnerOutputsToWrite = append ( [ ] Output ( nil ) , outputs ... )
return details , nil
}
2026-06-08 18:53:22 +00:00
if conflict , ok := sharedRootPathOwnershipConflict ( status , scope , plannedPaths ) ; ok {
reason := fmt . Sprintf ( "destination output path %s is owned by %s/%s" , conflict . Path , conflict . Owner . PipelineID , conflict . Owner . DestinationID )
details . Action = ActionFailConflict
details . Reason = reason
return details , fmt . Errorf ( "%s: %s" , ActionFailConflict , reason )
}
if err := rejectSharedRootUnmanagedCollisions ( ctx , req . DestinationBackend , req . DestinationBundlePath , status , scope , plannedPaths ) ; err != nil {
details . Action = ActionFailUnmanaged
details . Reason = err . Error ( )
return details , fmt . Errorf ( "%s: %s" , ActionFailUnmanaged , err )
}
details . OtherOwnerOutputs = otherOwnerOutputs ( status , scope )
ownerOutputs := currentOwnerOutputs ( status , scope )
planned := make ( map [ string ] struct { } , len ( plannedPaths ) )
for _ , path := range plannedPaths {
planned [ path ] = struct { } { }
}
for _ , output := range ownerOutputs {
if _ , exists := planned [ output . Path ] ; exists {
continue
}
if action == ActionReplaceOlder && reconciliation . Mode == config . ReconciliationModeReplace {
details . OwnerOutputsToDelete = append ( details . OwnerOutputsToDelete , output )
continue
}
if action == ActionReplaceOlder && reconciliation . Mode == config . ReconciliationModeMerge {
details . RetainedOwnerOutputs = append ( details . RetainedOwnerOutputs , output )
}
}
details . OwnerOutputsToWrite = append ( [ ] Output ( nil ) , outputs ... )
return details , nil
}
func isWriteAction ( action Action ) bool {
switch action {
2026-06-18 15:18:38 +00:00
case ActionPublishNew , ActionReplaceOlder , ActionReplaceTakeover , ActionForceReplace :
2026-06-08 18:53:22 +00:00
return true
default :
return false
}
}
func outputPaths ( outputs [ ] Output ) [ ] string {
paths := make ( [ ] string , 0 , len ( outputs ) )
for _ , output := range outputs {
paths = append ( paths , output . DestinationPath )
}
return paths
}
func sharedRootPathOwnershipConflict ( status state . DestinationStatus , scope state . OwnerScope , paths [ ] string ) ( state . PathOwnershipConflict , bool ) {
if status . SharedRoot != nil {
return status . SharedRoot . PathOwnershipConflict ( scope , paths )
}
return state . PathOwnershipConflict { } , false
}
func rejectSharedRootUnmanagedCollisions ( ctx context . Context , backend storage . Backend , bundlePath string , status state . DestinationStatus , scope state . OwnerScope , paths [ ] string ) error {
for _ , path := range paths {
if pathManagedBySharedRootStatus ( status , scope , path ) {
continue
}
destinationPath , err := storage . Join ( bundlePath , path )
if err != nil {
return err
}
if _ , err := backend . Stat ( ctx , destinationPath ) ; err == nil {
return fmt . Errorf ( "destination output path %s exists but is not managed by destination state" , storage . DisplayPath ( path ) )
} else if ! storage . IsNotFound ( err ) {
return err
}
}
return nil
}
func pathManagedBySharedRootStatus ( status state . DestinationStatus , scope state . OwnerScope , path string ) bool {
if status . SharedRoot != nil {
_ , exists := status . SharedRoot . OutputOwner ( path )
return exists
}
if status . State != nil && status . State . PipelineID == scope . PipelineID && status . State . DestinationID == scope . DestinationID {
_ , exists := state . FindOutputByPath ( status . State . Outputs , path )
return exists
}
return false
}
func otherOwnerOutputs ( status state . DestinationStatus , scope state . OwnerScope ) [ ] state . SharedRootOutputFile {
if status . SharedRoot == nil {
return nil
}
outputs := make ( [ ] state . SharedRootOutputFile , 0 , len ( status . SharedRoot . Outputs ) )
for _ , output := range status . SharedRoot . Outputs {
if output . Owner != scope {
outputs = append ( outputs , output )
}
}
return outputs
}
func currentOwnerOutputs ( status state . DestinationStatus , scope state . OwnerScope ) [ ] state . SharedRootOutputFile {
if status . SharedRoot != nil {
outputs := make ( [ ] state . SharedRootOutputFile , 0 , len ( status . SharedRoot . Outputs ) )
for _ , output := range status . SharedRoot . Outputs {
if output . Owner == scope {
outputs = append ( outputs , output )
}
}
return outputs
}
if status . State != nil && status . State . PipelineID == scope . PipelineID && status . State . DestinationID == scope . DestinationID {
outputs := make ( [ ] state . SharedRootOutputFile , 0 , len ( status . State . Outputs ) )
for _ , output := range status . State . Outputs {
outputs = append ( outputs , state . SharedRootOutputFile {
Path : output . Path ,
Kind : output . Kind ,
SourcePath : output . SourcePath ,
Transform : output . Transform ,
URL : output . URL ,
SHA256 : output . SHA256 ,
Size : output . Size ,
Owner : scope ,
SourceID : status . State . Source . Manifest . ID ,
SourceDigest : status . State . Source . Manifest . Digest ,
SourceCreated : status . State . Source . Manifest . Created ,
CreatedAt : output . CreatedAt ,
UpdatedAt : output . UpdatedAt ,
} )
}
return outputs
}
return nil
}
2026-05-31 17:29:20 +00:00
func actionForComparison ( comparison state . Comparison , transfer config . TransferPolicy , force bool ) ( Action , string ) {
2026-05-31 02:21:36 +00:00
switch comparison . Outcome {
case state . OutcomeDestinationAbsent :
return ActionPublishNew , comparison . Reason
case state . OutcomeDestinationUnmanaged :
2026-05-31 17:29:20 +00:00
if force {
return ActionForceReplace , "forced replacement of unmanaged destination content"
}
2026-05-31 02:21:36 +00:00
return ActionFailUnmanaged , comparison . Reason
2026-05-31 17:29:20 +00:00
case state . OutcomeInvalidState :
return ActionFailConflict , comparison . Reason
case state . OutcomeIdentityMismatch , state . OutcomeSameCreatedConflict , state . OutcomeDifferentSourceConflict :
if transfer . OnConflict == config . TransferActionReplace {
if force {
return ActionForceReplace , "forced replacement of conflicting destination state: " + comparison . Reason
}
return ActionFailConflict , "destination conflict replacement requires --force"
}
2026-05-31 02:21:36 +00:00
return ActionFailConflict , comparison . Reason
case state . OutcomeSameSource :
if transfer . OnDestinationSame == config . TransferActionFail {
return ActionFailConflict , "destination matches source and transfer policy requires failure"
}
return ActionSkipSame , comparison . Reason
case state . OutcomeDestinationOlder :
if transfer . OnDestinationOlder == config . TransferActionFail {
return ActionFailConflict , "destination is older and transfer policy requires failure"
}
return ActionReplaceOlder , comparison . Reason
case state . OutcomeDestinationNewer :
2026-05-31 17:29:20 +00:00
if transfer . OnDestinationNewer == config . TransferActionReplace {
if force {
return ActionForceReplace , "forced replacement of newer destination state"
}
return ActionFailConflict , "destination is newer and replacement requires --force"
}
2026-05-31 02:21:36 +00:00
if transfer . OnDestinationNewer == config . TransferActionFail {
return ActionFailConflict , "destination is newer and transfer policy requires failure"
}
return ActionSkipDestinationNewer , comparison . Reason
default :
return ActionFailConflict , "unsupported comparison outcome"
}
}
2026-06-18 15:18:38 +00:00
func takeoverActionAllowed ( req Request , status state . DestinationStatus , comparison state . Comparison , stateMode string , action Action ) bool {
if stateMode != config . StateModeSingleOwner || status . State == nil {
return false
}
if action == ActionForceReplace {
return false
}
switch comparison . Detail . Kind {
case state . ComparisonDetailPipelineIDMismatch ,
state . ComparisonDetailDestinationIDMismatch ,
state . ComparisonDetailDifferentSourceID :
default :
return false
}
takeover := normalizeTakeover ( req . Takeover )
switch takeover . Mode {
case config . TakeoverModeSamePipeline :
return status . State . PipelineID == req . PipelineID
case config . TakeoverModeSameSource :
return status . State . Source . Manifest . ID == req . SourceBundle . Manifest . ID
case config . TakeoverModeAnyManaged :
return true
case config . TakeoverModeNever :
return false
default :
return false
}
}