package publish import ( "context" "encoding/json" "fmt" "time" "gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/storage" ) func Execute(ctx context.Context, req Request, plan Plan) error { plan.Reconciliation = normalizeReconciliation(plan.Reconciliation) switch plan.Action { case ActionSkipSame, ActionSkipDestinationNewer: return nil case ActionPublishNew, ActionReplaceOlder, ActionForceReplace: default: return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason) } if plan.Action == ActionReplaceOlder { if plan.ExistingState == nil { return fmt.Errorf("replace requires existing destination state") } if plan.Reconciliation.Mode == config.ReconciliationModeReplace { if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, state.ManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { return err } if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil { return err } } } if plan.Action == ActionForceReplace { if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { return err } if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil { return err } } if usesMergeRetention(plan) { if err := ensureMergeOutputPaths(ctx, req.DestinationBackend, req.DestinationBundlePath, plan); err != nil { return err } } writtenOutputs := make([]Output, 0, len(plan.Outputs)) newOutputs := make([]Output, 0, len(plan.Outputs)) cleanup := func() { outputs := writtenOutputs if usesMergeRetention(plan) { outputs = newOutputs } _ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}) } for _, output := range plan.Outputs { destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath) if err != nil { cleanup() return err } data := output.Data if output.Kind == state.OutputKindSource { sourcePath, err := storage.Join(req.SourceBundle.RootRelativePath, output.SourcePath) if err != nil { cleanup() return err } data, err = req.SourceBackend.ReadFile(ctx, sourcePath) if err != nil { cleanup() return err } } managed := outputManagedByExistingState(output, plan.ExistingState) if _, err := req.DestinationBackend.WriteFile(ctx, destinationPath, data, storage.WriteOptions{Overwrite: managed && usesMergeRetention(plan), PreferAtomic: true}); err != nil { cleanup() return err } writtenOutputs = append(writtenOutputs, output) if !managed { newOutputs = append(newOutputs, output) } } now := time.Now().UTC() createdAt := now if plan.ExistingState != nil { createdAt = plan.ExistingState.CreatedAt } stateOutputs, err := stateOutputsForPlan(plan, now) if err != nil { cleanup() return err } destinationState := state.DistributorState{ SchemaVersion: state.SchemaVersion, DistributorVersion: req.DistributorVersion, PipelineID: req.PipelineID, DestinationID: req.DestinationID, PublishedAt: now, CreatedAt: createdAt, UpdatedAt: now, State: state.StatePolicy{Mode: state.StateModeSingleOwner}, Reconciliation: state.ReconciliationPolicy{Mode: plan.Reconciliation.Mode}, Source: state.SourceState{Manifest: req.SourceBundle.Manifest}, Outputs: stateOutputs, } if plan.PrimaryURL != "" { destinationState.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL} } if err := state.Validate(destinationState); err != nil { cleanup() return err } data, err := json.MarshalIndent(destinationState, "", " ") if err != nil { cleanup() return err } data = append(data, '\n') statePath, err := storage.StatePath(req.DestinationBundlePath) if err != nil { cleanup() return err } if _, err := req.DestinationBackend.WriteFile(ctx, statePath, data, storage.WriteOptions{Overwrite: plan.ExistingState != nil, PreferAtomic: true}); err != nil { cleanup() return err } return nil } func ensureMergeOutputPaths(ctx context.Context, backend storage.Backend, bundlePath string, plan Plan) error { for _, output := range plan.Outputs { if outputManagedByExistingState(output, plan.ExistingState) { continue } destinationPath, err := storage.Join(bundlePath, output.DestinationPath) if err != nil { return err } if _, err := backend.Stat(ctx, destinationPath); err == nil { return fmt.Errorf("merge output path %s exists but is not managed by destination state", storage.DisplayPath(output.DestinationPath)) } else if !storage.IsNotFound(err) { return err } } return nil } func outputManagedByExistingState(output Output, existing *state.DistributorState) bool { if existing == nil { return false } _, ok := state.FindOutputByPath(existing.Outputs, output.DestinationPath) return ok } func stateOutputsForPlan(plan Plan, now time.Time) ([]state.OutputFile, error) { existingOutputs := []state.OutputFile(nil) if plan.ExistingState != nil { existingOutputs = plan.ExistingState.Outputs } planned := state.ProjectOutputs(StateOutputProjections(plan.Outputs), existingOutputs, now) if !usesMergeRetention(plan) || plan.ExistingState == nil { return planned, nil } return state.MergeOutputFiles(plan.ExistingState.Outputs, planned) } func usesMergeRetention(plan Plan) bool { return plan.Reconciliation.Mode == config.ReconciliationModeMerge && plan.Action == ActionReplaceOlder }