package state import ( "bytes" "encoding/json" "fmt" "io" "time" "gitea.maximumdirect.net/eric/distributor/internal/bundle" ) const SchemaVersion = 1 type DistributorState struct { SchemaVersion int DistributorVersion string PipelineID string DestinationID string PublishedAt time.Time Source SourceState Links *LinkState Outputs []OutputFile } 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 } type rawDistributorState struct { SchemaVersion *int `json:"schema_version"` DistributorVersion string `json:"distributor_version"` PipelineID *string `json:"pipeline_id"` DestinationID *string `json:"destination_id"` PublishedAt *string `json:"published_at"` Source *rawSourceState `json:"source"` Links *rawLinkState `json:"links"` Outputs []rawOutputFile `json:"outputs"` } 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"` } func Parse(data []byte) (DistributorState, error) { decoder := json.NewDecoder(bytes.NewReader(data)) var raw rawDistributorState if err := decoder.Decode(&raw); err != nil { return DistributorState{}, fmt.Errorf("parse distributor state: %w", err) } var extra any if err := decoder.Decode(&extra); err != io.EOF { return DistributorState{}, fmt.Errorf("parse distributor state: trailing data") } state, err := parseRaw(raw) if err != nil { return DistributorState{}, err } if err := Validate(state); err != nil { return DistributorState{}, err } return state, nil } func parseRaw(raw rawDistributorState) (DistributorState, error) { var state DistributorState if raw.SchemaVersion == nil { return DistributorState{}, fmt.Errorf("state schema_version is required") } state.SchemaVersion = *raw.SchemaVersion if state.SchemaVersion != SchemaVersion { return DistributorState{}, fmt.Errorf("state schema_version must be %d", SchemaVersion) } state.DistributorVersion = raw.DistributorVersion if raw.PipelineID == nil || *raw.PipelineID == "" { return DistributorState{}, fmt.Errorf("state pipeline_id is required") } state.PipelineID = *raw.PipelineID if raw.DestinationID == nil || *raw.DestinationID == "" { return DistributorState{}, fmt.Errorf("state destination_id is required") } state.DestinationID = *raw.DestinationID if raw.PublishedAt == nil || *raw.PublishedAt == "" { return DistributorState{}, fmt.Errorf("state published_at is required") } publishedAt, err := time.Parse(time.RFC3339, *raw.PublishedAt) if err != nil { return DistributorState{}, fmt.Errorf("state published_at must be RFC3339: %w", err) } state.PublishedAt = publishedAt.UTC() if raw.Source == nil || len(raw.Source.Manifest) == 0 { return DistributorState{}, fmt.Errorf("state source.manifest is required") } manifest, err := bundle.ParseManifest(raw.Source.Manifest) if err != nil { 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") } outputs, err := parseOutputs(raw.Outputs) if err != nil { return DistributorState{}, err } state.Outputs = outputs return state, nil } func parseOutputs(rawOutputs []rawOutputFile) ([]OutputFile, error) { outputs := make([]OutputFile, 0, len(rawOutputs)) seen := make(map[string]struct{}, len(rawOutputs)) for index, raw := range rawOutputs { output, err := parseOutput(index, raw) if err != nil { return nil, err } if _, exists := seen[output.Path]; exists { return nil, fmt.Errorf("state outputs[%d].path duplicates %q", index, output.Path) } seen[output.Path] = struct{}{} outputs = append(outputs, output) } return outputs, nil } func parseOutput(index int, raw rawOutputFile) (OutputFile, error) { if raw.Path == nil || *raw.Path == "" { return OutputFile{}, fmt.Errorf("state outputs[%d].path is required", index) } if raw.Kind == nil || *raw.Kind == "" { return OutputFile{}, fmt.Errorf("state outputs[%d].kind is required", index) } if raw.SourcePath == nil || *raw.SourcePath == "" { return OutputFile{}, fmt.Errorf("state outputs[%d].source_path is required", index) } if raw.SHA256 == nil || *raw.SHA256 == "" { return OutputFile{}, fmt.Errorf("state outputs[%d].sha256 is required", index) } if raw.Size == nil { return OutputFile{}, fmt.Errorf("state outputs[%d].size is required", index) } return OutputFile{ Path: *raw.Path, Kind: *raw.Kind, SourcePath: *raw.SourcePath, Transform: raw.Transform, URL: raw.URL, SHA256: *raw.SHA256, Size: *raw.Size, }, nil } func (s DistributorState) PublishedAtString() string { return s.PublishedAt.UTC().Format(time.RFC3339) } func (s DistributorState) MarshalJSON() ([]byte, error) { type sourceJSON struct { Manifest bundle.Manifest `json:"manifest"` } type stateJSON struct { SchemaVersion int `json:"schema_version"` DistributorVersion string `json:"distributor_version,omitempty"` PipelineID string `json:"pipeline_id"` 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{ SchemaVersion: s.SchemaVersion, DistributorVersion: s.DistributorVersion, PipelineID: s.PipelineID, 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"` } return json.Marshal(outputJSON{ Path: o.Path, Kind: o.Kind, SourcePath: o.SourcePath, Transform: o.Transform, URL: o.URL, SHA256: o.SHA256, Size: o.Size, }) }