Skip to content

Commit 7120e7a

Browse files
committed
Introduce a 'diff' command to compare container checkpoints, highlighting changes in tasks and runtime state. This helps security engineers investigate incidents and understand how containers evolve over time, making state changes more visible and easier to analyze.
Signed-off-by: Lorygold <lory.goldoni@gmail.com>
1 parent 50f94b4 commit 7120e7a

File tree

3 files changed

+279
-0
lines changed

3 files changed

+279
-0
lines changed

checkpointctl.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func main() {
3333

3434
rootCommand.AddCommand(cmd.BuildCmd())
3535

36+
rootCommand.AddCommand(cmd.Diff())
37+
3638
rootCommand.Version = version
3739

3840
if err := rootCommand.Execute(); err != nil {

cmd/diff.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/checkpoint-restore/checkpointctl/internal"
8+
metadata "github.com/checkpoint-restore/checkpointctl/lib"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// creates the command
13+
func Diff() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "diff <checkpointA> <checkpointB>",
16+
Short: "Show changes between two container checkpoints",
17+
Args: cobra.ExactArgs(2),
18+
RunE: diff,
19+
}
20+
21+
flags := cmd.Flags()
22+
flags.StringVar(
23+
format,
24+
"format",
25+
"tree",
26+
"Specify output format: tree or json",
27+
)
28+
flags.BoolVar(
29+
psTreeCmd,
30+
"ps-tree-cmd",
31+
false,
32+
"Include full command lines in process tree diff",
33+
)
34+
flags.BoolVar(
35+
psTreeEnv,
36+
"ps-tree-env",
37+
false,
38+
"Include environment variables in process tree diff",
39+
)
40+
flags.BoolVar(
41+
files,
42+
"files",
43+
false,
44+
"Include file descriptors in the diff",
45+
)
46+
flags.BoolVar(
47+
sockets,
48+
"sockets",
49+
false,
50+
"Include sockets in the diff",
51+
)
52+
53+
return cmd
54+
}
55+
56+
// diff executes the checkpoint diff logic
57+
func diff(cmd *cobra.Command, args []string) error {
58+
checkA := args[0]
59+
checkB := args[1]
60+
61+
requiredFiles := []string{
62+
metadata.SpecDumpFile,
63+
metadata.ConfigDumpFile,
64+
}
65+
66+
if *files || *sockets || *psTreeCmd || *psTreeEnv {
67+
// Include all files necessary for deep diffs
68+
for _, f := range []string{"files.img", "fs-", "ids-", "fdinfo-", "pagemap-", "pages-", "mm-", "pstree.img", "core-"} {
69+
requiredFiles = append(requiredFiles, filepath.Join(metadata.CheckpointDirectory, f))
70+
}
71+
}
72+
73+
// Load tasks from both checkpoints
74+
tasksAVal, err := internal.CreateTasks([]string{checkA}, requiredFiles)
75+
if err != nil {
76+
return fmt.Errorf("failed to load checkpointA: %w", err)
77+
}
78+
defer internal.CleanupTasks(tasksAVal)
79+
80+
tasksBVal, err := internal.CreateTasks([]string{checkB}, requiredFiles)
81+
if err != nil {
82+
return fmt.Errorf("failed to load checkpointB: %w", err)
83+
}
84+
defer internal.CleanupTasks(tasksBVal)
85+
86+
// Convert []Task → []*Task for DiffTasks
87+
tasksA := make([]*internal.Task, len(tasksAVal))
88+
for i := range tasksAVal {
89+
tasksA[i] = &tasksAVal[i]
90+
}
91+
92+
tasksB := make([]*internal.Task, len(tasksBVal))
93+
for i := range tasksBVal {
94+
tasksB[i] = &tasksBVal[i]
95+
}
96+
97+
// Compute diff
98+
diffTasks, err := internal.DiffTasks(tasksA, tasksB, *psTreeCmd, *psTreeEnv, *files, *sockets)
99+
if err != nil {
100+
return fmt.Errorf("failed to compute diff: %w", err)
101+
}
102+
103+
// Render output
104+
switch *format {
105+
case "tree":
106+
return internal.RenderDiffTreeView(diffTasks)
107+
case "json":
108+
return internal.RenderDiffJSONView(diffTasks)
109+
default:
110+
return fmt.Errorf("invalid output format: %s", *format)
111+
}
112+
}

internal/diff_types.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package internal
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"reflect"
7+
)
8+
9+
// DiffStatus describes how a task changed between two checkpoints
10+
type DiffStatus string
11+
12+
const (
13+
Added DiffStatus = "added"
14+
Removed DiffStatus = "removed"
15+
Modified DiffStatus = "modified"
16+
Unchanged DiffStatus = "unchanged"
17+
)
18+
19+
// DiffTask represents the forensic diff of a single task/process
20+
type DiffTask struct {
21+
// Stable identifier for matching tasks across checkpoints
22+
ID string `json:"id"`
23+
24+
// High-level classification of the change
25+
Status DiffStatus `json:"status"`
26+
27+
// Task state in checkpoint A (nil if Added)
28+
Before *Task `json:"before,omitempty"`
29+
30+
// Task state in checkpoint B (nil if Removed)
31+
After *Task `json:"after,omitempty"`
32+
33+
// Fine-grained differences detected inside the task
34+
Changes []DiffChange `json:"changes,omitempty"`
35+
}
36+
37+
// DiffChange represents a single detected difference.
38+
type DiffChange struct {
39+
// Logical category of the change (pstree, files, sockets, env, cmdline, etc.)
40+
Category string `json:"category"`
41+
42+
// Specific field or subcomponent that changed
43+
Field string `json:"field"`
44+
45+
// Value in checkpoint A
46+
Before any `json:"before,omitempty"`
47+
48+
// Value in checkpoint B
49+
After any `json:"after,omitempty"`
50+
}
51+
52+
// DiffTasks compares two sets of tasks and returns their differences.
53+
func DiffTasks(
54+
tasksA []*Task,
55+
tasksB []*Task,
56+
psTreeCmd bool,
57+
psTreeEnv bool,
58+
files bool,
59+
sockets bool,
60+
) ([]DiffTask, error) {
61+
if tasksA == nil || tasksB == nil {
62+
return nil, fmt.Errorf("nil task list provided")
63+
}
64+
65+
// Index tasks by CheckpointFilePath for matching
66+
indexA := make(map[string]*Task)
67+
indexB := make(map[string]*Task)
68+
69+
for _, t := range tasksA {
70+
indexA[t.CheckpointFilePath] = t
71+
}
72+
for _, t := range tasksB {
73+
indexB[t.CheckpointFilePath] = t
74+
}
75+
76+
var diffs []DiffTask
77+
78+
// Tasks present in A
79+
for id, taskA := range indexA {
80+
taskB, exists := indexB[id]
81+
82+
if !exists {
83+
// Removed task
84+
diffs = append(diffs, DiffTask{
85+
ID: id,
86+
Status: Removed,
87+
Before: taskA,
88+
})
89+
continue
90+
}
91+
92+
// Exists in both → compare
93+
if reflect.DeepEqual(taskA, taskB) {
94+
diffs = append(diffs, DiffTask{
95+
ID: id,
96+
Status: Unchanged,
97+
Before: taskA,
98+
After: taskB,
99+
})
100+
continue
101+
}
102+
103+
// Modified task
104+
diffs = append(diffs, DiffTask{
105+
ID: id,
106+
Status: Modified,
107+
Before: taskA,
108+
After: taskB,
109+
Changes: []DiffChange{
110+
{
111+
Category: "task",
112+
Field: "struct",
113+
Before: taskA,
114+
After: taskB,
115+
},
116+
},
117+
})
118+
}
119+
120+
// Tasks only in B → Added
121+
for id, taskB := range indexB {
122+
if _, exists := indexA[id]; exists {
123+
continue
124+
}
125+
126+
diffs = append(diffs, DiffTask{
127+
ID: id,
128+
Status: Added,
129+
After: taskB,
130+
})
131+
}
132+
133+
return diffs, nil
134+
}
135+
136+
// RenderDiffTreeView prints a human-readable tree of diff tasks
137+
func RenderDiffTreeView(diffTasks []DiffTask) error {
138+
for _, dt := range diffTasks {
139+
fmt.Printf("\nTask ID: %s | Status: %s\n", dt.ID, dt.Status)
140+
141+
if dt.Before != nil {
142+
fmt.Printf(" Before checkpoint: %s\n", dt.Before.CheckpointFilePath)
143+
}
144+
if dt.After != nil {
145+
fmt.Printf(" After checkpoint: %s\n", dt.After.CheckpointFilePath)
146+
}
147+
148+
for _, ch := range dt.Changes {
149+
fmt.Printf(" Change: [%s] %s | Before: %v | After: %v\n",
150+
ch.Category, ch.Field, ch.Before, ch.After)
151+
}
152+
}
153+
return nil
154+
}
155+
156+
// RenderDiffJSONView prints diff tasks in JSON format
157+
func RenderDiffJSONView(diffTasks []DiffTask) error {
158+
jsonData, err := json.MarshalIndent(diffTasks, "", " ")
159+
if err != nil {
160+
return err
161+
}
162+
163+
fmt.Println(string(jsonData))
164+
return nil
165+
}

0 commit comments

Comments
 (0)