Centralize publish output projections
This commit is contained in:
@@ -434,13 +434,14 @@ func shouldNotify(action publish.Action) bool {
|
||||
func notifyEvent(plan publish.Plan) notify.Event {
|
||||
outputs := make([]notify.Output, 0, len(plan.Outputs))
|
||||
for _, output := range plan.Outputs {
|
||||
stateOutput := output.StateOutputFile()
|
||||
outputs = append(outputs, notify.Output{
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
Path: stateOutput.Path,
|
||||
Kind: stateOutput.Kind,
|
||||
SourcePath: stateOutput.SourcePath,
|
||||
Transform: stateOutput.Transform,
|
||||
SHA256: stateOutput.SHA256,
|
||||
Size: stateOutput.Size,
|
||||
})
|
||||
}
|
||||
return notify.Event{
|
||||
@@ -542,14 +543,15 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro
|
||||
func runOutputsFromPlan(outputs []publish.Output) []runOutputResult {
|
||||
results := make([]runOutputResult, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
stateOutput := output.StateOutputFile()
|
||||
results = append(results, runOutputResult{
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
URL: output.URL,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
Path: stateOutput.Path,
|
||||
Kind: stateOutput.Kind,
|
||||
SourcePath: stateOutput.SourcePath,
|
||||
Transform: stateOutput.Transform,
|
||||
URL: stateOutput.URL,
|
||||
SHA256: stateOutput.SHA256,
|
||||
Size: stateOutput.Size,
|
||||
})
|
||||
}
|
||||
return results
|
||||
|
||||
@@ -622,6 +622,32 @@ func TestRunNotifiesAfterPublication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNotifiesGeneratedOutputMetadata(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, false, true),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got, want := len(notifier.events), 1; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
outputs := notifier.events[0].Outputs
|
||||
if got, want := len(outputs), 1; got != want {
|
||||
t.Fatalf("notification output count = %d, want %d", got, want)
|
||||
}
|
||||
output := outputs[0]
|
||||
if output.Path != "report.html" || output.Kind != state.OutputKindGenerated || output.SourcePath != "report.md" || output.Transform != "markdown_to_html" || output.SHA256 == "" || output.Size <= 0 {
|
||||
t.Fatalf("notification output = %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
@@ -649,6 +675,49 @@ func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJSONIncludesGeneratedOutputMetadata(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex),
|
||||
DryRun: true,
|
||||
Stdout: &stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
result := decodeAppResult(t, stdout.String())
|
||||
actions, ok := result["actions"].([]any)
|
||||
if !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v, want one action", result["actions"])
|
||||
}
|
||||
action, ok := actions[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("action = %#v, want object", actions[0])
|
||||
}
|
||||
if action["primary_url"] != "https://reports.example.com/latest/" {
|
||||
t.Fatalf("action primary_url = %#v", action["primary_url"])
|
||||
}
|
||||
outputs, ok := action["outputs"].([]any)
|
||||
if !ok || len(outputs) != 1 {
|
||||
t.Fatalf("outputs = %#v, want one output", action["outputs"])
|
||||
}
|
||||
output, ok := outputs[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("output = %#v, want object", outputs[0])
|
||||
}
|
||||
if output["path"] != "index.html" || output["kind"] != state.OutputKindGenerated || output["source_path"] != "report.md" || output["transform"] != "markdown_to_html" || output["url"] != "https://reports.example.com/latest/" {
|
||||
t.Fatalf("output = %#v, want generated index metadata", output)
|
||||
}
|
||||
if output["sha256"] == "" || output["size"] == nil {
|
||||
t.Fatalf("output = %#v, want digest and size", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
|
||||
@@ -23,7 +23,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
if plan.ExistingState == nil {
|
||||
return fmt.Errorf("replace requires existing destination state")
|
||||
}
|
||||
if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, existingManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, stateOutputManagedPaths(plan.ExistingState.Outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
@@ -41,7 +41,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
|
||||
writtenOutputs := make([]Output, 0, len(plan.Outputs))
|
||||
cleanup := func() {
|
||||
_ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, managedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
_ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, ManagedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
}
|
||||
for _, output := range plan.Outputs {
|
||||
destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath)
|
||||
@@ -76,7 +76,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
DestinationID: req.DestinationID,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Source: state.SourceState{Manifest: req.SourceBundle.Manifest},
|
||||
Outputs: stateOutputs(plan.Outputs),
|
||||
Outputs: StateOutputFiles(plan.Outputs),
|
||||
}
|
||||
if plan.PrimaryURL != "" {
|
||||
destinationState.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL}
|
||||
@@ -102,11 +102,3 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingManagedOutputPaths(destinationState state.DistributorState) []string {
|
||||
paths := make([]string, 0, len(destinationState.Outputs))
|
||||
for _, output := range destinationState.Outputs {
|
||||
paths = append(paths, output.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -97,26 +97,42 @@ func rejectOutputCollisions(outputs []Output) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateOutputs(outputs []Output) []state.OutputFile {
|
||||
func (o Output) StateOutputFile() state.OutputFile {
|
||||
return state.OutputFile{
|
||||
Path: o.DestinationPath,
|
||||
Kind: o.Kind,
|
||||
SourcePath: o.SourcePath,
|
||||
Transform: o.Transform,
|
||||
URL: o.URL,
|
||||
SHA256: o.SHA256,
|
||||
Size: o.Size,
|
||||
}
|
||||
}
|
||||
|
||||
func (o Output) ManagedPath() string {
|
||||
return o.DestinationPath
|
||||
}
|
||||
|
||||
func StateOutputFiles(outputs []Output) []state.OutputFile {
|
||||
files := make([]state.OutputFile, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
files = append(files, state.OutputFile{
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
URL: output.URL,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
files = append(files, output.StateOutputFile())
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func managedOutputPaths(outputs []Output) []string {
|
||||
func ManagedOutputPaths(outputs []Output) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.DestinationPath)
|
||||
paths = append(paths, output.ManagedPath())
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func stateOutputManagedPaths(outputs []state.OutputFile) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -6,11 +6,58 @@ import (
|
||||
|
||||
"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/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
)
|
||||
|
||||
func TestOutputStateProjection(t *testing.T) {
|
||||
sourceOutput := Output{
|
||||
SourcePath: "report.md",
|
||||
DestinationPath: "report.md",
|
||||
Kind: state.OutputKindSource,
|
||||
URL: "https://reports.example.com/report.md",
|
||||
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Size: 123,
|
||||
}
|
||||
sourceState := sourceOutput.StateOutputFile()
|
||||
if sourceState.Path != "report.md" || sourceState.Kind != state.OutputKindSource || sourceState.SourcePath != "report.md" || sourceState.URL != sourceOutput.URL || sourceState.SHA256 != sourceOutput.SHA256 || sourceState.Size != sourceOutput.Size {
|
||||
t.Fatalf("source state output = %#v", sourceState)
|
||||
}
|
||||
|
||||
generatedOutput := Output{
|
||||
SourcePath: "report.md",
|
||||
DestinationPath: "report.html",
|
||||
Kind: state.OutputKindGenerated,
|
||||
Transform: transform.MarkdownToHTML,
|
||||
URL: "https://reports.example.com/report.html",
|
||||
SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Size: 456,
|
||||
}
|
||||
generatedState := generatedOutput.StateOutputFile()
|
||||
if generatedState.Path != "report.html" || generatedState.Kind != state.OutputKindGenerated || generatedState.SourcePath != "report.md" || generatedState.Transform != transform.MarkdownToHTML || generatedState.URL != generatedOutput.URL || generatedState.SHA256 != generatedOutput.SHA256 || generatedState.Size != generatedOutput.Size {
|
||||
t.Fatalf("generated state output = %#v", generatedState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputSliceProjections(t *testing.T) {
|
||||
outputs := []Output{
|
||||
{SourcePath: "report.md", DestinationPath: "report.md", Kind: state.OutputKindSource, SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1},
|
||||
{SourcePath: "report.md", DestinationPath: "report.html", Kind: state.OutputKindGenerated, Transform: transform.MarkdownToHTML, URL: "https://reports.example.com/report.html", SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Size: 2},
|
||||
}
|
||||
|
||||
stateOutputs := StateOutputFiles(outputs)
|
||||
if len(stateOutputs) != 2 || stateOutputs[1].Path != "report.html" || stateOutputs[1].Transform != transform.MarkdownToHTML || stateOutputs[1].URL != outputs[1].URL {
|
||||
t.Fatalf("state outputs = %#v", stateOutputs)
|
||||
}
|
||||
|
||||
paths := ManagedOutputPaths(outputs)
|
||||
if len(paths) != 2 || paths[0] != "report.md" || paths[1] != "report.html" {
|
||||
t.Fatalf("managed paths = %#v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOutputsRejectsCollision(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
|
||||
|
||||
Reference in New Issue
Block a user