Loading a container, getting it's state, and deleting it
This is Part 3 of the series Building a container runtime from scratch in Go.
In the third part of the series we implement the loading of a previously created container, retrieval of its current state and finally, deleting it.
The source code to accompany this post is available on GitHub.
We’re now able to create container objects and persist them to disk. But, we need a way to load them, query their state, and (given we’re going to be iterating quite frequently) easily delete them.
Loading a container
Before we can get the state of a container, we need to load it. So, we create a Load
function in the container
package that takes in the ID of a container to load and returns the corresponding container.
func Load(id string) (*Container, error) {
s, err := os.ReadFile(
filepath.Join(containerRootDir, id, "state.json"),
)
if err != nil {
return nil, fmt.Errorf("read state file: %w", err)
}
var state *specs.State
if err := json.Unmarshal(s, &state); err != nil {
return nil, fmt.Errorf("unmarshal state: %w", err)
}
config, err := os.ReadFile(
filepath.Join(state.Bundle, "config.json"),
)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
var spec *specs.Spec
if err := json.Unmarshal(config, &spec); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
c := &Container{
State: state,
Spec: spec,
}
return c, nil
}
First, we read the container’s state.json
file from disk and unmarshal it into a specs.State
struct.
Then, we read the bundle’s config.json
file from disk and unmarshal it into a specs.Spec
struct.
Finally, we create a Container
struct with the state and spec, then return it.
Getting the container state
Now that we’re able to load a container, we can implement the state
operation to retrieve the container’s state.
func State(opts *StateOpts) (string, error) {
cntr, err := container.Load(opts.ID)
if err != nil {
return "", fmt.Errorf("load container: %w", err)
}
state, err := json.Marshal(cntr.State)
if err != nil {
return "", fmt.Errorf("marshal state: %w", err)
}
return string(state), nil
}
We simply load the container using the Load
function we just created, then marshal the container’s state to JSON and return it as a string.
Printing the container state
We could leave the Cobra command for state
as it is and let the fmt.Println(state)
line handle printing the state out. However, we’ll see later why this would be problematic. For now, let’s replace the fmt.Println(state)
with the code below to write the state to cmd.OutOrStdout()
.
// ...
if _, err := cmd.OutOrStdout().Write(
[]byte(state),
); err != nil {
return fmt.Errorf("write state to stdout: %w", err)
}
// ...
We’ll see later exactly why it’s necessary to do this.
Deleting the container
We’ve (probably) run create
a couple of times now, and those resources in /var/lib/anocir/containers
not going to clean themselves up. So, let’s look at implementing the delete
operation.
As defined in the spec1, the delete
operation must only delete a container if it is in the stopped
state.
Attempting to
delete
a container that is notstopped
MUST have no effect on the container and MUST generate an error.
Since we haven’t got to the point of actually running a container, our containers will never be in that state - they’re always in creating
state. In order to get around this restriction, we’re going to do the same as runc and other runtimes do and add a --force
flag. This will enable us to delete a container regardless of it’s state.
Before doing that, let’s first add a helper function to check whether the container can be deleted.
func (c *Container) canBeDeleted() bool {
return c.State.Status == specs.StateStopped
}
The logic is simply checking whether the current state of the container is stopped
and returning a boolean to indicate whether it is (true
) or isn’t (false
) stopped.
Next, let’s implement the actual deletion of the container.
func (c *Container) Delete(force bool) error {
if !force && !c.canBeDeleted() {
return fmt.Errorf("container cannot be deleted in current state (%s) try using '--force'", c.State.Status)
}
if err := os.RemoveAll(
filepath.Join(containerRootDir, c.State.ID),
); err != nil {
return fmt.Errorf("delete container directory: %w", err)
}
return nil
}
First, we check if the container can be deleted and whether the force
argument is true
. If the container can’t be deleted and the force
argument is false
, then we return an error.
If the container can be deleted or the force
argument is true
, then we go ahead and delete the container directory.
It’s worth noting that this is only a part of the logic needed to actually delete a container. Once we actually have running containers, we’ll need to come back and add some additional functionality in here.
We can now update our delete
operation handler to delete the container.
type DeleteOpts struct {
ID string
Force bool
}
func Delete(opts *DeleteOpts) error {
cntr, err := container.Load(opts.ID)
if err != nil {
return fmt.Errorf("load container: %w", err)
}
if err := cntr.Delete(opts.Force); err != nil {
return fmt.Errorf("delete container: %w", err)
}
return nil
}
The DeleteOpts
struct is updated to include a Force
field and the Delete
function is updated to load then delete the container.
Finally, we need to update our delete
command to add a force
flag and pass that to the delete
operation handler.
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]
force, err := cmd.Flags().GetBool("force")
if err != nil {
return err
}
return operations.Delete(&operations.DeleteOpts{
ID: containerID,
Force: force,
})
},
}
cmd.Flags().BoolP("force", "f", false, "Delete container regardless of state")
return cmd
}
That should be everything hooked up to load a container, get it’s state, and delete it. Let’s try it out.
./anocir create --bundle alpinefs test1
./anocir create --bundle alpinefs test2
./anocir state test1
{"ociVersion":"1.2.0","id":"test1","status":"creating","bundle":"/home/nixpig/projects/alpinefs"}
./anocir state test2 | jq
{
"ociVersion": "1.2.0",
"id": "test2",
"status": "creating",
"bundle": "/home/nixpig/projects/alpinefs"
}
./anocir state test3
Error: load container: read state file: open /var/lib/anocir/containers/test3/state.json: no such file or directory
./anocir delete test1
Error: delete container: container cannot be deleted in current state (creating) try using '--force'
./anocir delete --force test1
./anocir delete --force test2
With a couple of the core commands now hooked up, we’re able to get to the ‘fun stuff’. In the next part of the series we’re going to initialise and start a bare-bones container in a forked process.
Part 4: Initialising and starting a container ยป
References
-
https://github.com/opencontainers/runtime-spec/blob/main/runtime.md#delete “Delete operation” ↩︎