package app import ( "context" "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m *manifest.Manifest, executed []string) error { if env == nil || env.Config == nil || env.Config.Pipeline == nil || m == nil { return nil } spoolRequested := env.Config.Pipeline.Spool.DeleteAudioAfterArchive workRequested := env.Config.Pipeline.Workspace.CleanupAfterArchive if !spoolRequested && !workRequested { return nil } sr := archiveStageRecordForCleanup(m, executed) if sr == nil { return nil } if sr.Metadata == nil { sr.Metadata = map[string]any{} } sr.Metadata["spool_cleanup_requested"] = spoolRequested sr.Metadata["workdir_cleanup_requested"] = workRequested eligible, reason := archiveCleanupEligible(env.Config, sr) if !eligible { sr.Metadata["cleanup_skipped"] = true sr.Metadata["cleanup_skipped_reason"] = reason if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { return fmt.Errorf("save manifest cleanup skip metadata %q: %w", manifestPath, err) } return nil } spoolDir := strings.TrimSpace(m.LocalSpoolDir) if spoolDir == "" { spoolDir = artifacts.SessionSpoolAudioDir( env.Config.Pipeline.Spool.Root, strings.TrimSpace(env.Config.Session.Campaign), strings.TrimSpace(env.Config.Session.SessionID), strings.TrimSpace(m.RunID), ) } workDir := strings.TrimSpace(m.LocalWorkDir) if workDir == "" { workDir = artifacts.SessionRunRootForCampaign( env.Config.Pipeline.Workspace.Root, strings.TrimSpace(env.Config.Session.Campaign), strings.TrimSpace(env.Config.Session.SessionID), strings.TrimSpace(m.RunID), ) } if spoolRequested { if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Spool.Root), spoolDir, "pipeline.spool.delete_audio_after_archive"); err != nil { sr.Metadata["cleanup_failed"] = true sr.Metadata["cleanup_failed_policy"] = "pipeline.spool.delete_audio_after_archive" sr.Metadata["cleanup_failed_path"] = spoolDir _ = env.ManifestStore.Save(ctx, manifestPath, m) return err } sr.Metadata["spool_cleanup_deleted"] = filepath.Clean(spoolDir) } if !workRequested { sr.Metadata["cleanup_completed"] = true sr.Metadata["cleanup_skipped"] = false if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { return fmt.Errorf("save manifest cleanup metadata %q: %w", manifestPath, err) } return nil } if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Workspace.Root), workDir, "pipeline.workspace.cleanup_after_archive"); err != nil { sr.Metadata["cleanup_failed"] = true sr.Metadata["cleanup_failed_policy"] = "pipeline.workspace.cleanup_after_archive" sr.Metadata["cleanup_failed_path"] = workDir _ = env.ManifestStore.Save(ctx, manifestPath, m) return err } sr.Metadata["workdir_cleanup_deleted"] = filepath.Clean(workDir) sr.Metadata["cleanup_completed"] = true sr.Metadata["cleanup_skipped"] = false return nil } func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *manifest.StageRecord { if m == nil { return nil } archiveRan := false for _, name := range executed { if name == "archive" { archiveRan = true break } } if !archiveRan { return nil } sr := m.Stages["archive"] if sr == nil || sr.Status != manifest.StatusSucceeded { return nil } return sr } func archiveCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Archive == nil { return false, "archive configuration is missing" } enabled := true if cfg.Pipeline.Archive.Enabled != nil { enabled = *cfg.Pipeline.Archive.Enabled } if !enabled { return false, "archive.enabled is false" } uploadRun := true if cfg.Pipeline.Archive.UploadRun != nil { uploadRun = *cfg.Pipeline.Archive.UploadRun } if !uploadRun { return false, "archive.upload_run is false" } if sr == nil || sr.Metadata == nil { return false, "archive metadata is missing" } if skipped, _ := sr.Metadata["skipped"].(bool); skipped { return false, "archive stage was skipped" } if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded { return false, "archive did not upload run record" } if pointer, _ := sr.Metadata["current_pointer_written"].(bool); !pointer { return false, "archive did not write current pointer" } if strings.TrimSpace(asString(sr.Metadata["current_run_id_key"])) == "" { return false, "archive current run pointer key is missing" } return true, "" } func removeRunScopedDir(root, target, policy string) error { cleanRoot := strings.TrimSpace(root) cleanTarget := strings.TrimSpace(target) if cleanRoot == "" { return fmt.Errorf("cleanup policy %s: root path is required", policy) } if cleanTarget == "" { return fmt.Errorf("cleanup policy %s: target path is required", policy) } rootAbs, err := filepath.Abs(cleanRoot) if err != nil { return fmt.Errorf("cleanup policy %s: resolve root %q: %w", policy, cleanRoot, err) } targetAbs, err := filepath.Abs(cleanTarget) if err != nil { return fmt.Errorf("cleanup policy %s: resolve target %q: %w", policy, cleanTarget, err) } rel, err := filepath.Rel(rootAbs, targetAbs) if err != nil { return fmt.Errorf("cleanup policy %s: relative path from %q to %q: %w", policy, rootAbs, targetAbs, err) } if rel == "." { return fmt.Errorf("cleanup policy %s: refusing to delete root directory %q", policy, rootAbs) } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return fmt.Errorf("cleanup policy %s: refusing to delete path outside root: root=%q target=%q", policy, rootAbs, targetAbs) } info, err := os.Lstat(targetAbs) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("cleanup policy %s: stat target %q: %w", policy, targetAbs, err) } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("cleanup policy %s: refusing to delete symlink path %q", policy, targetAbs) } if !info.IsDir() { return fmt.Errorf("cleanup policy %s: target %q is not a directory", policy, targetAbs) } if err := os.RemoveAll(targetAbs); err != nil { return fmt.Errorf("cleanup policy %s: remove %q: %w", policy, targetAbs, err) } return nil } func asString(v any) string { s, _ := v.(string) return s }