Add destination link metadata

This commit is contained in:
2026-06-01 21:42:00 +00:00
parent a8564035d3
commit 980ae15249
24 changed files with 737 additions and 16 deletions

View File

@@ -164,6 +164,7 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
PathMapping: destination.PathMap.Mode,
Publish: *destination.Publish,
Transform: destination.Transform,
Links: destination.Links,
Transformers: transforms,
Transfer: destination.Transfer,
DistributorVersion: Version,
@@ -475,6 +476,7 @@ type runActionResult struct {
DestinationPath string `json:"destination_path"`
PathMapping string `json:"path_mapping,omitempty"`
Action string `json:"action"`
PrimaryURL string `json:"primary_url,omitempty"`
Reason string `json:"reason,omitempty"`
Outputs []runOutputResult `json:"outputs"`
}
@@ -484,6 +486,7 @@ type runOutputResult struct {
Kind string `json:"kind"`
SourcePath string `json:"source_path,omitempty"`
Transform string `json:"transform,omitempty"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
@@ -503,6 +506,7 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
PathMapping: plan.PathMapping,
Action: "error",
PrimaryURL: plan.PrimaryURL,
Reason: planErr.Error(),
Outputs: []runOutputResult{},
}
@@ -516,6 +520,7 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
PathMapping: plan.PathMapping,
Action: string(plan.Action),
PrimaryURL: plan.PrimaryURL,
Reason: plan.Reason,
Outputs: runOutputsFromPlan(plan.Outputs),
}
@@ -542,6 +547,7 @@ func runOutputsFromPlan(outputs []publish.Output) []runOutputResult {
Kind: output.Kind,
SourcePath: output.SourcePath,
Transform: output.Transform,
URL: output.URL,
SHA256: output.SHA256,
Size: output.Size,
})

View File

@@ -215,6 +215,9 @@ func TestRunPublishesNewLocalBundle(t *testing.T) {
if got, want := len(destinationState.Outputs), 2; got != want {
t.Fatalf("state output count = %d, want %d", got, want)
}
if destinationState.Links != nil || destinationState.Outputs[0].URL != "" {
t.Fatalf("state links = %#v output URL=%q, want absent", destinationState.Links, destinationState.Outputs[0].URL)
}
}
func TestRunExplicitPreserveRelativePathMappingMatchesDefault(t *testing.T) {
@@ -232,6 +235,54 @@ func TestRunExplicitPreserveRelativePathMappingMatchesDefault(t *testing.T) {
}
}
func TestRunRecordsLinksForNestedBundlePath(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "daily/brentwood", testBundleOptions{})
err := Run(context.Background(), RunOptions{
ConfigPath: writeLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingPreserveRelative, "https://reports.example.com/archive", config.LinkPrimaryAuto, true, false, ""),
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
destinationState := readStateFile(t, filepath.Join(destinationRoot, "daily", "brentwood", storage.StateFileName))
if destinationState.Links == nil || destinationState.Links.PrimaryURL != "https://reports.example.com/archive/daily/brentwood/report.md" {
t.Fatalf("state links = %#v, want source primary URL", destinationState.Links)
}
outputs := outputsByPath(destinationState.Outputs)
if outputs["report.md"].URL != "https://reports.example.com/archive/daily/brentwood/report.md" {
t.Fatalf("report URL = %q", outputs["report.md"].URL)
}
if outputs["summary.txt"].URL != "https://reports.example.com/archive/daily/brentwood/summary.txt" {
t.Fatalf("summary URL = %q", outputs["summary.txt"].URL)
}
}
func TestRunRecordsLinksForFixedIndexDestination(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "older", testBundleOptions{ID: "reports.older", Created: testutil.DefaultCreated})
writeSourceBundle(t, sourceRoot, "newer", testBundleOptions{ID: "reports.newer", Created: testutil.DefaultCreated.Add(time.Hour)})
err := Run(context.Background(), RunOptions{
ConfigPath: writeLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex),
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
if destinationState.Links == nil || destinationState.Links.PrimaryURL != "https://reports.example.com/latest/" {
t.Fatalf("state links = %#v, want fixed index primary URL", destinationState.Links)
}
if got, want := len(destinationState.Outputs), 1; got != want {
t.Fatalf("state output count = %d, want %d", got, want)
}
if destinationState.Outputs[0].Path != "index.html" || destinationState.Outputs[0].URL != "https://reports.example.com/latest/" {
t.Fatalf("state output = %#v, want index URL", destinationState.Outputs[0])
}
}
func TestRunFixedPathPublishesNewestBundleAtDestinationRoot(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
@@ -1264,6 +1315,37 @@ pipelines:
`)
}
func writeLocalConfigWithLinks(t *testing.T, sourceRoot, destinationRoot, pathMapping, baseURL, primary string, publishSource, publishHTML bool, transformMode string) string {
t.Helper()
transformConfig := ""
if publishHTML {
transformConfig = `
transform:
markdown_to_html:
enabled: true
mode: ` + transformMode
}
return writeConfigFile(t, `
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
path_mapping:
mode: `+pathMapping+`
links:
base_url: `+baseURL+`
primary: `+primary+`
publish:
source: `+fmt.Sprintf("%t", publishSource)+`
html: `+fmt.Sprintf("%t", publishHTML)+transformConfig+`
`)
}
func writeLocalConfigWithMarkdownTransform(t *testing.T, sourceRoot, destinationRoot string, publishSource, publishHTML bool, mode, input string) string {
t.Helper()
enabled := publishHTML
@@ -1344,6 +1426,14 @@ func readStateFile(t *testing.T, path string) state.DistributorState {
return testutil.ReadDestinationState(t, path)
}
func outputsByPath(outputs []state.OutputFile) map[string]state.OutputFile {
byPath := make(map[string]state.OutputFile, len(outputs))
for _, output := range outputs {
byPath[output.Path] = output
}
return byPath
}
func assertFile(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)

View File

@@ -675,6 +675,58 @@ pipelines:
}
}
func TestExecuteRunJSONDryRunReportsLinks(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(`
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: web
backend: local
path: `+destinationRoot+`
links:
base_url: https://reports.example.com/archive
primary: source
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--dry-run", "--format", "json"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
result := envelopeResult(t, envelope)
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 || action["primary_url"] != "https://reports.example.com/archive/report.md" {
t.Fatalf("action = %#v, want primary URL", actions[0])
}
outputs, ok := action["outputs"].([]any)
if !ok || len(outputs) != 2 {
t.Fatalf("outputs = %#v, want two outputs", action["outputs"])
}
output, ok := outputs[0].(map[string]any)
if !ok || output["url"] != "https://reports.example.com/archive/report.md" {
t.Fatalf("output = %#v, want output URL", outputs[0])
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRunJSONWarningsAreStructured(t *testing.T) {
name := "DISTRIBUTOR_TEST_CLI_JSON_SECRET"
t.Setenv(name, "process-value")

View File

@@ -33,6 +33,7 @@ type Destination struct {
Publish *PublishPolicy `yaml:"publish"`
Transform Transform `yaml:"transform"`
PathMap PathMapping `yaml:"path_mapping"`
Links *Links `yaml:"links"`
Transfer TransferPolicy `yaml:"transfer"`
}
@@ -85,6 +86,11 @@ type PathMapping struct {
Mode string `yaml:"mode"`
}
type Links struct {
BaseURL string `yaml:"base_url"`
Primary string `yaml:"primary"`
}
type TransferPolicy struct {
OnDestinationSame string `yaml:"on_destination_same"`
OnDestinationOlder string `yaml:"on_destination_older"`

View File

@@ -30,6 +30,12 @@ const (
PathMappingFixed = "fixed"
)
const (
LinkPrimaryAuto = "auto"
LinkPrimaryHTML = "html"
LinkPrimarySource = "source"
)
const DefaultS3Region = "us-east-1"
func ApplyDefaults(cfg *Config) {
@@ -51,6 +57,9 @@ func ApplyDefaults(cfg *Config) {
if destination.PathMap.Mode == "" {
destination.PathMap.Mode = PathMappingPreserveRelative
}
if destination.Links != nil && destination.Links.Primary == "" {
destination.Links.Primary = LinkPrimaryAuto
}
if destination.Transfer.OnDestinationSame == "" {
destination.Transfer.OnDestinationSame = TransferActionSkip
}

View File

@@ -184,6 +184,30 @@ pipelines:
}
}
func TestLoadFileDefaultsLinksPrimaryToAuto(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
- id: reports
source:
backend: local
path: /source
destinations:
- id: web
backend: local
path: /destination
links:
base_url: https://reports.example.com/archive
`)
links := cfg.Pipelines[0].Destinations[0].Links
if links == nil {
t.Fatal("links = nil, want config")
}
if links.BaseURL != "https://reports.example.com/archive" || links.Primary != LinkPrimaryAuto {
t.Fatalf("links = %#v, want base URL with auto primary", links)
}
}
func TestLoadFileValidBackendConfigs(t *testing.T) {
tests := map[string]string{
"local": `

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"net/url"
"regexp"
"strings"
)
@@ -59,6 +60,7 @@ func Validate(cfg Config) error {
errs = validateDestinationBackend(errs, destinationContext, destination)
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap)
errs = validateLinks(errs, destinationContext+".links", destination.Links)
errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer)
}
}
@@ -179,6 +181,43 @@ func validatePathMapping(errs ValidationErrors, context string, mapping PathMapp
return errs
}
func validateLinks(errs ValidationErrors, context string, links *Links) ValidationErrors {
if links == nil {
return errs
}
if links.BaseURL == "" {
errs = append(errs, context+".base_url is required")
} else if err := validateLinkBaseURL(links.BaseURL); err != nil {
errs = append(errs, context+".base_url "+err.Error())
}
switch links.Primary {
case LinkPrimaryAuto, LinkPrimaryHTML, LinkPrimarySource:
default:
errs = append(errs, context+".primary must be "+LinkPrimaryAuto+", "+LinkPrimaryHTML+", or "+LinkPrimarySource)
}
return errs
}
func validateLinkBaseURL(value string) error {
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("must be a valid URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("must use http or https")
}
if parsed.Host == "" {
return fmt.Errorf("must include a host")
}
if parsed.RawQuery != "" {
return fmt.Errorf("must not include a query string")
}
if parsed.Fragment != "" {
return fmt.Errorf("must not include a fragment")
}
return nil
}
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors {
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail {
errs = append(errs, context+".on_destination_same must be skip or fail")

View File

@@ -104,6 +104,47 @@ func TestValidatePathMapping(t *testing.T) {
}
}
func TestValidateLinks(t *testing.T) {
tests := []struct {
name string
links *Links
wantErr bool
}{
{name: "absent links"},
{name: "http", links: &Links{BaseURL: "http://reports.example.com/archive", Primary: LinkPrimaryAuto}},
{name: "https", links: &Links{BaseURL: "https://reports.example.com/archive/", Primary: LinkPrimaryHTML}},
{name: "source primary", links: &Links{BaseURL: "https://reports.example.com", Primary: LinkPrimarySource}},
{name: "missing base", links: &Links{Primary: LinkPrimaryAuto}, wantErr: true},
{name: "ftp scheme", links: &Links{BaseURL: "ftp://reports.example.com", Primary: LinkPrimaryAuto}, wantErr: true},
{name: "missing host", links: &Links{BaseURL: "https:///archive", Primary: LinkPrimaryAuto}, wantErr: true},
{name: "query", links: &Links{BaseURL: "https://reports.example.com/archive?preview=1", Primary: LinkPrimaryAuto}, wantErr: true},
{name: "fragment", links: &Links{BaseURL: "https://reports.example.com/archive#top", Primary: LinkPrimaryAuto}, wantErr: true},
{name: "invalid primary", links: &Links{BaseURL: "https://reports.example.com", Primary: "document"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Config{Pipelines: []Pipeline{{
ID: "reports",
Source: Backend{Backend: BackendLocal, Path: "/source"},
Destinations: []Destination{{
ID: "web",
Backend: BackendLocal,
Path: "/destination",
Links: tt.links,
}},
}}}
ApplyDefaults(&cfg)
err := Validate(cfg)
if tt.wantErr && err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !tt.wantErr && err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
type publishTransformPolicyCase struct {
name string
publish PublishPolicy

View File

@@ -78,6 +78,9 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
Source: state.SourceState{Manifest: req.SourceBundle.Manifest},
Outputs: stateOutputs(plan.Outputs),
}
if plan.PrimaryURL != "" {
destinationState.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL}
}
if err := state.Validate(destinationState); err != nil {
cleanup()
return err

116
internal/publish/links.go Normal file
View File

@@ -0,0 +1,116 @@
package publish
import (
"fmt"
"net/url"
"strings"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
)
func PlanLinks(req Request, outputs []Output) ([]Output, string, error) {
if req.Links == nil {
return outputs, "", nil
}
linked := make([]Output, 0, len(outputs))
for _, output := range outputs {
outputURL, err := OutputURL(req.Links.BaseURL, req.DestinationBundlePath, output.DestinationPath)
if err != nil {
return nil, "", err
}
output.URL = outputURL
linked = append(linked, output)
}
return linked, primaryURL(linked, req.Links.Primary), nil
}
func OutputURL(baseURL, destinationBundlePath, outputPath string) (string, error) {
parsed, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("link base URL: %w", err)
}
segments := pathSegments(parsed.Path)
segments = append(segments, pathSegments(destinationBundlePath)...)
outputSegments := pathSegments(outputPath)
trailingSlash := false
if len(outputSegments) > 0 && outputSegments[len(outputSegments)-1] == "index.html" {
outputSegments = outputSegments[:len(outputSegments)-1]
trailingSlash = true
}
segments = append(segments, outputSegments...)
parsed.Path = urlPath(segments, trailingSlash)
parsed.RawPath = ""
return parsed.String(), nil
}
func pathSegments(value string) []string {
trimmed := strings.Trim(value, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func urlPath(segments []string, trailingSlash bool) string {
if len(segments) == 0 {
return "/"
}
path := "/" + strings.Join(segments, "/")
if trailingSlash && !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
func primaryURL(outputs []Output, policy string) string {
switch policy {
case config.LinkPrimaryHTML:
return firstGeneratedHTMLURL(outputs)
case config.LinkPrimarySource:
return firstSourceURL(outputs)
default:
if url := firstIndexURL(outputs); url != "" {
return url
}
if url := firstGeneratedHTMLURL(outputs); url != "" {
return url
}
return firstSourceURL(outputs)
}
}
func firstIndexURL(outputs []Output) string {
for _, output := range outputs {
if isIndexOutput(output.DestinationPath) {
return output.URL
}
}
return ""
}
func firstGeneratedHTMLURL(outputs []Output) string {
for _, output := range outputs {
if output.Kind == state.OutputKindGenerated && isHTMLOutput(output.DestinationPath) {
return output.URL
}
}
return ""
}
func firstSourceURL(outputs []Output) string {
for _, output := range outputs {
if output.Kind == state.OutputKindSource {
return output.URL
}
}
return ""
}
func isIndexOutput(path string) bool {
return path == "index.html" || strings.HasSuffix(path, "/index.html")
}
func isHTMLOutput(path string) bool {
return strings.HasSuffix(path, ".html")
}

View File

@@ -0,0 +1,149 @@
package publish
import (
"testing"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
)
func TestOutputURLUsesURLPathSemantics(t *testing.T) {
tests := []struct {
name string
baseURL string
destinationBundlePath string
outputPath string
want string
}{
{
name: "nested non index",
baseURL: "https://reports.example.com/archive",
destinationBundlePath: "daily/brentwood",
outputPath: "report.html",
want: "https://reports.example.com/archive/daily/brentwood/report.html",
},
{
name: "nested index",
baseURL: "https://reports.example.com/archive",
destinationBundlePath: "daily/brentwood",
outputPath: "index.html",
want: "https://reports.example.com/archive/daily/brentwood/",
},
{
name: "fixed index",
baseURL: "https://reports.example.com/latest",
destinationBundlePath: "",
outputPath: "index.html",
want: "https://reports.example.com/latest/",
},
{
name: "escaped segments",
baseURL: "https://reports.example.com/archive root",
destinationBundlePath: "daily reports",
outputPath: "morning report.html",
want: "https://reports.example.com/archive%20root/daily%20reports/morning%20report.html",
},
{
name: "nested output index",
baseURL: "https://reports.example.com",
destinationBundlePath: "daily",
outputPath: "site/index.html",
want: "https://reports.example.com/daily/site/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := OutputURL(tt.baseURL, tt.destinationBundlePath, tt.outputPath)
if err != nil {
t.Fatalf("OutputURL() error = %v", err)
}
if got != tt.want {
t.Fatalf("OutputURL() = %q, want %q", got, tt.want)
}
})
}
}
func TestPlanLinksSelectsPrimaryURL(t *testing.T) {
outputs := []Output{
{
DestinationPath: "report.md",
Kind: state.OutputKindSource,
},
{
DestinationPath: "report.html",
Kind: state.OutputKindGenerated,
},
{
DestinationPath: "index.html",
Kind: state.OutputKindGenerated,
},
}
tests := []struct {
name string
primary string
want string
}{
{name: "auto prefers index", primary: config.LinkPrimaryAuto, want: "https://reports.example.com/daily/"},
{name: "html uses first generated html", primary: config.LinkPrimaryHTML, want: "https://reports.example.com/daily/report.html"},
{name: "source uses first source", primary: config.LinkPrimarySource, want: "https://reports.example.com/daily/report.md"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
linked, primaryURL, err := PlanLinks(Request{
DestinationBundlePath: "daily",
Links: &config.Links{
BaseURL: "https://reports.example.com",
Primary: tt.primary,
},
}, outputs)
if err != nil {
t.Fatalf("PlanLinks() error = %v", err)
}
if primaryURL != tt.want {
t.Fatalf("primary URL = %q, want %q", primaryURL, tt.want)
}
for index, output := range linked {
if output.URL == "" {
t.Fatalf("linked output %d has empty URL", index)
}
}
})
}
}
func TestPlanLinksReturnsNoPrimaryWhenPolicyHasNoMatch(t *testing.T) {
linked, primaryURL, err := PlanLinks(Request{
DestinationBundlePath: "daily",
Links: &config.Links{
BaseURL: "https://reports.example.com",
Primary: config.LinkPrimaryHTML,
},
}, []Output{{
DestinationPath: "report.md",
Kind: state.OutputKindSource,
}})
if err != nil {
t.Fatalf("PlanLinks() error = %v", err)
}
if primaryURL != "" {
t.Fatalf("primary URL = %q, want empty", primaryURL)
}
if linked[0].URL != "https://reports.example.com/daily/report.md" {
t.Fatalf("linked URL = %q", linked[0].URL)
}
}
func TestPlanLinksLeavesOutputsUnchangedWithoutConfig(t *testing.T) {
outputs := []Output{{DestinationPath: "report.md", Kind: state.OutputKindSource}}
linked, primaryURL, err := PlanLinks(Request{}, outputs)
if err != nil {
t.Fatalf("PlanLinks() error = %v", err)
}
if primaryURL != "" {
t.Fatalf("primary URL = %q, want empty", primaryURL)
}
if linked[0].URL != "" {
t.Fatalf("output URL = %q, want empty", linked[0].URL)
}
}

View File

@@ -105,6 +105,7 @@ func stateOutputs(outputs []Output) []state.OutputFile {
Kind: output.Kind,
SourcePath: output.SourcePath,
Transform: output.Transform,
URL: output.URL,
SHA256: output.SHA256,
Size: output.Size,
})

View File

@@ -33,6 +33,7 @@ type Request struct {
PathMapping string
Publish config.PublishPolicy
Transform config.Transform
Links *config.Links
Transformers TransformerResolver
Transfer config.TransferPolicy
DistributorVersion string
@@ -53,6 +54,7 @@ type Plan struct {
Action Action
Reason string
Force bool
PrimaryURL string
Outputs []Output
ExistingState *state.DistributorState
}
@@ -62,6 +64,7 @@ type Output struct {
DestinationPath string
Kind string
Transform string
URL string
Data []byte
SHA256 string
Size int64
@@ -75,6 +78,10 @@ func Build(ctx context.Context, req Request) (Plan, error) {
if err != nil {
return Plan{}, err
}
outputs, primaryURL, err := PlanLinks(req, outputs)
if err != nil {
return Plan{}, err
}
status, err := inspectDestination(ctx, req.DestinationBackend, req.DestinationBundlePath)
if err != nil {
return Plan{}, err
@@ -91,6 +98,7 @@ func Build(ctx context.Context, req Request) (Plan, error) {
Action: action,
Reason: reason,
Force: action == ActionForceReplace,
PrimaryURL: primaryURL,
Outputs: outputs,
ExistingState: status.State,
}

View File

@@ -19,6 +19,7 @@ type DistributorState struct {
DestinationID string
PublishedAt time.Time
Source SourceState
Links *LinkState
Outputs []OutputFile
}
@@ -26,11 +27,16 @@ type SourceState struct {
Manifest bundle.Manifest
}
type LinkState struct {
PrimaryURL string
}
type OutputFile struct {
Path string
Kind string
SourcePath string
Transform string
URL string
SHA256 string
Size int64
}
@@ -42,6 +48,7 @@ type rawDistributorState struct {
DestinationID *string `json:"destination_id"`
PublishedAt *string `json:"published_at"`
Source *rawSourceState `json:"source"`
Links *rawLinkState `json:"links"`
Outputs []rawOutputFile `json:"outputs"`
}
@@ -49,11 +56,16 @@ type rawSourceState struct {
Manifest json.RawMessage `json:"manifest"`
}
type rawLinkState struct {
PrimaryURL string `json:"primary_url"`
}
type rawOutputFile struct {
Path *string `json:"path"`
Kind *string `json:"kind"`
SourcePath *string `json:"source_path"`
Transform string `json:"transform"`
URL string `json:"url"`
SHA256 *string `json:"sha256"`
Size *int64 `json:"size"`
}
@@ -112,6 +124,9 @@ func parseRaw(raw rawDistributorState) (DistributorState, error) {
return DistributorState{}, fmt.Errorf("state source.manifest: %w", err)
}
state.Source.Manifest = manifest
if raw.Links != nil {
state.Links = &LinkState{PrimaryURL: raw.Links.PrimaryURL}
}
if raw.Outputs == nil {
return DistributorState{}, fmt.Errorf("state outputs is required")
}
@@ -161,6 +176,7 @@ func parseOutput(index int, raw rawOutputFile) (OutputFile, error) {
Kind: *raw.Kind,
SourcePath: *raw.SourcePath,
Transform: raw.Transform,
URL: raw.URL,
SHA256: *raw.SHA256,
Size: *raw.Size,
}, nil
@@ -181,6 +197,7 @@ func (s DistributorState) MarshalJSON() ([]byte, error) {
DestinationID string `json:"destination_id"`
PublishedAt string `json:"published_at"`
Source sourceJSON `json:"source"`
Links *LinkState `json:"links,omitempty"`
Outputs []OutputFile `json:"outputs"`
}
return json.Marshal(stateJSON{
@@ -190,16 +207,25 @@ func (s DistributorState) MarshalJSON() ([]byte, error) {
DestinationID: s.DestinationID,
PublishedAt: s.PublishedAtString(),
Source: sourceJSON{Manifest: s.Source.Manifest},
Links: s.Links,
Outputs: s.Outputs,
})
}
func (l LinkState) MarshalJSON() ([]byte, error) {
type linkJSON struct {
PrimaryURL string `json:"primary_url,omitempty"`
}
return json.Marshal(linkJSON{PrimaryURL: l.PrimaryURL})
}
func (o OutputFile) MarshalJSON() ([]byte, error) {
type outputJSON struct {
Path string `json:"path"`
Kind string `json:"kind"`
SourcePath string `json:"source_path"`
Transform string `json:"transform,omitempty"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
@@ -208,6 +234,7 @@ func (o OutputFile) MarshalJSON() ([]byte, error) {
Kind: o.Kind,
SourcePath: o.SourcePath,
Transform: o.Transform,
URL: o.URL,
SHA256: o.SHA256,
Size: o.Size,
})

View File

@@ -29,6 +29,22 @@ func TestParseValidState(t *testing.T) {
}
}
func TestParseValidStateWithLinks(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"outputs": [`, `"links": {"primary_url": "https://reports.example.com/archive/report.md"},`+"\n "+`"outputs": [`, 1)
body = strings.Replace(body, `"source_path": "report.md",`, `"source_path": "report.md",`+"\n "+`"url": "https://reports.example.com/archive/report.md",`, 1)
state, err := Parse([]byte(body))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if state.Links == nil || state.Links.PrimaryURL != "https://reports.example.com/archive/report.md" {
t.Fatalf("links = %#v, want primary URL", state.Links)
}
if state.Outputs[0].URL != "https://reports.example.com/archive/report.md" {
t.Fatalf("output URL = %q", state.Outputs[0].URL)
}
}
func TestParseNormalizesPublishedAtOffset(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1)
state, err := Parse([]byte(body))
@@ -145,6 +161,12 @@ func TestParseRejectsInvalidOutputMetadata(t *testing.T) {
"negative size": func(s *DistributorState) {
s.Outputs[0].Size = -1
},
"invalid output url": func(s *DistributorState) {
s.Outputs[0].URL = "file:///tmp/report.md"
},
"invalid primary url": func(s *DistributorState) {
s.Links = &LinkState{PrimaryURL: "file:///tmp/report.md"}
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
@@ -188,6 +210,36 @@ func TestMarshalNormalizesPublishedAtUTC(t *testing.T) {
}
}
func TestMarshalIncludesLinksWhenPresent(t *testing.T) {
source := validManifest(t)
state := DistributorState{
SchemaVersion: SchemaVersion,
PipelineID: "reports",
DestinationID: "archive",
PublishedAt: time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC),
Source: SourceState{Manifest: source},
Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"},
Outputs: []OutputFile{{
Path: "report.md",
Kind: OutputKindSource,
SourcePath: "report.md",
URL: "https://reports.example.com/archive/report.md",
SHA256: source.Files[0].SHA256,
Size: source.Files[0].Size,
}},
}
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), `"links":{"primary_url":"https://reports.example.com/archive/report.md"}`) {
t.Fatalf("json = %s, want links primary URL", data)
}
if !strings.Contains(string(data), `"url":"https://reports.example.com/archive/report.md"`) {
t.Fatalf("json = %s, want output URL", data)
}
}
func validStateJSON(t *testing.T) string {
t.Helper()
return validStateWithManifestJSON(t, manifestJSON(t))

View File

@@ -2,6 +2,7 @@ package state
import (
"fmt"
"net/url"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
@@ -28,6 +29,11 @@ func Validate(s DistributorState) error {
if err := validateEmbeddedManifest(s.Source.Manifest); err != nil {
return fmt.Errorf("state source.manifest: %w", err)
}
if s.Links != nil && s.Links.PrimaryURL != "" {
if err := validateStateURL(s.Links.PrimaryURL); err != nil {
return fmt.Errorf("state links.primary_url: %w", err)
}
}
if s.Outputs == nil {
return fmt.Errorf("state outputs is required")
}
@@ -63,6 +69,11 @@ func validateOutput(index int, output OutputFile) error {
if output.Kind == OutputKindGenerated && output.Transform == "" {
return fmt.Errorf("state outputs[%d].transform is required for generated output", index)
}
if output.URL != "" {
if err := validateStateURL(output.URL); err != nil {
return fmt.Errorf("state outputs[%d].url: %w", index, err)
}
}
if err := bundle.ValidateDigest(output.SHA256); err != nil {
return fmt.Errorf("state outputs[%d].sha256: %w", index, err)
}
@@ -71,3 +82,23 @@ func validateOutput(index int, output OutputFile) error {
}
return nil
}
func validateStateURL(value string) error {
parsed, err := url.Parse(value)
if err != nil {
return err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("must use http or https")
}
if parsed.Host == "" {
return fmt.Errorf("must include a host")
}
if parsed.RawQuery != "" {
return fmt.Errorf("must not include a query string")
}
if parsed.Fragment != "" {
return fmt.Errorf("must not include a fragment")
}
return nil
}