Files
releasekit/recovery.go

151 lines
7.2 KiB
Go

package releasekit
import (
"context"
"errors"
"fmt"
"go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/publish"
)
// PublicationEvidence identifies one attempted publication independently of
// current repository state. Reconciliation uses RepositoryDir as Git's working
// directory, Tag and Endpoint as the observed ref and destination, and the
// captured object IDs to classify the observation. Endpoint may contain
// credentials; treat this value and saved records as sensitive.
type PublicationEvidence struct {
RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory.
ModuleDir string // physical module directory captured for this attempt.
ModulePath string // declared module path captured for this attempt.
Version string // semantic version selected for this attempt.
Tag string // complete Git tag ref selected for this attempt.
CandidateCommit string // candidate commit captured before publication.
TagObject string // annotated tag object ID captured before pushing, when available.
Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials.
Remote string // remote name supplying supported transport configuration during later observation.
RecordedAt string // UTC RFC3339Nano time when this attempt's evidence was initialized, when available.
}
// PublicationReconciliationResult combines saved evidence with one later
// observation. It does not update local or remote state or modify the record.
type PublicationReconciliationResult struct {
Evidence PublicationEvidence // original selected evidence.
State PublicationState // published, not_published, conflict, or unknown.
ObservedTagObject string // observed remote tag-object ID, when available.
ObservedCommit string // observed peeled commit ID, when available.
}
// PublicationRecoveryError describes an unresolved recovery operation or a
// record/storage failure. Its Error text is credential-safe; Unwrap retains
// the underlying cause for errors.Is and errors.As.
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
// transport_configuration, remote_observation, inconsistent_observation,
// record_format, record_read, record_write, and storage_enumeration.
type PublicationRecoveryError struct {
Kind string
RecordPath string // selected or affected record path, when applicable.
MissingFields []string // private-record field names required for the attempted classification.
cause error
}
// Error returns the credential-safe recovery diagnostic.
func (e *PublicationRecoveryError) Error() string {
if e == nil {
return ""
}
message := fmt.Sprintf("publication recovery %s", e.Kind)
if e.RecordPath != "" {
message += fmt.Sprintf(" for %q", git.DisplayDiagnostic(e.RecordPath))
}
if e.cause != nil {
message += ": " + git.DisplayError(e.cause).Error()
}
return git.DisplayDiagnostic(message)
}
// Unwrap returns the underlying recovery or storage cause.
func (e *PublicationRecoveryError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// ReconcilePublication observes the recorded endpoint without running candidate
// validation or changing local or remote state.
func ReconcilePublication(ctx context.Context, evidence PublicationEvidence) (PublicationReconciliationResult, error) {
observation, err := publish.Reconcile(ctx, internalEvidence(evidence))
return publicObservation(observation), publicRecoveryError(err, "")
}
// LoadPublicationEvidence loads one explicit private attempt record. Relative
// paths are interpreted from the caller's current directory; no Git lookup is
// performed.
func LoadPublicationEvidence(path string) (PublicationEvidence, error) {
evidence, err := publish.LoadEvidence(path)
return publicEvidence(evidence), publicRecoveryError(err, path)
}
// PublicationAttemptsRequest selects local publication-attempt storage.
type PublicationAttemptsRequest struct {
RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory.
RecordDir string // optional storage directory; relative paths use the repository root, and absolute paths can be listed outside a checkout.
}
// PublicationAttempt pairs one readable record path with the evidence it stores.
type PublicationAttempt struct {
RecordPath string // absolute path of the selected record.
Evidence PublicationEvidence // saved identity, including any partial fields.
}
// PublicationAttemptsResult contains readable attempts and per-record
// diagnostics from one local directory enumeration.
type PublicationAttemptsResult struct {
RecordDir string // resolved absolute directory selected for enumeration, when available.
Attempts []PublicationAttempt // readable records in deterministic path order.
Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available.
}
// ListPublicationAttempts discovers saved local records without querying a
// remote or inferring which attempt should be reconciled.
func ListPublicationAttempts(ctx context.Context, request PublicationAttemptsRequest) (PublicationAttemptsResult, error) {
listed, err := publish.ListAttempts(ctx, request.RepositoryDir, request.RecordDir)
return publicAttemptsResult(listed, err)
}
func publicAttemptsResult(listed publish.AttemptsResult, err error) (PublicationAttemptsResult, error) {
result := PublicationAttemptsResult{RecordDir: listed.RecordDir, Attempts: make([]PublicationAttempt, 0, len(listed.Attempts)), Diagnostics: make([]*PublicationRecoveryError, 0, len(listed.Diagnostics))}
for _, attempt := range listed.Attempts {
result.Attempts = append(result.Attempts, PublicationAttempt{RecordPath: attempt.RecordPath, Evidence: publicEvidence(attempt.Evidence)})
}
for _, diagnostic := range listed.Diagnostics {
converted := publicRecoveryError(diagnostic, diagnostic.RecordPath)
if recovery, ok := converted.(*PublicationRecoveryError); ok {
result.Diagnostics = append(result.Diagnostics, recovery)
}
}
return result, publicRecoveryError(err, "")
}
func internalEvidence(e PublicationEvidence) publish.Evidence { return publish.Evidence(e) }
func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) }
func publicObservation(o publish.Observation) PublicationReconciliationResult {
return PublicationReconciliationResult{Evidence: publicEvidence(o.Evidence), State: PublicationState(o.State), ObservedTagObject: o.ObservedTagObject, ObservedCommit: o.ObservedCommit}
}
func publicRecoveryError(err error, path string) error {
if err == nil {
return nil
}
var recovery *publish.RecoveryError
if errors.As(err, &recovery) {
recordPath := path
if recordPath == "" {
recordPath = recovery.RecordPath
}
return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: recordPath, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery}
}
return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err}
}