Files
distributor/internal/publish/plan.go

356 lines
11 KiB
Go
Raw Normal View History

2026-05-31 02:21:36 +00:00
package publish
import (
"context"
"fmt"
2026-06-19 15:36:26 +00:00
"time"
2026-05-31 02:21:36 +00:00
"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"
ActionReplaceConflict Action = "replace_conflict"
ActionReplaceNewer Action = "replace_newer"
2026-05-31 02:21:36 +00:00
ActionSkipSame Action = "skip_same"
ActionSkipDestinationNewer Action = "skip_destination_newer"
ActionFailConflict Action = "fail_conflict"
ActionFailUnmanaged Action = "fail_unmanaged"
ActionForceReplace Action = "force_replace"
ActionReplaceTakeover Action = "replace_takeover"
2026-06-19 15:36:26 +00:00
ActionUpsertAdditive Action = "upsert_additive"
ActionReplaceCatalog Action = "replace_catalog"
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-19 15:36:26 +00:00
Workflow string
2026-05-31 03:30:08 +00:00
Transformers TransformerResolver
2026-05-31 02:21:36 +00:00
DistributorVersion string
Force bool
2026-06-19 15:36:26 +00:00
Now time.Time
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-06-19 15:36:26 +00:00
type Output struct {
SourcePath string
DestinationPath string
Kind string
Transform string
URL string
Data []byte
SHA256 string
Size int64
}
2026-05-31 02:21:36 +00:00
type Plan struct {
2026-06-19 15:36:26 +00:00
PipelineID string
DestinationID string
BundleID string
BundlePath string
DestinationBundlePath string
PathMapping string
Action Action
Reason string
Force bool
PrimaryURL string
Workflow string
OwnerScope state.OwnerScope
Outputs []Output
ExistingCatalog *state.CatalogState
SupersededLegacy *state.SupersededLegacyState
CatalogOutputsToWrite []state.CatalogOutputFile
CatalogOutputsToRetain []state.CatalogOutputFile
CatalogOutputsToDelete []state.CatalogOutputFile
ClearDestinationRoot bool
// Retained while the executor is migrated to catalog state.
2026-06-08 18:53:22 +00:00
StateMode string
Reconciliation config.ReconciliationPolicy
2026-06-18 15:35:06 +00:00
TakeoverMode string
2026-05-31 02:21:36 +00:00
ExistingState *state.DistributorState
2026-06-08 18:53:22 +00:00
ExistingSharedRoot *state.SharedRootState
OtherOwnerOutputs []state.SharedRootOutputFile
2026-06-18 15:27:50 +00:00
TakenOverOwnerOutputs []state.SharedRootOutputFile
2026-06-08 18:53:22 +00:00
RetainedOwnerOutputs []state.SharedRootOutputFile
OwnerOutputsToDelete []state.SharedRootOutputFile
OwnerOutputsToWrite []Output
2026-05-31 02:21:36 +00:00
}
2026-06-19 15:36:26 +00:00
type catalogPlanDetails struct {
Action Action
Reason string
CatalogOutputsToWrite []state.CatalogOutputFile
CatalogOutputsToRetain []state.CatalogOutputFile
CatalogOutputsToDelete []state.CatalogOutputFile
ClearDestinationRoot bool
2026-05-31 02:21:36 +00:00
}
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-19 15:36:26 +00:00
workflow := normalizeWorkflow(req.Workflow)
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
now := requestTime(req)
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-06-01 21:42:00 +00:00
PrimaryURL: primaryURL,
2026-06-19 15:36:26 +00:00
Workflow: workflow,
OwnerScope: scope,
2026-05-31 02:21:36 +00:00
Outputs: outputs,
2026-06-19 15:36:26 +00:00
ExistingCatalog: status.Catalog,
SupersededLegacy: status.SupersededLegacy,
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
if status.StateErr != nil {
plan.Action = ActionFailConflict
plan.Reason = status.StateErr.Error()
2026-06-18 15:27:50 +00:00
return plan, fmt.Errorf("%s: %s", plan.Action, plan.Reason)
2026-05-31 02:21:36 +00:00
}
2026-06-19 15:36:26 +00:00
var details catalogPlanDetails
switch {
case status.Catalog != nil:
details, err = planExistingCatalog(ctx, req, *status.Catalog, outputs, workflow, scope, now)
case status.SupersededLegacy != nil:
details = planSupersededLegacy(req, outputs, workflow, scope, now)
default:
details, err = planWithoutCatalog(ctx, req, outputs, workflow, scope, now)
}
plan.Action = details.Action
plan.Reason = details.Reason
plan.CatalogOutputsToWrite = details.CatalogOutputsToWrite
plan.CatalogOutputsToRetain = details.CatalogOutputsToRetain
plan.CatalogOutputsToDelete = details.CatalogOutputsToDelete
plan.ClearDestinationRoot = details.ClearDestinationRoot
if err != nil {
return plan, err
}
2026-05-31 02:21:36 +00:00
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")
}
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-19 15:36:26 +00:00
switch normalizeWorkflow(req.Workflow) {
case config.WorkflowAdditive, config.WorkflowReplacement:
default:
2026-06-19 15:36:26 +00:00
return fmt.Errorf("destination.workflow must be %s or %s", config.WorkflowAdditive, config.WorkflowReplacement)
}
2026-05-31 02:21:36 +00:00
return nil
}
2026-06-19 15:36:26 +00:00
func normalizeWorkflow(workflow string) string {
if workflow == "" {
return config.WorkflowAdditive
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
return workflow
2026-06-01 21:33:12 +00:00
}
2026-06-19 15:36:26 +00:00
func requestTime(req Request) time.Time {
if req.Now.IsZero() {
return time.Now().UTC()
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
return req.Now.UTC()
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
func planExistingCatalog(ctx context.Context, req Request, catalog state.CatalogState, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) (catalogPlanDetails, error) {
if err := rejectCatalogUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, catalog.Outputs, outputs); err != nil {
return catalogPlanDetails{
Action: ActionFailUnmanaged,
Reason: err.Error(),
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
}
2026-06-19 15:36:26 +00:00
planned := outputPathSet(outputs)
details := catalogPlanDetails{
Action: actionForWorkflow(workflow),
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, catalog.Outputs, scope, now),
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
for _, output := range catalog.Outputs {
2026-06-08 18:53:22 +00:00
if _, exists := planned[output.Path]; exists {
continue
}
2026-06-19 15:36:26 +00:00
if workflow == config.WorkflowReplacement && output.PipelineID == scope.PipelineID && output.DestinationID == scope.DestinationID {
details.CatalogOutputsToDelete = append(details.CatalogOutputsToDelete, output)
2026-06-08 18:53:22 +00:00
continue
}
2026-06-19 15:36:26 +00:00
details.CatalogOutputsToRetain = append(details.CatalogOutputsToRetain, output)
2026-06-08 18:53:22 +00:00
}
return details, nil
}
2026-06-19 15:36:26 +00:00
func planSupersededLegacy(req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) catalogPlanDetails {
details := catalogPlanDetails{
Action: actionForWorkflow(workflow),
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, nil, scope, now),
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
if workflow == config.WorkflowReplacement {
details.ClearDestinationRoot = true
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
return details
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) (catalogPlanDetails, error) {
if err := rejectCatalogUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, nil, outputs); err != nil {
return catalogPlanDetails{
Action: ActionFailUnmanaged,
Reason: err.Error(),
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
}
return catalogPlanDetails{
Action: ActionPublishNew,
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, nil, scope, now),
}, nil
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
func actionForWorkflow(workflow string) Action {
if workflow == config.WorkflowReplacement {
return ActionReplaceCatalog
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
return ActionUpsertAdditive
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
func catalogOutputsForPlan(req Request, outputs []Output, existing []state.CatalogOutputFile, scope state.OwnerScope, now time.Time) []state.CatalogOutputFile {
files := make([]state.CatalogOutputFile, 0, len(outputs))
source := state.CatalogSourceIdentity{
ID: req.SourceBundle.Manifest.ID,
Digest: req.SourceBundle.Manifest.Digest,
Created: req.SourceBundle.Manifest.Created,
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
for _, output := range outputs {
createdAt := now
if existingOutput, ok := state.FindCatalogOutputByPath(existing, output.DestinationPath); ok {
createdAt = existingOutput.CreatedAt
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
file := state.CatalogOutputFile{
Path: output.DestinationPath,
PipelineID: scope.PipelineID,
DestinationID: scope.DestinationID,
Source: source,
Kind: output.Kind,
URL: output.URL,
SHA256: output.SHA256,
Size: output.Size,
CreatedAt: createdAt,
UpdatedAt: now,
}
if output.Kind == state.OutputKindGenerated {
file.SourcePath = output.SourcePath
file.Transform = output.Transform
}
files = append(files, file)
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
return files
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
func rejectCatalogUnmanagedCollisions(ctx context.Context, backend storage.Backend, bundlePath string, existing []state.CatalogOutputFile, outputs []Output) error {
managed := catalogOutputPathSet(existing)
for _, output := range outputs {
if _, exists := managed[output.DestinationPath]; exists {
2026-06-08 18:53:22 +00:00
continue
}
2026-06-19 15:36:26 +00:00
destinationPath, err := storage.Join(bundlePath, output.DestinationPath)
2026-06-08 18:53:22 +00:00
if err != nil {
return err
}
if _, err := backend.Stat(ctx, destinationPath); err == nil {
2026-06-19 15:36:26 +00:00
return fmt.Errorf("destination output path %s exists but is not managed by catalog state", storage.DisplayPath(output.DestinationPath))
2026-06-08 18:53:22 +00:00
} else if !storage.IsNotFound(err) {
return err
}
}
return nil
}
2026-06-19 15:36:26 +00:00
func outputPaths(outputs []Output) []string {
paths := make([]string, 0, len(outputs))
for _, output := range outputs {
paths = append(paths, output.DestinationPath)
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
return paths
2026-06-18 15:27:50 +00:00
}
2026-06-19 15:36:26 +00:00
func outputPathSet(outputs []Output) map[string]struct{} {
paths := make(map[string]struct{}, len(outputs))
for _, output := range outputs {
paths[output.DestinationPath] = struct{}{}
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
return paths
2026-06-08 18:53:22 +00:00
}
2026-06-19 15:36:26 +00:00
func catalogOutputPathSet(outputs []state.CatalogOutputFile) map[string]struct{} {
2026-06-18 15:27:50 +00:00
paths := make(map[string]struct{}, len(outputs))
for _, output := range outputs {
paths[output.Path] = struct{}{}
}
return paths
}
2026-06-19 15:36:26 +00:00
func normalizeReconciliation(policy config.ReconciliationPolicy) config.ReconciliationPolicy {
if policy.Mode == "" {
policy.Mode = config.ReconciliationModeReplace
2026-05-31 02:21:36 +00:00
}
2026-06-19 15:36:26 +00:00
return policy
2026-05-31 02:21:36 +00:00
}
func isReconciliationReplacementAction(action Action) bool {
return action == ActionReplaceOlder || action == ActionReplaceNewer
}
2026-06-19 15:36:26 +00:00
func sharedRootOutputPathSet(outputs []state.SharedRootOutputFile) map[string]struct{} {
paths := make(map[string]struct{}, len(outputs))
for _, output := range outputs {
paths[output.Path] = struct{}{}
}
2026-06-19 15:36:26 +00:00
return paths
}