Building the CLI interface for a container runtime in Go

This is Part 1 of the series Building a container runtime from scratch in Go.

In the first part of the series we learn about the operations defined by the OCI Runtime Spec and implement the command-line interface to handle them.

The source code to accompany this post is available on GitHub.

Framework for the OCI Runtime CLI

The OCI Runtime Spec defines a set of operations1 for higher-level container tooling to interact with a container runtime, leaving individual runtimes to decide the actual implementation details. Different tooling and runtimes communicate over different APIs or protocols. For example, Docker communicates with a container runtime using a command-line interface (CLI), whereas Kubernetes communicates over gRPC.

For our runtime, we’ll be keeping things simple and creating a CLI.

Architecture of the runtime CLI

The architecture for our CLI will be structured as below.

graph LR
    A[Stdin]
    A --> B(Root Cmd)
	B --> C(Sub Cmd A)
    B --> D(Sub Cmd B)
	C --> E(Handler A)
	D --> F(Handler B)
	B --> G(Sub Cmd _..Z_)
	G --> H(Handler _..Z_)

For example, when running the command anocir create --bundle busybox mycontainer:

graph LR
    A[**1**<br>'anocir create busybox'<br>Stdin]
    A --> B(**2**<br>'anocir'<br>Root Cmd)
	B --> C(**3**<br>'create'<br>Sub Cmd)
	C --> E(**4**<br>'create'<br>Handler)
  1. The anocir create --bundle busybox mycontainer command in read in from stdin.
  2. The anocir root command will delegate to the create subcommand.
  3. The create subcommand will parse the busybox value from the --bundle flag and the mycontainer container ID argument then call the create handler.
  4. The create handler will perform the necessary tasks to handle the create operation - creating the container with the ID mycontainer from the busybox bundle.

Scaffolding out the project

Let’s initialise a new project.

  1. Create a directory for the project: mkdir anocir
  2. Change into the directory: cd anocir
  3. Initialise: go mod init github.com/nixpig/anocir
  4. Create the main.go file and add the main function
package main

func main() {}

Creating handlers for the runtime operations

The handlers for the runtime operations specify a ‘generic’ interface, rather than a specific API like a CLI; from the spec:

Note: these operations are not specifying any command-line APIs, and the parameters are inputs for general operations.1

At this point, we’re only concerned with implementing the API for these operations, so we’ll focus only on that. We’ll address the actual handling of these operations in a later part.

Each operation will be a function in an internal/operations package. The operation function will take in a pointer to a struct of options for that operation. All operations will return an error, with the exception of the state operation, which will return a (string, error); we’ll address that one first.

Query State operation

Operation Link to spec
state <container-id> Spec

The state operation is responsible for getting the state of a container. It takes in the ID of the container to get the state of.

package operations

import "fmt"

type StateOpts struct {
    ID string
}

func State(opts *StateOpts) (string, error) {
    fmt.Println(opts)

    return "", nil
}

Create operation

Operation Link to spec
create <container-id> <path-to-bundle> Spec

The create operation is responsible for creating a container. It takes in the ID of the new container to create and the path to a runtime bundle (more on this later) from which to create the container.

package operations

import "fmt"

type CreateOpts struct {
    ID            string
    Bundle        string
}

func Create(opts *CreateOpts) error {
    fmt.Println(opts)

    return nil
}

Start operation

Operation Link to spec
start <container-id> Spec

The start operation is responsible for starting a container. It takes in the ID of the container to start.

package operations

import "fmt"

type StartOpts struct {
    ID string
}

func Start(opts *StartOpts) error {
    fmt.Println(opts)

    return nil
}

Kill operation

Operation Link to spec
kill <container-id> <signal> Spec

The kill operation is responsible for sending a signal to a running container. It takes in the ID of the container to send a signal to and the signal to send.

package operations

import "fmt"

type KillOpts struct {
    ID     string
    Signal string
}

func Kill(opts *KillOpts) error {
    fmt.Println(opts)

    return nil
}

Delete operation

Operation Link to spec
delete <container-id> Spec

The delete operation is responsible for deleting a container. It takes in the ID of the container to delete.

package operations

import "fmt"

type DeleteOpts struct {
    ID string
}

func Delete(opts *DeleteOpts) error {
    fmt.Println(opts)

    return nil
}

With the operations API defined, we can start creating the CLI to interact with it.

Creating the runtime CLI

Since we’re interested in learning about Linux container runtimes, and not the intricacies of building command-line interfaces, we’re going to use the popular Cobra library to handle CLI interactions, such as commands, arguments and flags parsing. If you’ve ever used Kubernetes, Helm or the GitHub CLI, then you’ve interacted with it before.

Run go get github.com/spf13/cobra@v1.8.1 from the project root to install Cobra, then we’ll create an internal/cli package and scaffold out the ‘root command’.

package cli

import "github.com/spf13/cobra"

func RootCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use:          "anocir",
	SilenceUsage: true,
    }

    return cmd
}

The root command will serve as the entrypoint when a user runs the anocir command from the terminal and will be responsible for delegating to subcommands, such as when a user runs anocir create --bundle busybox mycontainer.

From the main package in main.go, import the cli package and execute the root command, handling any error by printing to the console and exiting with exit code 1.

package main

import (
    "fmt"
    "os"

    "github.com/nixpig/anocir/internal/cli"
)

func main() {
    if err := cli.RootCmd().Execute(); err != nil {
	fmt.Println(err)
	os.Exit(1)
    }
}

We now need to create a subcommand for each of the operations we previously defined.

Query State subcommand

anocir state mycontainer

package cli

import (
    "github.com/spf13/cobra"
    "github.com/nixpig/anocir/internal/operations"
)

func stateCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use: "state [flags] CONTAINER_ID",
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
	    containerID := args[0]

	    state, err := operations.State(&operations.StateOpts{
		ID: containerID,
	    })
	    if err != nil {
		return err
	    }

	    // TODO: do something with 'state'
	    fmt.Println(state)

	    return nil
	},
    }

    return cmd
}

Create subcommand

anocir create --bundle busybox mycontainer

anocir create mycontainer

package cli

import (
    "os"

    "github.com/spf13/cobra"
    "github.com/nixpig/anocir/internal/operations"
)

func createCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use: "create [flags] CONTAINER_ID",
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
	    containerID := args[0]

	    bundle, err := cmd.Flags().GetString("bundle")
	    if err != nil {
		return err
	    }

	    return operations.Create(&operations.CreateOpts{
		ID: containerID,
		Bundle: bundle,
	    })
	},
    }

    cwd, _ := os.Getwd()
    cmd.Flags().StringP("bundle", "b", cwd, "Path to bundle directory")

    return cmd
}

Start subcommand

anocir start mycontainer

package cli

import (
    "github.com/spf13/cobra"
    "github.com/nixpig/anocir/internal/operations"
)

func startCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use: "start [flags] CONTAINER_ID",
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
	    containerID := args[0]

	    return operations.Start(&operations.StartOpts{
		ID: containerID,
	    })
	},
    }

    return cmd
}

Kill subcommand

anocir kill mycontainer 9

package cli

import (
    "github.com/spf13/cobra"
    "github.com/nixpig/anocir/internal/operations"
)

func killCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use: "kill [flags] CONTAINER_ID SIGNAL",
	Args: cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
	    containerID := args[0]
	    signal := args[1]

	    return operations.Kill(&operations.KillOpts{
		ID: containerID,
		Signal: signal,
	    })
	},
    }

    return cmd
}

Delete subcommand

anocir delete mycontainer

package cli

import (
    "github.com/spf13/cobra"
    "github.com/nixpig/anocir/internal/operations"
)

func deleteCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use: "delete [flags] CONTAINER_ID",
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
	    containerID := args[0]

	    return operations.Delete(&operations.DeleteOpts{
		ID: containerID,
	    })
	},
    }

    return cmd
}

Updating the root command

With all of the subcommands defined, we can now add them to the root command.

package cli

import "github.com/spf13/cobra"

func RootCmd() *cobra.Command {
    cmd := &cobra.Command{
	Use:          "anocir",
	SilenceUsage: true,
    }

    cmd.AddCommand(
	stateCmd(),
	createCmd(),
	startCmd(),
	deleteCmd(),
	killCmd(),
    )

    return cmd
}

With the CLI and operations all wired up, let’s try compiling the app and running a few commands to ensure that the operation handlers get called with the correct arguments (which will be printed to screen).

Build the app

go build -o anocir

Create

Execute the create command, specifying the bundle directory as busybox and the container ID as mycontainer.

./anocir create --bundle busybox mycontainer

We should see the values passed to the create operation handler in the CreateOpts struct printed out.

&{mycontainer busybox}

State

Execute the state command, specifying the container ID as mycontainer.

./anocir state mycontainer

We should see the values pass to the state operation handler in the StateOpts struct printed out.

&{mycontainer}

Start

Execute the start command, specifying the container ID as mycontainer.

./anocir start mycontainer

We should see the values pass to the start operation handler in the StartOpts struct printed out.

&{mycontainer}

Kill

Execute the kill command, specifying the container ID as mycontainer and the signal as 9.

./anocir kill mycontainer 9

We should see the values pass to the kill operation handler in the KillOpts struct printed out.

&{mycontainer 9}

Delete

Execute the delete command, specifying the container ID as mycontainer.

./anocir delete mycontainer

We should see the values pass to the delete operation handler in the DeleteOpts struct printed out.

&{mycontainer}

All seems to be wired up correctly! ๐ŸŽ‰


Next, let’s start implementing some features…

Part 2: Reading the bundle config and saving the container state ยป

References

Enjoyed this article? Consider buying me a coffee.