Skip to content

Commit da39e60

Browse files
authored
Merge pull request #174 from thin-edge/feat-container-image-sm-plugin
feat: add container-image plugin to allow users to manage container images without having to start containers
2 parents 01e551a + 54b2796 commit da39e60

File tree

12 files changed

+440
-1
lines changed

12 files changed

+440
-1
lines changed

.goreleaser.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ nfpms:
119119
dst: /etc/tedge/sm-plugins/container-group
120120
type: symlink
121121

122+
- src: /usr/bin/tedge-container
123+
dst: /etc/tedge/sm-plugins/container-image
124+
type: symlink
125+
122126
# log plugins
123127
- src: packaging/log-plugins/container
124128
dst: /usr/share/tedge/log-plugins/container

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The instructions assume that you are using thin-edge.io >= 1.0.0
1616
* The following software management plugins which is called when installing and removing containers/container groups via Cumulocity
1717
* `container` - Deploy a single container (`docker run xxx` equivalent)
1818
* `container-group` - Deploy one or more container as defined by a `docker-compose.yaml` file (`docker compose up` equivalent), or an archive (gzip or zip)
19+
* `container-image` - (optional) Install/remove container images. This software management plugin is disabled by default but can be enabled by setting `container_image.enabled` to `true` in the configuration file
1920

2021

2122
**Technical summary**

cli/container_image/cmd.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package container_image
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/spf13/viper"
9+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
10+
)
11+
12+
// IsEnabled check if the container-image software management plugin is enabled or not
13+
func IsEnabled(cmdCli cli.Cli) func(*cobra.Command, []string) error {
14+
return func(cmd *cobra.Command, args []string) error {
15+
enabled := cmdCli.GetBool("container_image.enabled")
16+
if !enabled {
17+
slog.Info("The container-image sm-plugin is not enabled. Enabled it using the 'container-image.enabled' setting")
18+
return cli.ExitCodeError{
19+
Code: 1,
20+
Err: fmt.Errorf("container-image is not enabled"),
21+
Silent: true,
22+
}
23+
}
24+
return nil
25+
}
26+
}
27+
28+
// NewCommand returns a cobra command for `container` subcommands
29+
func NewCommand(cmdCli cli.Cli) *cobra.Command {
30+
cmd := &cobra.Command{
31+
Use: "container-image",
32+
Short: "container-image software management plugin",
33+
}
34+
viper.SetDefault("container_image.enabled", false)
35+
cmd.AddCommand(
36+
NewPrepareCommand(cmdCli),
37+
NewInstallCommand(cmdCli),
38+
NewRemoveCommand(cmdCli),
39+
NewUpdateListCommand(cmdCli),
40+
NewListCommand(cmdCli),
41+
NewFinalizeCommand(cmdCli),
42+
)
43+
return cmd
44+
}

cli/container_image/finalize.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
Copyright © 2024 thin-edge.io <info@thin-edge.io>
3+
*/
4+
package container_image
5+
6+
import (
7+
"log/slog"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
11+
)
12+
13+
func NewFinalizeCommand(ctx cli.Cli) *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "finalize",
16+
Short: "Finalize container image install/remove operation",
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
slog.Info("Executing", "cmd", cmd.CalledAs(), "args", args)
19+
return nil
20+
},
21+
}
22+
return cmd
23+
}

cli/container_image/install.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
Copyright © 2024 thin-edge.io <info@thin-edge.io>
3+
*/
4+
package container_image
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"log/slog"
12+
"os"
13+
"strings"
14+
"time"
15+
16+
"github.com/docker/docker/client"
17+
"github.com/spf13/cobra"
18+
"github.com/spf13/viper"
19+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
20+
"github.com/thin-edge/tedge-container-plugin/pkg/container"
21+
)
22+
23+
type InstallCommand struct {
24+
*cobra.Command
25+
26+
CommandContext cli.Cli
27+
ModuleVersion string
28+
File string
29+
}
30+
31+
type ImageResponse struct {
32+
Stream string `json:"stream"`
33+
}
34+
35+
// installCmd represents the install command
36+
func NewInstallCommand(cliContext cli.Cli) *cobra.Command {
37+
command := &InstallCommand{
38+
CommandContext: cliContext,
39+
}
40+
cmd := &cobra.Command{
41+
Use: "install <MODULE_NAME>",
42+
Short: "Install a container image",
43+
Example: `
44+
Example 1: Install a container and pull in the image from any available registries
45+
46+
$ tedge-container container-image install docker.io/nginx --module-version latest
47+
48+
`,
49+
Args: cobra.ExactArgs(1),
50+
PreRunE: IsEnabled(cliContext),
51+
RunE: command.RunE,
52+
}
53+
54+
cmd.Flags().StringVar(&command.ModuleVersion, "module-version", "latest", "Software version to install")
55+
cmd.Flags().StringVar(&command.File, "file", "", "File")
56+
viper.SetDefault("container.alwaysPull", false)
57+
command.Command = cmd
58+
return cmd
59+
}
60+
61+
func (c *InstallCommand) RunE(cmd *cobra.Command, args []string) error {
62+
slog.Info("Executing", "cmd", cmd.CalledAs(), "args", args)
63+
imageName := args[0]
64+
imageRef := fmt.Sprintf("%s:%s", imageName, c.ModuleVersion)
65+
66+
// Only enable pulling if the user is providing a file
67+
disablePull := c.File != ""
68+
69+
cli, err := container.NewContainerClient(context.TODO())
70+
if err != nil {
71+
return err
72+
}
73+
74+
ctx := context.Background()
75+
76+
if c.File != "" {
77+
slog.Info("Loading image from file.", "file", c.File)
78+
file, err := os.Open(c.File)
79+
if err != nil {
80+
return err
81+
}
82+
defer file.Close()
83+
84+
imageResp, err := cli.Client.ImageLoad(ctx, file, client.ImageLoadWithQuiet(true))
85+
if err != nil {
86+
return err
87+
}
88+
defer imageResp.Body.Close()
89+
if imageResp.JSON {
90+
b, err := io.ReadAll(imageResp.Body)
91+
if err != nil {
92+
return nil
93+
}
94+
imageDetails := &ImageResponse{}
95+
if err := json.Unmarshal(b, &imageDetails); err != nil {
96+
return err
97+
}
98+
99+
slog.Info("Loaded image.", "stream", imageDetails.Stream)
100+
images := make([]string, 0)
101+
moduleVersionFound := false
102+
for _, line := range strings.Split(imageDetails.Stream, "\n") {
103+
if strings.HasPrefix(line, "Loaded image: ") {
104+
imageName := strings.TrimPrefix(line, "Loaded image: ")
105+
slog.Info("Found image reference in file.", "file", c.File, "image", imageName)
106+
images = append(images, imageName)
107+
if imageName == c.ModuleVersion {
108+
moduleVersionFound = true
109+
}
110+
}
111+
}
112+
113+
// Check if the user has given correct image to use from the
114+
if !moduleVersionFound {
115+
switch count := len(images); count {
116+
case 0:
117+
slog.Warn("No images detected in stream output. Aborting to prevent accidentally load image from network.", "file", c.File, "images", images)
118+
// Fail hard to prevent potentially trying to pull in the image (as the user has opted into file based images)
119+
return fmt.Errorf("no image detected in file. name=%s, version=%s, file=%s", imageName, c.ModuleVersion, c.File)
120+
default:
121+
if count > 1 {
122+
slog.Warn("More than 1 image detected in file. Only using the first image.", "file", c.File, "images", images, "image_count", count)
123+
}
124+
125+
imageRef = images[0]
126+
slog.Info("Detected image reference does not match the module-version. Using first imageRef from loaded image.", "imageRef", imageRef, "version", c.ModuleVersion)
127+
}
128+
}
129+
}
130+
}
131+
132+
//
133+
// Check and pull image if it is not present
134+
if !disablePull {
135+
if _, err := cli.ImagePullWithRetries(ctx, imageRef, c.CommandContext.ImageAlwaysPull(), container.ImagePullOptions{
136+
AuthFunc: c.CommandContext.GetContainerRepositoryCredentialsFunc(imageRef),
137+
MaxAttempts: 2,
138+
Wait: 5 * time.Second,
139+
}); err != nil {
140+
return err
141+
}
142+
}
143+
144+
slog.Info("Installed image.", "name", imageName, "version", c.ModuleVersion, "imageRef", imageRef)
145+
return nil
146+
}

cli/container_image/list.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright © 2024 thin-edge.io <info@thin-edge.io>
3+
*/
4+
package container_image
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"log/slog"
10+
"strings"
11+
12+
"github.com/docker/docker/api/types/image"
13+
"github.com/spf13/cobra"
14+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
15+
"github.com/thin-edge/tedge-container-plugin/pkg/container"
16+
)
17+
18+
// listCmd represents the list command
19+
func NewListCommand(cliContext cli.Cli) *cobra.Command {
20+
return &cobra.Command{
21+
Use: "list",
22+
Short: "List container images",
23+
Args: cobra.ExactArgs(0),
24+
PreRunE: IsEnabled(cliContext),
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
slog.Info("Executing", "cmd", cmd.CalledAs(), "args", args)
27+
ctx := context.Background()
28+
cli, err := container.NewContainerClient(ctx, cliContext.GetContainerClientOptions()...)
29+
if err != nil {
30+
return err
31+
}
32+
images, err := cli.Client.ImageList(context.Background(), image.ListOptions{})
33+
if err != nil {
34+
return err
35+
}
36+
stdout := cmd.OutOrStdout()
37+
for _, item := range images {
38+
for _, tag := range item.RepoTags {
39+
if name, version, ok := strings.Cut(tag, ":"); ok {
40+
fmt.Fprintf(stdout, "%s\t%s\n", name, version)
41+
}
42+
}
43+
}
44+
return nil
45+
},
46+
}
47+
}

cli/container_image/prepare.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
Copyright © 2024 thin-edge.io <info@thin-edge.io>
3+
*/
4+
package container_image
5+
6+
import (
7+
"log/slog"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
11+
)
12+
13+
// prepareCmd represents the prepare command
14+
func NewPrepareCommand(ctx cli.Cli) *cobra.Command {
15+
return &cobra.Command{
16+
Use: "prepare",
17+
Short: "Prepare for container image install/removal",
18+
Run: func(cmd *cobra.Command, args []string) {
19+
slog.Info("Executing", "cmd", cmd.CalledAs(), "args", args)
20+
},
21+
}
22+
}

cli/container_image/remove.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright © 2024 thin-edge.io <info@thin-edge.io>
3+
*/
4+
package container_image
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"log/slog"
10+
11+
"github.com/containerd/errdefs"
12+
"github.com/docker/docker/api/types/image"
13+
"github.com/spf13/cobra"
14+
"github.com/thin-edge/tedge-container-plugin/pkg/cli"
15+
"github.com/thin-edge/tedge-container-plugin/pkg/container"
16+
)
17+
18+
type RemoveCommand struct {
19+
*cobra.Command
20+
21+
ModuleVersion string
22+
}
23+
24+
// removeCmd represents the remove command
25+
func NewRemoveCommand(cliContext cli.Cli) *cobra.Command {
26+
command := &RemoveCommand{}
27+
cmd := &cobra.Command{
28+
Use: "remove",
29+
Short: "Remove a container image",
30+
Example: `
31+
Example 1: Remove a container image
32+
33+
$ tedge-container container remove alpine --module-version 3.21
34+
`,
35+
Args: cobra.ExactArgs(1),
36+
PreRunE: IsEnabled(cliContext),
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
slog.Info("Executing", "cmd", cmd.CalledAs(), "args", args)
39+
ctx := context.Background()
40+
imageName := args[0]
41+
42+
var imageRef string
43+
if command.ModuleVersion != "" {
44+
imageRef = fmt.Sprintf("%s:%s", imageName, command.ModuleVersion)
45+
} else {
46+
imageRef = imageName
47+
}
48+
49+
cli, err := container.NewContainerClient(ctx, cliContext.GetContainerClientOptions()...)
50+
if err != nil {
51+
return err
52+
}
53+
54+
_, imageErr := cli.Client.ImageRemove(context.Background(), imageRef, image.RemoveOptions{})
55+
if errdefs.IsNotFound(imageErr) {
56+
slog.Info("Image reference not found, so nothing to remove", "imageRef", imageRef)
57+
return nil
58+
}
59+
if imageErr != nil {
60+
return imageErr
61+
}
62+
slog.Info("Successfully removed image", "imageRef", imageRef)
63+
return nil
64+
},
65+
}
66+
cmd.Flags().StringVar(&command.ModuleVersion, "module-version", "", "Software version to remove")
67+
return cmd
68+
}

0 commit comments

Comments
 (0)