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)
- The
anocir create --bundle busybox mycontainer
command in read in from stdin. - The
anocir
root command will delegate to thecreate
subcommand. - The
create
subcommand will parse thebusybox
value from the--bundle
flag and themycontainer
container ID argument then call thecreate
handler. - The
create
handler will perform the necessary tasks to handle the create operation - creating the container with the IDmycontainer
from thebusybox
bundle.
Scaffolding out the project
Let’s initialise a new project.
- Create a directory for the project:
mkdir anocir
- Change into the directory:
cd anocir
- Initialise:
go mod init github.com/nixpig/anocir
- Create the
main.go
file and add themain
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
-
https://github.com/opencontainers/runtime-spec/blob/main/runtime.md#operations “OCI Runtime operations” ↩︎ ↩︎