blob: 55e14e2c966ca9724fbeacacb0ddbd53f5c27bee [file]
package containers
import (
"context"
"fmt"
"log"
"os"
"syscall"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/pkg/cio"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/containerd/containerd/v2/pkg/oci"
"github.com/opencontainers/runtime-spec/specs-go"
)
// GetOrDownloadImage gets an image from the containerd client if the image is
// already available locally, and downloads and unpacks it if it is not.
func GetOrDownloadImage(ctx context.Context, client *containerd.Client, imageName string) (containerd.Image, error) {
image, err := client.GetImage(ctx, imageName)
if err != nil {
log.Printf("error getting local image: %v\n", err)
}
if err != nil || image == nil {
image, err = client.Pull(ctx, imageName, containerd.WithPullUnpack)
if err != nil {
return nil, fmt.Errorf("error pulling image: %v", err)
} else {
log.Println("finished pulling image")
}
}
return image, nil
}
// A ContainerConfig is a simplified set of configurations which can be used to
// build an OCI container.
type ContainerConfig struct {
// Name is the name that will be given to the container.
Name string
// Command is the command that will serve as the container entrypoint. If empty,
// the default command for the container will be used.
Command string
// Args is the set of arguments which will be passed to the container command.
Args []string
// Cwd is the working directory where the command will be run.
Cwd string
// Env is a map from variable names to values which will be passed to the container
// as environment variables.
Env map[string]string
// DirMounts is the set of directories which will be mapped from the host to the container.
// The directories will be created automatically if they don't exist.
DirMounts []specs.Mount
// FileMounts is the set of files that will be mapped from the host to the container.
// It is an error if the expected file doesn't exist in the host.
FileMounts []specs.Mount
// Priviliged determines whether or not the container will be run in privileged mode.
Privileged bool
// NetHost determines whether or not the container will use the host's networking.
NetHost bool
// Opts is any additional options which will be used to create the container.
Opts []oci.SpecOpts
}
// Run downloads an image if it's not available locally, then runs the container
// command specified by config and waits for its completion.
// Returns the exit status of the container, or 1 on other failure.
func Run(ctx context.Context, imageName string, config ContainerConfig, containerNamespace, containerdSocket string) (uint32, error) {
client, err := containerd.New(containerdSocket)
if err != nil {
log.Fatalf("error connecting to containerd socket: %v\n", err)
}
defer client.Close()
ctx = namespaces.WithNamespace(ctx, containerNamespace)
image, err := GetOrDownloadImage(ctx, client, imageName)
if err != nil {
return 1, err
}
container, err := NewContainer(ctx, client, image, config)
if err != nil {
return 1, err
}
defer func() {
if err := DeleteContainer(ctx, client, container); err != nil {
log.Printf("could not delete container: %v\n", err)
}
}()
return RunContainerTask(ctx, client, container)
}
// NewContainer builds and returns a new container from the provided image and config.
func NewContainer(
ctx context.Context,
client *containerd.Client,
image containerd.Image,
config ContainerConfig,
) (containerd.Container, error) {
processArgs := append([]string{config.Command}, config.Args...)
var envVarsArray []string
for name, value := range config.Env {
envVarsArray = append(envVarsArray, fmt.Sprintf("%s=%s", name, value))
}
for _, mount := range config.DirMounts {
if err := os.MkdirAll(mount.Source, 0777); err != nil {
return nil, fmt.Errorf("could not find or create mount directory %s: %v", mount.Source, err)
}
}
// The order matters here! The image config has to come first.
opts := []oci.SpecOpts{
oci.WithImageConfig(image),
oci.WithEnv(envVarsArray),
oci.WithProcessArgs(processArgs...),
oci.WithProcessCwd("/"),
oci.WithMounts(config.DirMounts),
oci.WithMounts(config.FileMounts),
}
if config.Privileged {
opts = append(opts, oci.WithPrivileged)
}
if config.NetHost {
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("could not get hostname: %v\n", err)
}
hostNetworkOpts := []oci.SpecOpts{
oci.WithHostNamespace(specs.NetworkNamespace),
oci.WithHostHostsFile,
oci.WithHostResolvconf,
oci.WithEnv([]string{fmt.Sprintf("HOSTNAME=%s", hostname)}),
}
opts = append(opts, hostNetworkOpts...)
}
opts = append(opts, config.Opts...)
spec := containerd.WithNewSpec(opts...)
container, err := client.NewContainer(
ctx,
config.Name,
containerd.WithNewSnapshot(fmt.Sprintf("%s-rootfs", config.Name), image),
spec,
)
if err != nil {
return nil, fmt.Errorf("error creating container: %v", err)
}
return container, nil
}
// RunContainerTask runs a container's command and waits for its completion.
// Returns the exit status of the container, or 1 on other failure.
func RunContainerTask(ctx context.Context, client *containerd.Client, container containerd.Container) (uint32, error) {
cioCreator := cio.NewCreator(cio.WithStdio)
task, err := container.NewTask(ctx, cioCreator)
if err != nil {
return 1, fmt.Errorf("error creating task: %v", err)
}
defer func() {
if err := DeleteTask(ctx, task); err != nil {
log.Printf("could not delete container task: %v\n", err)
}
}()
exitStatusC, err := task.Wait(ctx)
if err != nil {
return 1, fmt.Errorf("error waiting for task completion: %v", err)
}
err = task.Start(ctx)
if err != nil {
return 1, fmt.Errorf("error starting task: %v", err)
}
status := <-exitStatusC
code, _, err := status.Result()
if err != nil {
return code, err
}
return code, nil
}
// DeleteContainer deletes a container and its associated snapshot.
func DeleteContainer(ctx context.Context, client *containerd.Client, container containerd.Container) error {
if err := container.Delete(ctx, containerd.WithSnapshotCleanup); err != nil {
return fmt.Errorf("failed to delete container: %v", err)
}
return nil
}
// DeleteTask deletes a task.
// If the task is not already stopped, this will kill it by calling KillTask.
func DeleteTask(ctx context.Context, task containerd.Task) error {
taskStatus, err := task.Status(ctx)
if err != nil {
log.Printf("failed to get task status: %v\n", err)
}
if taskStatus.Status != containerd.Stopped {
if err := KillTask(ctx, task); err != nil {
return fmt.Errorf("failed to kill task: %v", err)
}
}
if _, err = task.Delete(ctx); err != nil {
log.Printf("error deleting task: %v\n", err)
}
return nil
}
// KillTask attempts to kill a task by sending SIGTERM.
// If the SIGTERM does not kill the task, a SIGKILL will be sent as well.
func KillTask(ctx context.Context, task containerd.Task) error {
if err := task.Kill(ctx, syscall.SIGTERM); err != nil {
return fmt.Errorf("could not kill task: %v", err)
}
taskStatus, err := task.Status(ctx)
if err != nil || taskStatus.Status != containerd.Stopped {
log.Printf("task could not be killed with SIGTERM, sending SIGKILL: %v\n", err)
if err := task.Kill(ctx, syscall.SIGKILL); err != nil {
return fmt.Errorf("could not kill task: %v", err)
}
}
return nil
}