Files

382 lines
12 KiB
Go
Raw Permalink 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 (
2026-06-19 17:12:21 +00:00
ActionPublishNew Action = "publish_new"
ActionSkipSame Action = "skip_same"
ActionFailConflict Action = "fail_conflict"
ActionFailUnmanaged Action = "fail_unmanaged"
ActionForceReplace Action = "force_replace"
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
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.Reason = status.StateErr.Error()
2026-06-19 16:39:01 +00:00
if req.Force {
plan.Action = ActionForceReplace
plan.Force = true
plan.ClearDestinationRoot = true
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
return plan, nil
}
plan.Action = ActionFailConflict
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:
2026-06-19 16:39:01 +00:00
details, err = planWithoutCatalog(ctx, req, outputs, workflow, scope, now, status.HasContents)
2026-06-19 15:36:26 +00:00
}
plan.Action = details.Action
plan.Reason = details.Reason
plan.CatalogOutputsToWrite = details.CatalogOutputsToWrite
plan.CatalogOutputsToRetain = details.CatalogOutputsToRetain
plan.CatalogOutputsToDelete = details.CatalogOutputsToDelete
plan.ClearDestinationRoot = details.ClearDestinationRoot
2026-06-19 16:39:01 +00:00
if err != nil && req.Force && forceCanReplace(details.Action) {
plan.Action = ActionForceReplace
plan.Force = true
plan.ClearDestinationRoot = true
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
plan.CatalogOutputsToRetain = nil
plan.CatalogOutputsToDelete = nil
return plan, nil
}
2026-06-19 15:36:26 +00:00
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
}
allPlannedOutputsMatch := catalogContainsMatchingOutputs(req, catalog.Outputs, outputs, scope)
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
}
if allPlannedOutputsMatch && (workflow == config.WorkflowAdditive || len(details.CatalogOutputsToDelete) == 0) {
details.Action = ActionSkipSame
details.CatalogOutputsToWrite = nil
details.CatalogOutputsToDelete = nil
}
2026-06-08 18:53:22 +00:00
return details, nil
}
func catalogContainsMatchingOutputs(req Request, existing []state.CatalogOutputFile, outputs []Output, scope state.OwnerScope) bool {
for _, output := range outputs {
catalogOutput, ok := state.FindCatalogOutputByPath(existing, output.DestinationPath)
if !ok || !catalogOutputMatchesPlan(req, catalogOutput, output, scope) {
return false
}
}
return true
}
func catalogOutputMatchesPlan(req Request, catalogOutput state.CatalogOutputFile, output Output, scope state.OwnerScope) bool {
if catalogOutput.PipelineID != scope.PipelineID ||
catalogOutput.DestinationID != scope.DestinationID ||
catalogOutput.Source.ID != req.SourceBundle.Manifest.ID ||
catalogOutput.Source.Digest != req.SourceBundle.Manifest.Digest ||
!catalogOutput.Source.Created.Equal(req.SourceBundle.Manifest.Created) ||
catalogOutput.Path != output.DestinationPath ||
catalogOutput.Kind != output.Kind ||
catalogOutput.URL != output.URL ||
catalogOutput.SHA256 != output.SHA256 ||
catalogOutput.Size != output.Size {
return false
}
if output.Kind == state.OutputKindGenerated {
return catalogOutput.SourcePath == output.SourcePath && catalogOutput.Transform == output.Transform
}
return catalogOutput.SourcePath == "" && catalogOutput.Transform == ""
}
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 16:39:01 +00:00
func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time, hasContents bool) (catalogPlanDetails, error) {
if hasContents {
err := fmt.Errorf("destination has content but no distributor state")
return catalogPlanDetails{
Action: ActionFailUnmanaged,
Reason: err.Error(),
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
}
2026-06-19 15:36:26 +00:00
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 16:39:01 +00:00
func forceCanReplace(action Action) bool {
return action == ActionFailUnmanaged || action == ActionFailConflict
}
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
}