Skip to content

Commit cc8cd59

Browse files
authored
Merge pull request #202 from adrianreber/2026-02-23-plugin
Add plugin system for external subcommands
2 parents ee69110 + c07ceff commit cc8cd59

7 files changed

Lines changed: 679 additions & 6 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,45 @@ In this example, the `checkpointctl build` command converts the `checkpoint.tar`
216216
OCI-compatible image and tags it as `quay.io/foo/bar:latest`. The following `buildah push` command
217217
then uploads the newly created OCI image to the container registry, making it available for deployment.
218218

219+
### `plugin` sub-command
220+
221+
The `plugin` sub-command manages external plugins that extend checkpointctl
222+
with additional subcommands. Plugins are standalone executables in PATH with
223+
names matching the pattern `checkpointctl-<name>`.
224+
225+
```console
226+
$ checkpointctl plugin list
227+
Available plugins:
228+
hello My custom plugin description
229+
/usr/local/bin/checkpointctl-hello
230+
```
231+
232+
This plugin architecture allows extending checkpointctl functionality without
233+
increasing the binary size of the core tool. Plugins with larger dependencies
234+
can be distributed separately, and can be implemented in any programming
235+
language.
236+
237+
#### Creating a plugin
238+
239+
To create a plugin, create an executable named `checkpointctl-<name>`:
240+
241+
```bash
242+
#!/bin/sh
243+
# Support --plugin-description for help text (must exit with code 42)
244+
if [ "$1" = "--plugin-description" ]; then
245+
echo "Say hello"
246+
exit 42
247+
fi
248+
echo "Hello, world!"
249+
```
250+
251+
Save it as `checkpointctl-hello` in a directory in your PATH and make it
252+
executable. It will then be available as `checkpointctl hello`.
253+
254+
Note: The exit code 42 is required to indicate that the plugin supports
255+
the `--plugin-description` flag. Any other exit code will result in a
256+
default description being used.
257+
219258
## Installing from source code
220259

221260
1. Clone the repository.

checkpointctl.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,23 @@ func main() {
2424
}
2525

2626
rootCommand.AddCommand(cmd.Show())
27-
2827
rootCommand.AddCommand(cmd.Inspect())
29-
3028
rootCommand.AddCommand(cmd.MemParse())
31-
3229
rootCommand.AddCommand(cmd.List())
33-
3430
rootCommand.AddCommand(cmd.BuildCmd())
31+
rootCommand.AddCommand(cmd.PluginCmd())
32+
33+
// Discover and register external plugins from PATH.
34+
// Plugins are executables named checkpointctl-<name> where <name>
35+
// becomes a subcommand. Built-in commands take precedence.
36+
builtinCommands := getBuiltinCommandNames(rootCommand)
37+
for _, plugin := range cmd.DiscoverPlugins() {
38+
// Skip plugins that would shadow built-in commands
39+
if builtinCommands[plugin.Name] {
40+
continue
41+
}
42+
rootCommand.AddCommand(cmd.CreatePluginCommand(plugin))
43+
}
3544

3645
rootCommand.AddCommand(cmd.Diff())
3746

@@ -41,3 +50,12 @@ func main() {
4150
os.Exit(1)
4251
}
4352
}
53+
54+
// getBuiltinCommandNames returns a set of command names already registered.
55+
func getBuiltinCommandNames(root *cobra.Command) map[string]bool {
56+
names := make(map[string]bool)
57+
for _, c := range root.Commands() {
58+
names[c.Name()] = true
59+
}
60+
return names
61+
}

cmd/plugin.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package cmd
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
"syscall"
14+
"time"
15+
16+
"github.com/spf13/cobra"
17+
)
18+
19+
const (
20+
pluginPrefix = "checkpointctl-"
21+
pluginDescriptionFlag = "--plugin-description"
22+
pluginDescriptionTimeout = 500 * time.Millisecond
23+
pluginDescriptionExitCode = 42
24+
)
25+
26+
// Plugin represents a discovered external plugin.
27+
type Plugin struct {
28+
Name string // subcommand name (e.g., "build")
29+
Path string // full path to executable
30+
Description string // description provided by the plugin
31+
}
32+
33+
// getPluginDescription queries the plugin for its description by running
34+
// it with --plugin-description flag. Returns empty string if the plugin
35+
// doesn't support this flag (must exit with code 42) or fails to respond in time.
36+
func getPluginDescription(pluginPath string) string {
37+
ctx, cancel := context.WithTimeout(context.Background(), pluginDescriptionTimeout)
38+
defer cancel()
39+
40+
cmd := exec.CommandContext(ctx, pluginPath, pluginDescriptionFlag)
41+
output, err := cmd.Output()
42+
// Plugin must exit with code 42 to indicate it supports --plugin-description
43+
if err != nil {
44+
var exitErr *exec.ExitError
45+
if errors.As(err, &exitErr) {
46+
if exitErr.ExitCode() == pluginDescriptionExitCode {
47+
// Exit code 42 with output means description is supported
48+
desc := strings.TrimSpace(string(output))
49+
if idx := strings.Index(desc, "\n"); idx != -1 {
50+
desc = desc[:idx]
51+
}
52+
return desc
53+
}
54+
}
55+
return ""
56+
}
57+
58+
// Exit code 0 does not indicate description support
59+
return ""
60+
}
61+
62+
// DiscoverPlugins searches PATH for checkpointctl-* executables.
63+
func DiscoverPlugins() []Plugin {
64+
var plugins []Plugin
65+
seen := make(map[string]bool)
66+
67+
pathEnv := os.Getenv("PATH")
68+
for _, dir := range filepath.SplitList(pathEnv) {
69+
if dir == "" {
70+
continue
71+
}
72+
73+
entries, err := os.ReadDir(dir)
74+
if err != nil {
75+
continue
76+
}
77+
78+
for _, entry := range entries {
79+
if entry.IsDir() {
80+
continue
81+
}
82+
83+
name := entry.Name()
84+
if !strings.HasPrefix(name, pluginPrefix) {
85+
continue
86+
}
87+
88+
// Extract subcommand name from checkpointctl-<name>
89+
subcommand := strings.TrimPrefix(name, pluginPrefix)
90+
if subcommand == "" {
91+
continue
92+
}
93+
94+
// Skip if already found (first in PATH wins)
95+
if seen[subcommand] {
96+
continue
97+
}
98+
99+
fullPath := filepath.Join(dir, name)
100+
info, err := os.Stat(fullPath)
101+
if err != nil {
102+
continue
103+
}
104+
105+
// Check if executable
106+
if info.Mode()&0o111 == 0 {
107+
continue
108+
}
109+
110+
// Convert to absolute path to ensure exec.Command works correctly
111+
// (relative paths without "/" are looked up in PATH)
112+
absPath, err := filepath.Abs(fullPath)
113+
if err != nil {
114+
absPath = fullPath
115+
}
116+
117+
seen[subcommand] = true
118+
plugins = append(plugins, Plugin{
119+
Name: subcommand,
120+
Path: absPath,
121+
Description: getPluginDescription(absPath),
122+
})
123+
}
124+
}
125+
126+
return plugins
127+
}
128+
129+
// CreatePluginCommand creates a cobra.Command that executes the plugin.
130+
func CreatePluginCommand(plugin Plugin) *cobra.Command {
131+
short := plugin.Description
132+
if short == "" {
133+
short = fmt.Sprintf("Plugin provided by %s", plugin.Path)
134+
}
135+
136+
return &cobra.Command{
137+
Use: plugin.Name,
138+
Short: short,
139+
Long: fmt.Sprintf("External plugin command provided by %s", plugin.Path),
140+
DisableFlagParsing: true,
141+
RunE: func(cmd *cobra.Command, args []string) error {
142+
return ExecutePlugin(plugin.Path, args)
143+
},
144+
}
145+
}
146+
147+
// ExecutePlugin runs the plugin binary with the given arguments.
148+
// It uses syscall.Exec to replace the current process with the plugin,
149+
// which provides proper signal handling and exit code propagation.
150+
func ExecutePlugin(pluginPath string, args []string) error {
151+
return syscall.Exec(pluginPath, append([]string{pluginPath}, args...), os.Environ())
152+
}
153+
154+
// PluginList returns a command that lists all available plugins.
155+
func PluginList() *cobra.Command {
156+
return &cobra.Command{
157+
Use: "list",
158+
Short: "List available plugins",
159+
RunE: func(cmd *cobra.Command, args []string) error {
160+
plugins := DiscoverPlugins()
161+
if len(plugins) == 0 {
162+
fmt.Println("No plugins found in PATH")
163+
return nil
164+
}
165+
fmt.Println("Available plugins:")
166+
for _, p := range plugins {
167+
desc := p.Description
168+
if desc == "" {
169+
desc = "(no description)"
170+
}
171+
fmt.Printf(" %-20s %s\n", p.Name, desc)
172+
fmt.Printf(" %-20s %s\n", "", p.Path)
173+
}
174+
return nil
175+
},
176+
}
177+
}
178+
179+
// PluginCmd returns the parent command for plugin management.
180+
func PluginCmd() *cobra.Command {
181+
cmd := &cobra.Command{
182+
Use: "plugin",
183+
Short: "Manage checkpointctl plugins",
184+
}
185+
cmd.AddCommand(PluginList())
186+
return cmd
187+
}

0 commit comments

Comments
 (0)