245 lines
7.1 KiB
Go
245 lines
7.1 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
)
|
|
|
|
// RestoreReport is the durable restore diagnostic model.
|
|
type RestoreReport struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
SessionID string `json:"session_id"`
|
|
Campaign string `json:"campaign"`
|
|
RunID string `json:"run_id"`
|
|
DryRun bool `json:"dry_run"`
|
|
Force bool `json:"force"`
|
|
IncludeAudio bool `json:"include_audio"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
Plan RestorePlanSummary `json:"plan"`
|
|
Execution RestoreExecutionStats `json:"execution"`
|
|
Actions []RestoreReportAction `json:"actions"`
|
|
reportPathRel string
|
|
}
|
|
|
|
type RestorePlanSummary struct {
|
|
Actions int `json:"actions"`
|
|
Download int `json:"download"`
|
|
SkipSame int `json:"skip_same"`
|
|
Conflicts int `json:"conflicts"`
|
|
}
|
|
|
|
type RestoreExecutionStats struct {
|
|
Downloaded int `json:"downloaded"`
|
|
Failed int `json:"failed"`
|
|
}
|
|
|
|
type RestoreReportAction struct {
|
|
Kind string `json:"kind"`
|
|
LocalRelativePath string `json:"local_relative_path"`
|
|
RemoteKey string `json:"remote_key"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func newRestoreReport(current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) (*RestoreReport, error) {
|
|
if current == nil {
|
|
return nil, fmt.Errorf("remote current state is required")
|
|
}
|
|
if plan == nil {
|
|
return nil, fmt.Errorf("restore plan is required")
|
|
}
|
|
r := &RestoreReport{
|
|
GeneratedAt: nowUTC().Format("2006-01-02T15:04:05.999999999Z07:00"),
|
|
SessionID: current.SessionID,
|
|
Campaign: current.Campaign,
|
|
RunID: current.RunID,
|
|
DryRun: opts.DryRun,
|
|
Force: opts.Force,
|
|
IncludeAudio: opts.IncludeAudio,
|
|
Status: "planned",
|
|
Plan: RestorePlanSummary{
|
|
Actions: len(plan.Actions),
|
|
Download: plan.DownloadCount,
|
|
SkipSame: plan.SkipSameCount,
|
|
Conflicts: plan.ConflictCount,
|
|
},
|
|
Actions: make([]RestoreReportAction, 0, len(plan.Actions)),
|
|
reportPathRel: filepath.ToSlash(filepath.Join(config.PathReportsDirSegment, "restore-latest.json")),
|
|
}
|
|
for _, action := range plan.Actions {
|
|
r.Actions = append(r.Actions, RestoreReportAction{
|
|
Kind: string(action.Kind),
|
|
LocalRelativePath: action.LocalRelativePath,
|
|
RemoteKey: action.RemoteKey,
|
|
Reason: action.Reason,
|
|
Status: initialRestoreActionStatus(action.Kind),
|
|
})
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func initialRestoreActionStatus(kind RestoreActionKind) string {
|
|
switch kind {
|
|
case RestoreActionDownload:
|
|
return "planned_download"
|
|
case RestoreActionSkipSame:
|
|
return "skipped_same"
|
|
case RestoreActionConflict:
|
|
return "conflict"
|
|
default:
|
|
return "planned"
|
|
}
|
|
}
|
|
|
|
func (r *RestoreReport) markDownloaded(action RestoreAction) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
if idx := r.findAction(action); idx >= 0 {
|
|
r.Actions[idx].Status = "downloaded"
|
|
r.Actions[idx].Error = ""
|
|
}
|
|
r.Execution.Downloaded++
|
|
}
|
|
|
|
func (r *RestoreReport) markFailed(action RestoreAction, err error) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
if idx := r.findAction(action); idx >= 0 {
|
|
r.Actions[idx].Status = "failed"
|
|
if err != nil {
|
|
r.Actions[idx].Error = err.Error()
|
|
}
|
|
}
|
|
r.Execution.Failed++
|
|
}
|
|
|
|
func (r *RestoreReport) setFailed(err error) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.Status = "failed"
|
|
if err != nil {
|
|
r.Error = err.Error()
|
|
}
|
|
}
|
|
|
|
func (r *RestoreReport) setSucceeded() {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.Status = "succeeded"
|
|
r.Error = ""
|
|
}
|
|
|
|
func (r *RestoreReport) findAction(action RestoreAction) int {
|
|
if r == nil {
|
|
return -1
|
|
}
|
|
for i := range r.Actions {
|
|
if r.Actions[i].LocalRelativePath == action.LocalRelativePath && r.Actions[i].RemoteKey == action.RemoteKey {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func writeRestoreDryRunSummary(out io.Writer, report *RestoreReport) error {
|
|
if out == nil {
|
|
return fmt.Errorf("output writer is required")
|
|
}
|
|
if report == nil {
|
|
return fmt.Errorf("restore report is required")
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Restore plan for %s/%s\n", report.Campaign, report.SessionID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Would download: %d\n", report.Plan.Download); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Would skip unchanged: %d\n", report.Plan.SkipSame); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil {
|
|
return err
|
|
}
|
|
for _, action := range report.Actions {
|
|
line := ""
|
|
switch action.Status {
|
|
case "planned_download":
|
|
line = "Would download: " + action.LocalRelativePath
|
|
case "skipped_same":
|
|
line = "Would skip unchanged: " + action.LocalRelativePath
|
|
case "conflict":
|
|
line = "Conflict: " + action.LocalRelativePath
|
|
default:
|
|
line = strings.TrimSpace(action.Kind) + ": " + action.LocalRelativePath
|
|
}
|
|
if _, err := fmt.Fprintln(out, line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeRestoreSuccessSummary(out io.Writer, report *RestoreReport) error {
|
|
if out == nil {
|
|
return fmt.Errorf("output writer is required")
|
|
}
|
|
if report == nil {
|
|
return fmt.Errorf("restore report is required")
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Restored session state for %s/%s\n", report.Campaign, report.SessionID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Downloaded: %d\n", report.Execution.Downloaded); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Skipped unchanged: %d\n", report.Plan.SkipSame); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func persistRestoreReport(store artifacts.Store, cfg *config.Config, report *RestoreReport) (string, error) {
|
|
if store == nil {
|
|
return "", fmt.Errorf("artifact store is required")
|
|
}
|
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
|
return "", fmt.Errorf("resolved config with pipeline/session is required")
|
|
}
|
|
if report == nil {
|
|
return "", fmt.Errorf("restore report is required")
|
|
}
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
reportPath := filepath.Join(sessionRoot, filepath.FromSlash(report.reportPathRel))
|
|
payload, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal restore report: %w", err)
|
|
}
|
|
payload = append(payload, '\n')
|
|
if err := store.WriteFileAtomic(reportPath, payload, fileops.WorkspaceFileMode); err != nil {
|
|
return "", fmt.Errorf("write restore report %q: %w", reportPath, err)
|
|
}
|
|
return reportPath, nil
|
|
}
|