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 not stopped 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

Enjoyed this article? Consider buying me a coffee.