Skip to content

Commit 6a1b9fe

Browse files
feat: add edit subcommand
Currently only supports tcp listen port remapping. Made the following changes: - Added the edit command in cmd/. - Added edit_archive.go in internals/ for the main edit logic. - Added archive_modifiers.go inside internals/. It contains the modifier logic for any entry that would need changes in the archive. Kept extensibility in mind, for any additional change logic in the future, we can reuse the same function tarStreamRewrite (in edit_archive.go) and write additional modifier functions in archive_modifiers.go. Assisted-by: Github Copilot Signed-off-by: Imranullah Khan <imranullahkhann2004@gmail.com>
1 parent c503439 commit 6a1b9fe

4 files changed

Lines changed: 477 additions & 0 deletions

File tree

checkpointctl.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func main() {
2929
rootCommand.AddCommand(cmd.List())
3030
rootCommand.AddCommand(cmd.BuildCmd())
3131
rootCommand.AddCommand(cmd.PluginCmd())
32+
rootCommand.AddCommand(cmd.EditCmd())
3233

3334
// Discover and register external plugins from PATH.
3435
// Plugins are executables named checkpointctl-<name> where <name>

cmd/edit.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/checkpoint-restore/checkpointctl/internal"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var tcpListenRemapFlag string
11+
12+
func EditCmd() *cobra.Command {
13+
cmd := &cobra.Command{
14+
Use: "edit <archive-path>",
15+
Short: "Edit a checkpoint archive",
16+
Long: `The 'edit' command can help you change the properties of a container inside a checkpoint archive.
17+
Currently only supports remapping the TCP listen ports.
18+
Example:
19+
checkpointctl edit --tcp-listen-remap 8080:80 checkpoint.tar`,
20+
Args: cobra.ExactArgs(1),
21+
RunE: editArchive,
22+
}
23+
24+
cmd.Flags().StringVar(
25+
&tcpListenRemapFlag,
26+
"tcp-listen-remap",
27+
"",
28+
"Remap TCP listen port (format: oldport:newport)",
29+
)
30+
31+
return cmd
32+
}
33+
34+
func editArchive(cmd *cobra.Command, args []string) error {
35+
archivePath := args[0]
36+
37+
if tcpListenRemapFlag != "" {
38+
return internal.TcpListenRemap(tcpListenRemapFlag, archivePath)
39+
}
40+
41+
return fmt.Errorf("no edit operation specified; use --tcp-listen-remap")
42+
}

internal/archive_modifiers.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package internal
4+
5+
import (
6+
"archive/tar"
7+
"bytes"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"os"
12+
"strconv"
13+
14+
"github.com/checkpoint-restore/go-criu/v8/crit"
15+
"github.com/checkpoint-restore/go-criu/v8/crit/images/fdinfo"
16+
)
17+
18+
// TCP_LISTEN state value from the Linux kernel
19+
const tcpListenState = 10
20+
21+
// remapFilesImg decodes a CRIU files.img binary image, remaps the source port
22+
// of any TCP listen socket matching oldPort to newPort, and re-encodes the image.
23+
func remapFilesImg(hdr *tar.Header, content io.Reader, oldPort, newPort uint32) (*tar.Header, []byte, error) {
24+
// crit.New requires *os.File, so write the tar entry content to a temp file
25+
tmpIn, err := os.CreateTemp("", "files-img-in-*.img")
26+
if err != nil {
27+
return nil, nil, fmt.Errorf("creating temp input file: %w", err)
28+
}
29+
defer os.Remove(tmpIn.Name())
30+
defer tmpIn.Close()
31+
32+
if _, err := io.Copy(tmpIn, content); err != nil {
33+
return nil, nil, fmt.Errorf("writing to temp file: %w", err)
34+
}
35+
if _, err := tmpIn.Seek(0, 0); err != nil {
36+
return nil, nil, fmt.Errorf("seeking temp file: %w", err)
37+
}
38+
39+
// Decode the binary image
40+
c := crit.New(tmpIn, nil, "", false, false)
41+
img, err := c.Decode(&fdinfo.FileEntry{})
42+
if err != nil {
43+
return nil, nil, fmt.Errorf("decoding files.img: %w", err)
44+
}
45+
46+
// Walk every entry looking for TCP listen sockets on the old port
47+
remapped := 0
48+
for _, entry := range img.Entries {
49+
fileEntry, ok := entry.Message.(*fdinfo.FileEntry)
50+
if !ok {
51+
continue
52+
}
53+
if fileEntry.GetType() != fdinfo.FdTypes_INETSK {
54+
continue
55+
}
56+
isk := fileEntry.GetIsk()
57+
if isk == nil {
58+
continue
59+
}
60+
if isk.GetState() == tcpListenState && isk.GetSrcPort() == oldPort {
61+
np := newPort
62+
isk.SrcPort = &np
63+
remapped++
64+
}
65+
}
66+
67+
if remapped == 0 {
68+
return nil, nil, fmt.Errorf("no TCP listen sockets found with source port %d", oldPort)
69+
}
70+
71+
// Encode the modified image to another temp file
72+
tmpOut, err := os.CreateTemp("", "files-img-out-*.img")
73+
if err != nil {
74+
return nil, nil, fmt.Errorf("creating temp output file: %w", err)
75+
}
76+
defer os.Remove(tmpOut.Name())
77+
defer tmpOut.Close()
78+
79+
cOut := crit.New(nil, tmpOut, "", false, false)
80+
if err := cOut.Encode(img); err != nil {
81+
return nil, nil, fmt.Errorf("encoding files.img: %w", err)
82+
}
83+
84+
// Read the re-encoded bytes
85+
if _, err := tmpOut.Seek(0, 0); err != nil {
86+
return nil, nil, fmt.Errorf("seeking output file: %w", err)
87+
}
88+
var buf bytes.Buffer
89+
if _, err := io.Copy(&buf, tmpOut); err != nil {
90+
return nil, nil, fmt.Errorf("reading output file: %w", err)
91+
}
92+
93+
// Update the tar header to reflect the new size
94+
hdr.Size = int64(buf.Len())
95+
return hdr, buf.Bytes(), nil
96+
}
97+
98+
// remapConfigDump modifies the container runtime config JSON to update:
99+
// - Port mappings (Podman/CRI-O: newPortMappings[].container_port)
100+
// - PORT environment variable in any nested env arrays
101+
func remapConfigDump(hdr *tar.Header, content io.Reader, oldPort, newPort string) (*tar.Header, []byte, error) {
102+
data, err := io.ReadAll(content)
103+
if err != nil {
104+
return nil, nil, fmt.Errorf("reading config.dump: %w", err)
105+
}
106+
107+
// Parse into a generic map to preserve all fields
108+
var config map[string]any
109+
if err := json.Unmarshal(data, &config); err != nil {
110+
return nil, nil, fmt.Errorf("parsing config.dump JSON: %w", err)
111+
}
112+
113+
// Update port mappings — search for any array of objects containing
114+
// "container_port" (covers Podman's "newPortMappings" and
115+
// CRI-O's "portMappings" or any similar structure)
116+
remapPortMappings(config, oldPort, newPort)
117+
118+
// Update PORT env var in any nested env arrays
119+
remapEnvRecursive(config, oldPort, newPort)
120+
121+
output, err := json.Marshal(config)
122+
if err != nil {
123+
return nil, nil, fmt.Errorf("marshaling config.dump: %w", err)
124+
}
125+
126+
hdr.Size = int64(len(output))
127+
return hdr, output, nil
128+
}
129+
130+
// remapSpecDump modifies the OCI runtime spec JSON to update the PORT env var.
131+
func remapSpecDump(hdr *tar.Header, content io.Reader, oldPort, newPort string) (*tar.Header, []byte, error) {
132+
data, err := io.ReadAll(content)
133+
if err != nil {
134+
return nil, nil, fmt.Errorf("reading spec.dump: %w", err)
135+
}
136+
137+
var spec map[string]any
138+
if err := json.Unmarshal(data, &spec); err != nil {
139+
return nil, nil, fmt.Errorf("parsing spec.dump JSON: %w", err)
140+
}
141+
142+
// The env array lives under spec.process.env
143+
if process, ok := spec["process"].(map[string]any); ok {
144+
if envSlice, ok := process["env"].([]any); ok {
145+
process["env"] = remapEnvSlice(envSlice, oldPort, newPort)
146+
}
147+
}
148+
149+
output, err := json.Marshal(spec)
150+
if err != nil {
151+
return nil, nil, fmt.Errorf("marshaling spec.dump: %w", err)
152+
}
153+
154+
hdr.Size = int64(len(output))
155+
return hdr, output, nil
156+
}
157+
158+
// remapPortMappings recursively searches a JSON structure for arrays of objects
159+
// containing a "container_port" key, and remaps matching ports.
160+
// This covers Podman ("newPortMappings"), CRI-O ("portMappings"), and any
161+
// future runtime that uses a similar convention.
162+
func remapPortMappings(obj any, oldPort, newPort string) {
163+
switch v := obj.(type) {
164+
case map[string]any:
165+
for _, val := range v {
166+
remapPortMappings(val, oldPort, newPort)
167+
}
168+
case []any:
169+
for _, item := range v {
170+
if mapping, ok := item.(map[string]any); ok {
171+
if cp, ok := mapping["container_port"].(float64); ok && strconv.FormatFloat(cp, 'f', -1, 64) == oldPort {
172+
newPortNum, _ := strconv.ParseFloat(newPort, 64)
173+
mapping["container_port"] = newPortNum
174+
}
175+
}
176+
remapPortMappings(item, oldPort, newPort)
177+
}
178+
}
179+
}
180+
181+
// remapEnvRecursive walks a JSON structure looking for any "env" key
182+
// whose value is an array of strings, and replaces PORT=oldPort with PORT=newPort.
183+
// This handles any runtime's config layout without hardcoding paths.
184+
func remapEnvRecursive(obj any, oldPort, newPort string) {
185+
m, ok := obj.(map[string]any)
186+
if !ok {
187+
return
188+
}
189+
for key, val := range m {
190+
if key == "env" {
191+
if envSlice, ok := val.([]any); ok {
192+
m["env"] = remapEnvSlice(envSlice, oldPort, newPort)
193+
}
194+
} else {
195+
switch child := val.(type) {
196+
case map[string]any:
197+
remapEnvRecursive(child, oldPort, newPort)
198+
case []any:
199+
for _, item := range child {
200+
remapEnvRecursive(item, oldPort, newPort)
201+
}
202+
}
203+
}
204+
}
205+
}
206+
207+
// remapEnvSlice replaces PORT=oldPort with PORT=newPort in an env slice.
208+
func remapEnvSlice(envSlice []any, oldPort, newPort string) []any {
209+
target := "PORT=" + oldPort
210+
replacement := "PORT=" + newPort
211+
for i, v := range envSlice {
212+
s, ok := v.(string)
213+
if !ok {
214+
continue
215+
}
216+
if s == target {
217+
envSlice[i] = replacement
218+
}
219+
}
220+
return envSlice
221+
}

0 commit comments

Comments
 (0)