Building Carthage with Carthage
Feb. 9th, 2023 01:42 pmThis is the second in a series of blog posts introducing Carthage, an Infrastructure as Code framework I’ve been working on the last four years. In this post we’ll talk about how we use Carthage to build the Carthage container images. We absolutely could have just used a Containerfile to do this; in fact I recently removed a hybrid solution that produced an artifact and then used a Containerfile to turn it into an OCI image. The biggest reason we don’t use a Containerfile is that we want to be able to reuse the same infrastructure (installed software and configuration) across multiple environments. For example CarthageServerRole, a reusable Carthage component that install Carthage itself is used in several places:
- on raw hardware when we’re using Carthage to drive a hypervisor
- As part of image building pipelines to build AMIs for Amazon Web Services
- Installed onto AWS instances built from the Debian AMI where we cannot use custom AMIs
- Installed onto KVM VMs
- As part of building the Carthage container images
So the biggest thing Carthage gives us is uniformity in how we set up infrastructure. We’ve found a number of disadvantages of Containerfiles as well:
Containerfiles mix the disadvantages of imperative and declarative formats. Like a declarative format they have no explicit control logic. It seems like that would be good for introspecting and reasoning about Containers. But all you get is the base image and a set of commands to build a container. For reasoning about common things like whether a container has a particular vulnerability or can be distributed under a particular license, that’s not very useful. So we don’t get much valuable introspection out of the declarative aspects, and all too often we see Containerfiles generated by Makefiles or other multi-level build-systems to get more logic or control flow.
Containerfiles have limited facility for doing things outside the container. The disadvantage of this is that you end up installing all the software you need to build the container into the container itself (or having a multi-level build system). But for example if I want to use Ansible to configure a container, the easiest way to do that is to actually install Ansible into the container itself, even though Ansible has a large dependency chain most of which we won’t need in the container. Yes, Ansible does have a number of connection methods including one for Buildah, but by the point you’re using that, you’re already using a multi-level build system and aren’t really just using a Containerfile.
Okay, so since we’re not going to just use a Containerfile, what do we do instead? We produce a CarthageLayout. A CarthageLayout is an object in the Carthage modeling language. The modeling language looks a lot like Python—in fact it’s even implemented using Python metaclasses and uses the Python parser. However, there are some key semantic differences and it may help to think of the modeling language as its own thing. Carthage layouts are typically contained in Carthage plugins. For example, the oci_images plugin is our focus today. Most of the work in that plugin is in layout.py, and the layout begins here:
class layout(CarthageLayout):
add_provider(ConfigLayout)
add_provider(carthage.ansible.ansible_log, str(_dir/"ansible.log"))The add_provider calls are special, and we’ll discuss them in a future post. For now, think of them as assignments in a more complex namespace than simple identifiers. But the heart of this layout is the CarthageImage class:
class CarthageImage(PodmanImageModel, carthage_base.CarthageServerRole):
base_image = injector_access('from_scratch_debian')
oci_image_tag = 'localhost/carthage:latest'
oci_image_command = ['/bin/systemd']Most of the work of our image is done by inheritance. We inherit from
the CarthageServerRole from the carthage_base plugin collection. A role is a
reusable set of infrastructure that can be attached directly to a MachineModel.
By inheriting from this role, we request the installation of the
Carthage software. The role also supports copying in various
dependencies; for example when Carthage is used to manage a cluster of
machines, the layout corresponding to the cluster can automatically be
copied to all nodes in the cluster. We do not need this feature to build
the container image. The CarthageImage class sets its base
image. Currently we are using our own base Debian image that we build
with debootstrap and then import as a container image. In
the fairly near future, we’ll change that to:
base_image = ‘debian:bookworm’That will simply use the Debian image from Dockerhub. We are building our own base image for historical reasons and need to confirm that everything works before switching over. By setting oci_image_tag we specify where in the local images the resulting image will be stored. We also specify that this image boots systemd. We actually do want to do a bit of work on top of CarthageServerRole specific to the container image. To do that we use a Carthage feature called a Customization. There are various types of customization. For example MachineCustomization runs a set of tasks on a Machine that is booted and on the network. When building images, the most common type of customization is a FilesystemCustomization. For these, we have access to the filesystem, and we have some way of running a command in the context of the filesystem. We don’t boot the filesystem as a machine unless we need to. (We might if the filesystem is a kvm VM or AWS instance for example). Carthage collects all the customizations in a role or image model. In the case of container image classes like PodmanImageModel, each customization is applied as an individual layer in the resulting container image.
Roles and customizations are both reusable infrastructure. Roles typically contain customizations. Roles operate at the modeling layer; you might introspect a machine’s model or an image’s model to see what functionality (roles) it provides. In contrast, customizations operate at the implementation layer. They do specific things like move files around, apply Ansible roles or similar.
Let’s take a look at the customization applied for the Carthage container image (full code):
class customize_for_oci(FilesystemCustomization):
@setup_task("Remove Software")
async def remove_software(self):
await self.run_command("apt", "-y", "purge",
"exim4-base",
)
@setup_task("Install service")
async def install_service(self):
# installs and activates a systemd unitThen to pull it all together, we simply run the layout:
sudo PYTHONPATH=$(pwd) python3 ./bin/carthage-runner ./oci_images build
In the next post, we will dig more into how to make infrastructure reusable.