Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 0 additions & 167 deletions cmd/AUTO_PREP_DEALS_INTEGRATION_TEST.md

This file was deleted.

16 changes: 16 additions & 0 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/data-preservation-programs/singularity/cmd/dataprep"
"github.com/data-preservation-programs/singularity/cmd/deal"
"github.com/data-preservation-programs/singularity/cmd/deal/schedule"
"github.com/data-preservation-programs/singularity/cmd/deal/state"
"github.com/data-preservation-programs/singularity/cmd/dealtemplate"
"github.com/data-preservation-programs/singularity/cmd/errorlog"
"github.com/data-preservation-programs/singularity/cmd/ez"
Expand Down Expand Up @@ -147,6 +148,21 @@ Upgrading:
schedule.RemoveCmd,
},
},
{
Name: "state",
Usage: "Deal state management and monitoring",
Description: `Comprehensive deal state management tools including:
- View and filter deal state changes
- Export state history to CSV/JSON
- Manual recovery and repair operations
- State change statistics and analytics`,
Subcommands: []*cli.Command{
state.ListCmd,
state.GetCmd,
state.StatsCmd,
state.RepairCmd,
},
},
deal.SendManualCmd,
deal.ListCmd,
},
Expand Down
145 changes: 145 additions & 0 deletions cmd/deal/state/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package state

import (
"encoding/csv"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/cockroachdb/errors"
"github.com/data-preservation-programs/singularity/model"
)

// exportStateChanges exports state changes to the specified format and file path
func exportStateChanges(stateChanges []model.DealStateChange, format, outputPath string) error {
// Validate and clean the output path to prevent directory traversal
cleanPath := filepath.Clean(outputPath)
if filepath.IsAbs(cleanPath) {
return errors.New("absolute paths are not allowed for security reasons")
}
// Check for directory traversal attempts
if strings.Contains(cleanPath, "..") {
return errors.New("directory traversal using '..' is not allowed")
}
if len(cleanPath) > 255 {
return errors.New("output path is too long")
}

switch format {
case "csv":
return exportToCSV(stateChanges, cleanPath)
case "json":
return exportToJSON(stateChanges, cleanPath)
default:
return errors.Errorf("unsupported export format: %s", format)
}
}

// exportToCSV exports state changes to a CSV file
func exportToCSV(stateChanges []model.DealStateChange, outputPath string) (err error) {
file, err := os.Create(outputPath) // #nosec G304 -- path is validated in exportStateChanges
if err != nil {
return errors.Wrap(err, "failed to create CSV file")
}
defer func() {
if closeErr := file.Close(); closeErr != nil && err == nil {
err = errors.Wrap(closeErr, "failed to close CSV file")
}
}()

writer := csv.NewWriter(file)
defer writer.Flush()

// Write CSV header
// Note: Both ID and DealID are database IDs, not CIDs
// ID = state change record database ID, DealID = internal singularity deal database ID
header := []string{
"StateChangeID", // Database ID of the state change record
"DealID", // Internal singularity deal database ID
"PreviousState",
"NewState",
"Timestamp",
"EpochHeight",
"SectorID",
"ProviderID",
"ClientAddress",
"Metadata",
}
if err := writer.Write(header); err != nil {
return errors.Wrap(err, "failed to write CSV header")
}

// Write state change records
for _, change := range stateChanges {
record := []string{
strconv.FormatUint(change.ID, 10),
strconv.FormatUint(uint64(change.DealID), 10),
string(change.PreviousState),
string(change.NewState),
change.Timestamp.Format("2006-01-02 15:04:05"),
formatOptionalInt32(change.EpochHeight),
formatOptionalString(change.SectorID),
change.ProviderID,
change.ClientAddress,
change.Metadata,
}
if err := writer.Write(record); err != nil {
return errors.Wrap(err, "failed to write CSV record")
}
}

return nil
}

// exportToJSON exports state changes to a JSON file
func exportToJSON(stateChanges []model.DealStateChange, outputPath string) (err error) {
file, err := os.Create(outputPath) // #nosec G304 -- path is validated in exportStateChanges
if err != nil {
return errors.Wrap(err, "failed to create JSON file")
}
defer func() {
if closeErr := file.Close(); closeErr != nil && err == nil {
err = errors.Wrap(closeErr, "failed to close JSON file")
}
}()

// Create export structure with metadata
exportData := struct {
Metadata struct {
ExportTime string `json:"exportTime"`
TotalCount int `json:"totalCount"`
} `json:"metadata"`
StateChanges []model.DealStateChange `json:"stateChanges"`
}{
StateChanges: stateChanges,
}

exportData.Metadata.ExportTime = time.Now().Format(time.RFC3339)
exportData.Metadata.TotalCount = len(stateChanges)

encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(exportData); err != nil {
return errors.Wrap(err, "failed to encode JSON")
}

return nil
}

// Helper functions for formatting optional fields
func formatOptionalInt32(value *int32) string {
if value == nil {
return ""
}
return strconv.FormatInt(int64(*value), 10)
}

func formatOptionalString(value *string) string {
if value == nil {
return ""
}
return *value
}
Loading
Loading