diff --git a/checkpointctl.go b/checkpointctl.go index 3851ba20..ea8b67ee 100644 --- a/checkpointctl.go +++ b/checkpointctl.go @@ -33,6 +33,8 @@ func main() { rootCommand.AddCommand(cmd.BuildCmd()) + rootCommand.AddCommand(cmd.Diff()) + rootCommand.Version = version if err := rootCommand.Execute(); err != nil { diff --git a/cmd/diff.go b/cmd/diff.go new file mode 100644 index 00000000..20a8dc21 --- /dev/null +++ b/cmd/diff.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/checkpoint-restore/checkpointctl/internal" + metadata "github.com/checkpoint-restore/checkpointctl/lib" + "github.com/spf13/cobra" +) + +// creates the command +func Diff() *cobra.Command { + cmd := &cobra.Command{ + Use: "diff ", + Short: "Show changes between two container checkpoints", + Args: cobra.ExactArgs(2), + RunE: diff, + } + + flags := cmd.Flags() + flags.StringVar( + format, + "format", + "tree", + "Specify output format: tree or json", + ) + flags.BoolVar( + psTreeCmd, + "ps-tree-cmd", + false, + "Include full command lines in process tree diff", + ) + flags.BoolVar( + psTreeEnv, + "ps-tree-env", + false, + "Include environment variables in process tree diff", + ) + flags.BoolVar( + files, + "files", + false, + "Include file descriptors in the diff", + ) + flags.BoolVar( + sockets, + "sockets", + false, + "Include sockets in the diff", + ) + + return cmd +} + +// diff executes the checkpoint diff logic +func diff(cmd *cobra.Command, args []string) error { + checkA := args[0] + checkB := args[1] + + requiredFiles := []string{ + metadata.SpecDumpFile, + metadata.ConfigDumpFile, + } + + if *files || *sockets || *psTreeCmd || *psTreeEnv { + // Include all files necessary for deep diffs + for _, f := range []string{"files.img", "fs-", "ids-", "fdinfo-", "pagemap-", "pages-", "mm-", "pstree.img", "core-"} { + requiredFiles = append(requiredFiles, filepath.Join(metadata.CheckpointDirectory, f)) + } + } + + // Load tasks from both checkpoints + tasksAVal, err := internal.CreateTasks([]string{checkA}, requiredFiles) + if err != nil { + return fmt.Errorf("failed to load checkpointA: %w", err) + } + defer internal.CleanupTasks(tasksAVal) + + tasksBVal, err := internal.CreateTasks([]string{checkB}, requiredFiles) + if err != nil { + return fmt.Errorf("failed to load checkpointB: %w", err) + } + defer internal.CleanupTasks(tasksBVal) + + // Convert []Task → []*Task for DiffTasks + tasksA := make([]*internal.Task, len(tasksAVal)) + for i := range tasksAVal { + tasksA[i] = &tasksAVal[i] + } + + tasksB := make([]*internal.Task, len(tasksBVal)) + for i := range tasksBVal { + tasksB[i] = &tasksBVal[i] + } + + // Compute diff + diffTasks, err := internal.DiffTasks(tasksA, tasksB, *psTreeCmd, *psTreeEnv, *files, *sockets) + if err != nil { + return fmt.Errorf("failed to compute diff: %w", err) + } + + // Render output + switch *format { + case "tree": + return internal.RenderDiffTreeView(diffTasks) + case "json": + return internal.RenderDiffJSONView(diffTasks) + default: + return fmt.Errorf("invalid output format: %s", *format) + } +} diff --git a/internal/diff_types.go b/internal/diff_types.go new file mode 100644 index 00000000..a6b3a399 --- /dev/null +++ b/internal/diff_types.go @@ -0,0 +1,165 @@ +package internal + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// DiffStatus describes how a task changed between two checkpoints +type DiffStatus string + +const ( + Added DiffStatus = "added" + Removed DiffStatus = "removed" + Modified DiffStatus = "modified" + Unchanged DiffStatus = "unchanged" +) + +// DiffTask represents the forensic diff of a single task/process +type DiffTask struct { + // Stable identifier for matching tasks across checkpoints + ID string `json:"id"` + + // High-level classification of the change + Status DiffStatus `json:"status"` + + // Task state in checkpoint A (nil if Added) + Before *Task `json:"before,omitempty"` + + // Task state in checkpoint B (nil if Removed) + After *Task `json:"after,omitempty"` + + // Fine-grained differences detected inside the task + Changes []DiffChange `json:"changes,omitempty"` +} + +// DiffChange represents a single detected difference. +type DiffChange struct { + // Logical category of the change (pstree, files, sockets, env, cmdline, etc.) + Category string `json:"category"` + + // Specific field or subcomponent that changed + Field string `json:"field"` + + // Value in checkpoint A + Before any `json:"before,omitempty"` + + // Value in checkpoint B + After any `json:"after,omitempty"` +} + +// DiffTasks compares two sets of tasks and returns their differences. +func DiffTasks( + tasksA []*Task, + tasksB []*Task, + psTreeCmd bool, + psTreeEnv bool, + files bool, + sockets bool, +) ([]DiffTask, error) { + if tasksA == nil || tasksB == nil { + return nil, fmt.Errorf("nil task list provided") + } + + // Index tasks by CheckpointFilePath for matching + indexA := make(map[string]*Task) + indexB := make(map[string]*Task) + + for _, t := range tasksA { + indexA[t.CheckpointFilePath] = t + } + for _, t := range tasksB { + indexB[t.CheckpointFilePath] = t + } + + var diffs []DiffTask + + // Tasks present in A + for id, taskA := range indexA { + taskB, exists := indexB[id] + + if !exists { + // Removed task + diffs = append(diffs, DiffTask{ + ID: id, + Status: Removed, + Before: taskA, + }) + continue + } + + // Exists in both → compare + if reflect.DeepEqual(taskA, taskB) { + diffs = append(diffs, DiffTask{ + ID: id, + Status: Unchanged, + Before: taskA, + After: taskB, + }) + continue + } + + // Modified task + diffs = append(diffs, DiffTask{ + ID: id, + Status: Modified, + Before: taskA, + After: taskB, + Changes: []DiffChange{ + { + Category: "task", + Field: "struct", + Before: taskA, + After: taskB, + }, + }, + }) + } + + // Tasks only in B → Added + for id, taskB := range indexB { + if _, exists := indexA[id]; exists { + continue + } + + diffs = append(diffs, DiffTask{ + ID: id, + Status: Added, + After: taskB, + }) + } + + return diffs, nil +} + +// RenderDiffTreeView prints a human-readable tree of diff tasks +func RenderDiffTreeView(diffTasks []DiffTask) error { + for _, dt := range diffTasks { + fmt.Printf("\nTask ID: %s | Status: %s\n", dt.ID, dt.Status) + + if dt.Before != nil { + fmt.Printf(" Before checkpoint: %s\n", dt.Before.CheckpointFilePath) + } + if dt.After != nil { + fmt.Printf(" After checkpoint: %s\n", dt.After.CheckpointFilePath) + } + + for _, ch := range dt.Changes { + fmt.Printf(" Change: [%s] %s | Before: %v | After: %v\n", + ch.Category, ch.Field, ch.Before, ch.After) + } + } + return nil +} + +// RenderDiffJSONView prints diff tasks in JSON format +func RenderDiffJSONView(diffTasks []DiffTask) error { + jsonData, err := json.MarshalIndent(diffTasks, "", " ") + if err != nil { + return err + } + + fmt.Println(string(jsonData)) + return nil +}