From b8a8db8e96c6f6f8c7d1c1bcf945572412ff13b1 Mon Sep 17 00:00:00 2001 From: Maximilian Bosch <mb@flyingcircus.io> Date: Mon, 7 Oct 2024 12:31:31 +0200 Subject: [PATCH 1/2] batou_ext.oci: Add support for `podman` FC-37959 We mostly want this for healthchecks that must pass before the unit is actually active. This is kept intentionally simple, only healthchecks via sd_notify are supported. Mixing containers in systemd units with `healthy` and `conmon` strategy leads to setups with conflicting requirements (linger vs no linger) and it's far too easy to end up with an unsupported configuration (and containers not behaving properly). --- .../20241129_131748_mb_FC_37959_podman.md | 9 ++ src/batou_ext/oci.py | 111 ++++++++++++++++-- src/batou_ext/resources/oci-template.nix | 32 ++++- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 CHANGES.d/20241129_131748_mb_FC_37959_podman.md diff --git a/CHANGES.d/20241129_131748_mb_FC_37959_podman.md b/CHANGES.d/20241129_131748_mb_FC_37959_podman.md new file mode 100644 index 0000000..08ae902 --- /dev/null +++ b/CHANGES.d/20241129_131748_mb_FC_37959_podman.md @@ -0,0 +1,9 @@ +- `batou_ext.oci.Container`: allow to use `podman` as backend instead of `docker`. + This also enables the following features: + + * Rootless containers: by setting the `user` option to a different user. By default, + the service user of the deployment is used. + + * Only mark services as `active` if the container is up. This requires that the + container has a healthcheck. Alternatively, a healthcheck can be configured + with the health_cmd attribute. diff --git a/src/batou_ext/oci.py b/src/batou_ext/oci.py index 6cef240..da06bdc 100644 --- a/src/batou_ext/oci.py +++ b/src/batou_ext/oci.py @@ -1,3 +1,4 @@ +import json import os import shlex from textwrap import dedent @@ -12,11 +13,38 @@ import batou_ext.nix +class PodmanRuntime(Component): + """ + Marker to indicate that containers are running with podman instead of + docker. + + The backend can only be set globally in NixOS, so this is done with a single, + global component here. + """ + + def configure(self): + self.provide("oci:podman", self) + self += File( + "/etc/local/nixos/oci-backend.nix", + content=dedent( + """\ + { + virtualisation.podman.enable = true; + } + """ + ), + ) + + class Container(Component): """A OCI Container component. - With this component you can dynamically schedule docker containers to be - run on the target host. + With this component you can dynamically schedule docker or podman containers + to be run on the target host. + + Note: the `podman` backend is still considered experimental and thus subject + to change. Running both docker and podman containers on the same VM is currently + not supported. Note: Docker image specifiers do not follow a properly resolvable pattern. Therefore, container registries have to be specified seperately if you need @@ -70,10 +98,43 @@ class Container(Component): self += Rebuild() self += container.activate() + ``` + Containers can use `podman` as backend by adding the `PodmanRuntime` + component: + ``` + self += batou_ext.oci.PodmanRuntime() + self += batou_ext.oci.Container( + image="mysql" + ) + ``` + + This assumes that the container has a healthcheck configured. + The unit `podman-mysql` will remain in state `activating` until + the container is in `healthy` state. Then, it's transitioned into + `active` state. + + The `batou_ext.nix.Rebuild` component will wait until newly started + and restarted units are `active`, i.e. it will wait until the container is + up with `podman`. + + If a podman container doesn't have a healthcheck defined, it's possible to add + one via this component. The command is passed `/bin/sh -c`: + ``` + self += batou_ext.oci.PodmanRuntime() + self += batou_ext.oci.Container( + image="without-healthcheck", + health_cmd="curl --fail localhost || exit 1" + ) + ``` + Please note that the healthcheck is executed _inside_ the container, so + the container above would require a `curl` installed. + When using podman containers, the user running the container + has lingering enabled, i.e. a long-running user session is started by + logind (https://www.freedesktop.org/software/systemd/man/latest/loginctl.html#enable-linger%20USER%E2%80%A6). """ # general options @@ -81,6 +142,9 @@ class Container(Component): version: str = "latest" container_name = Attribute(str) + health_cmd = Attribute(str, None) + user = Attribute(str, None) + # Set up monitoring monitor: bool = True @@ -95,7 +159,7 @@ class Container(Component): ports: dict = {} env: dict = {} depends_on: list = None - extra_options: list = None + extra_options: list = [] # secrets registry_address = Attribute(Optional[str], None) @@ -108,6 +172,12 @@ class Container(Component): } def configure(self): + self.backend = ( + "podman" + if self.require("oci:podman", strict=False, host=self.host) + else "docker" + ) + if ( self.registry_user or self.registry_password ) and not self.registry_address: @@ -145,6 +215,21 @@ def configure(self): if self.docker_cmd: self._docker_cmd_list = shlex.split(self.docker_cmd) + if self.backend != "podman": + for prop in ["user", "health_cmd"]: + assert ( + getattr(self, prop) is None + ), f"Container '{self.container_name}' runs with Docker, so the '{prop}' option is not supported!" + else: + if self.health_cmd is not None: + # `json.dumps` quotes the quoted string in a way that it can + # be placed as valid string into a Nix expression. + # The bash-quoting is done in the OCI module in NixOS. + self.health_cmd = json.dumps(self.health_cmd) + + if self.user is None: + self.user = self.host.service_user + if not self.depends_on: self.depends_on = [] @@ -220,7 +305,11 @@ def verify(self): # query the local digest to compare against upstream local_digest = self._get_local_digest() - image_ident = f"{container.image}:{container.version}@{local_digest}" + image_ident = ( + f"{container.image}:{container.version}@{local_digest}" + if self.container.backend == "docker" + else f"{container.image}@{local_digest}" + ) # test whether the ident has been checked already if image_ident in self._remote_manifest_cache: @@ -243,7 +332,7 @@ def verify(self): def update(self): if self._need_explicit_restart: self.cmd( - f"sudo systemctl restart docker-{self.container.container_name}" + f"sudo systemctl restart {self.container.backend}-{self.container.container_name}" ) def _get_running_container_image_id(self): @@ -254,7 +343,7 @@ def _get_running_container_image_id(self): image_id, stderr = self.cmd( dedent( """\ - docker container inspect {{component.container.container_name}} \ + {{ component.container.backend }} container inspect {{component.container.container_name}} \ | jq -r '.[0].Image' """ ) @@ -269,7 +358,7 @@ def _get_local_image_id(self): local_image_id, stderr = self.cmd( dedent( """\ - docker image inspect {{component.container.image}}:{{component.container.version}} \ + {{ component.container.backend }} image inspect {{component.container.image}}:{{component.container.version}} \ | jq -r '.[0].Id' \ || echo image not available locally """ @@ -281,7 +370,7 @@ def _get_local_digest(self): local_digest, stderr = self.cmd( dedent( """\ - docker image inspect {{component.container.image}}:{{component.container.version}} \ + {{ component.container.backend }} image inspect {{component.container.image}}:{{component.container.version}} \ | jq -r 'first | .RepoDigests | first | split("@") | last' \ || echo image not available locally """ @@ -294,7 +383,7 @@ def _docker_login(self): self.expand( dedent( """\ - docker login \\ + {{ component.container.backend }} login \\ {%- if component.container.registry_user and component.container.registry_password %} -u {{component.container.registry_user}} \\ -p {{component.container.registry_password}} \\ @@ -309,7 +398,9 @@ def _validate_remote_image(self, image_ident): try: # check if the digest aligns with the remote image # if it does not, this command will throw an error - stdout, stderr = self.cmd(f"docker manifest inspect {image_ident}") + stdout, stderr = self.cmd( + f"{self.container.backend} manifest inspect {image_ident}" + ) except CmdExecutionError as e: error = e.stderr if error.startswith("unsupported manifest format"): # gitlab diff --git a/src/batou_ext/resources/oci-template.nix b/src/batou_ext/resources/oci-template.nix index b5c21c7..9622d88 100644 --- a/src/batou_ext/resources/oci-template.nix +++ b/src/batou_ext/resources/oci-template.nix @@ -2,13 +2,13 @@ { # {% if component.monitor %} flyingcircus = { - services.sensu-client.checks."docker-{{component.container_name}}" = { + services.sensu-client.checks."{{ component.backend }}-{{component.container_name}}" = { notification = "Status of container {{component.container_name}}"; command = '' - if $(systemctl is-active --quiet docker-{{component.container_name}}); then - echo "docker container {{component.container_name}} is ok" + if $(systemctl is-active --quiet {{ component.backend }}-{{component.container_name}}); then + echo "{{ component.backend }} container {{component.container_name}} is ok" else - echo "docker container {{component.container_name}} is inactive" + echo "{{ component.backend }} container {{component.container_name}} is inactive" exit 2 fi ''; @@ -17,7 +17,6 @@ # {% endif %} virtualisation.oci-containers = { - backend = "docker"; containers."{{component.container_name}}" = { # {% if component.entrypoint %} entrypoint = "{{component.entrypoint}}"; @@ -40,9 +39,20 @@ extraOptions = [ "--pull=always" + # {% if component.backend == "podman" %} + "--cgroups=enabled" + "--cidfile=/run/{{component.container_name}}/ctr-id" + # {% endif %} # {% for option in (component.extra_options or []) %} "{{option}}" # {% endfor %} + # {% if component.backend == "podman" %} + "--sdnotify=healthy" + # {% endif %} + # {% if component.health_cmd %} + "--health-cmd" + {{ component.health_cmd }} + # {% endif %} ]; volumes = [ @@ -68,4 +78,16 @@ ]; }; }; + + # {% if component.backend == "podman" %} + systemd.services."podman-{{ component.container_name }}".serviceConfig = { + User = "{{ component.user }}"; + RuntimeDirectory = "{{component.container_name}}"; + Delegate = true; + NotifyAccess = "all"; + }; + + systemd.services."podman-{{ component.container_name }}".wants = [ "linger-users.service" ]; + users.users."{{ component.user }}".linger = true; + # {% endif %} } From c9beca6cc62641b8836d4a07af22ffd7d5fabf18 Mon Sep 17 00:00:00 2001 From: Maximilian Bosch <mb@flyingcircus.io> Date: Wed, 5 Feb 2025 15:16:51 +0100 Subject: [PATCH 2/2] batou_ext.http.HTTPServiceWatchdog: make rebuild optional --- src/batou_ext/http.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/batou_ext/http.py b/src/batou_ext/http.py index 87ce7b2..bdad592 100644 --- a/src/batou_ext/http.py +++ b/src/batou_ext/http.py @@ -61,7 +61,6 @@ def _deploy_customer_http_auth_file(self): self.path = self._.path -@batou_ext.nix.rebuild class HTTPServiceWatchdog(batou.component.Component): """ Adds a "watchdog" on top of a systemd service that checks within a given interval @@ -209,6 +208,8 @@ class HTTPServiceWatchdog(batou.component.Component): start_timeout = batou.component.Attribute(int, default=64) watchdog_interval = batou.component.Attribute(int, default=64) + rebuild = batou.component.Attribute("literal", default=True) + def configure(self): if self.predefined_service and self.script is not None: raise ValueError( @@ -226,6 +227,9 @@ def configure(self): ), ) + if self.rebuild: + self += batou_ext.nix.Rebuild() + class HTTPWatchdogScript(batou.component.Component): """