2025-07-22 13:00:41 +02:00
|
|
|
#!/usr/bin/env python3
|
2025-07-26 19:04:27 +02:00
|
|
|
|
|
|
|
# SPDX-FileCopyrightText: (C) 2025 Enno Tensing <tenno+containerctl@suij.in>
|
|
|
|
#
|
|
|
|
# SPDX-License-Identifier: GPL-2.0-only
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
"""Util module holding Container and related objects."""
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from typing import Self, TypeAlias
|
|
|
|
|
|
|
|
from log import Log
|
|
|
|
|
|
|
|
ConfigValue: TypeAlias = str | dict | list | bool | None
|
|
|
|
|
|
|
|
|
|
|
|
class ConfigError(Exception):
|
|
|
|
"""An invalid config."""
|
|
|
|
|
|
|
|
message: str
|
|
|
|
|
|
|
|
def __init__(self, message: str = "") -> None:
|
|
|
|
"""Create Exception object."""
|
|
|
|
self.message = message
|
2025-07-22 16:15:47 +02:00
|
|
|
super().__init__(message)
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
"""Convert Exception object to a string."""
|
|
|
|
return f"Configuration error: {self.message}"
|
|
|
|
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
def maybe(json: dict, key: str) -> ConfigValue:
|
2025-07-22 13:00:41 +02:00
|
|
|
"""Maybe get a value."""
|
|
|
|
try:
|
|
|
|
return json[key]
|
|
|
|
except KeyError:
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
def maybe_or(json: dict, key: str, _or: ConfigValue) -> ConfigValue:
|
|
|
|
"""Maybe get a value, but return _or if it is None."""
|
|
|
|
val = maybe(json, key)
|
2025-07-30 10:32:15 +02:00
|
|
|
if val is None or not isinstance(val, type(_or)):
|
2025-07-30 10:24:51 +02:00
|
|
|
return _or
|
|
|
|
return val
|
|
|
|
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
def trim(s: str) -> str:
|
|
|
|
"""Remove sequential whitespace."""
|
|
|
|
s = s.replace("\t ", "\t")
|
|
|
|
s = s.replace("\t\\\n", " ")
|
2025-07-22 16:15:47 +02:00
|
|
|
while " " in s:
|
2025-07-22 13:00:41 +02:00
|
|
|
s = s.replace(" ", " ")
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
@dataclass
|
|
|
|
class Cgroup:
|
|
|
|
"""cgroup Config."""
|
|
|
|
|
|
|
|
config: list
|
|
|
|
parent: str
|
|
|
|
namespace: str
|
|
|
|
how: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
2025-07-30 10:51:33 +02:00
|
|
|
if val is None:
|
|
|
|
return cls([], "", "", "")
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("cgroup Config is invalid!")
|
|
|
|
return cls([], "", "", "")
|
|
|
|
|
|
|
|
config = maybe_or(val, "config", [])
|
|
|
|
parent = maybe_or(val, "parent", "")
|
|
|
|
namespace = maybe_or(val, "namespace", "")
|
|
|
|
how = maybe_or(val, "how", "")
|
|
|
|
|
|
|
|
if how == "split" and parent != "":
|
|
|
|
logger.log_warning(
|
|
|
|
"Split cgroups can not be combined with a cgroup parent!"
|
|
|
|
)
|
|
|
|
parent = ""
|
|
|
|
|
|
|
|
return cls(config, parent, namespace, how)
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
cmd = ""
|
|
|
|
seperator = " \\\n"
|
|
|
|
cmd += seperator.join(
|
2025-07-30 10:34:13 +02:00
|
|
|
[f"\t--cgroup-conf={option}{seperator}" for option in self.config]
|
2025-07-30 10:24:51 +02:00
|
|
|
)
|
|
|
|
if self.parent != "":
|
|
|
|
cmd += f"\t--cgroup-parent={self.parent}{seperator}"
|
|
|
|
if self.namespace != "":
|
|
|
|
cmd += f"\t--cgroupns={self.namespace}{seperator}"
|
|
|
|
if self.how != "":
|
|
|
|
cmd += f"\t--cgroups={self.how}{seperator}"
|
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Cpu:
|
|
|
|
"""CPU Accounting."""
|
|
|
|
|
|
|
|
period: str
|
|
|
|
quota: str
|
|
|
|
shares: str
|
|
|
|
number: str
|
|
|
|
cpuset_cpus: str
|
|
|
|
cpuset_mems: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
2025-07-30 10:51:33 +02:00
|
|
|
if val is None:
|
|
|
|
return cls("", "", "", "", "", "")
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("cpu Config is invalid!")
|
|
|
|
return cls("", "", "", "", "", "")
|
|
|
|
|
|
|
|
period = maybe_or(val, "period", "")
|
|
|
|
quota = maybe_or(val, "quota", "")
|
|
|
|
shares = maybe_or(val, "shares", "")
|
|
|
|
number = maybe_or(val, "number", "")
|
|
|
|
cpuset = maybe_or(val, "cpuset", {})
|
|
|
|
cpuset_cpus = ""
|
|
|
|
cpuset_mems = ""
|
|
|
|
if len(cpuset) != 0:
|
|
|
|
cpuset_cpus += maybe_or(cpuset, "cpus", "")
|
|
|
|
cpuset_mems += maybe_or(cpuset, "mems", "")
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
period,
|
|
|
|
quota,
|
|
|
|
shares,
|
|
|
|
number,
|
|
|
|
cpuset_cpus,
|
|
|
|
cpuset_mems,
|
|
|
|
)
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
cmd = ""
|
|
|
|
seperator = " \\\n"
|
|
|
|
if self.period != "":
|
|
|
|
cmd += f"\t--cpu-period={self.period}{seperator}"
|
|
|
|
if self.quota != "":
|
|
|
|
cmd += f"\t--cpu-quota={self.quota}{seperator}"
|
|
|
|
if self.shares != "":
|
|
|
|
cmd += f"\t--cpu-shares={self.shares}{seperator}"
|
|
|
|
if self.number != "":
|
|
|
|
cmd += f"\t--cpus={self.number}{seperator}"
|
|
|
|
if self.cpuset_cpus != "":
|
|
|
|
cmd += f"\t--cpuset-cpus={self.cpuset_cpus}{seperator}"
|
|
|
|
if self.cpuset_mems != "":
|
|
|
|
cmd += f"\t--cpuset-mems={self.cpuset_mems}{seperator}"
|
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
2025-07-30 11:23:30 +02:00
|
|
|
@dataclass
|
|
|
|
class Memory:
|
|
|
|
"""Memory accounting."""
|
|
|
|
|
|
|
|
limit: str
|
|
|
|
reservation: str
|
|
|
|
swap: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None:
|
|
|
|
return cls("", "", "")
|
|
|
|
|
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("memory Config is invalid!")
|
|
|
|
return cls("", "", "")
|
|
|
|
|
|
|
|
limit = maybe_or(val, "limit", "")
|
|
|
|
reservation = maybe_or(val, "reservation", "")
|
|
|
|
swap = maybe_or(val, "swap", "")
|
|
|
|
if limit == "":
|
|
|
|
return cls("", "", "")
|
|
|
|
return cls(limit, reservation, swap)
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
if self.limit == "":
|
|
|
|
return ""
|
|
|
|
cmd = ""
|
|
|
|
seperator = " \\\n"
|
|
|
|
|
|
|
|
cmd += f"\t--memory={self.limit}{seperator}"
|
|
|
|
if self.reservation != "":
|
|
|
|
cmd += f"\t--memory-reservation={self.reservation}{seperator}"
|
|
|
|
if self.swap != "":
|
|
|
|
cmd += f"\t--memory-swap={self.swap}{seperator}"
|
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
@dataclass
|
|
|
|
class Accounting:
|
|
|
|
"""Resource Accounting."""
|
|
|
|
|
|
|
|
cgroup: Cgroup
|
|
|
|
cpu: Cpu
|
2025-07-30 11:23:30 +02:00
|
|
|
memory: Memory
|
2025-07-30 10:24:51 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, data: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
2025-07-30 10:51:33 +02:00
|
|
|
if data is None:
|
|
|
|
return cls(
|
2025-07-30 11:23:30 +02:00
|
|
|
Cgroup.from_json(None, logger),
|
|
|
|
Cpu.from_json(None, logger),
|
|
|
|
Memory.from_json(None, logger),
|
2025-07-30 10:51:33 +02:00
|
|
|
)
|
|
|
|
|
2025-07-30 10:24:51 +02:00
|
|
|
cgroup_data = maybe(data, "cgroup")
|
|
|
|
cpu_data = maybe(data, "cpu")
|
2025-07-30 11:23:30 +02:00
|
|
|
memory_data = maybe(data, "memory")
|
2025-07-30 10:24:51 +02:00
|
|
|
cgroup = Cgroup.from_json(cgroup_data, logger)
|
|
|
|
cpu = Cpu.from_json(cpu_data, logger)
|
2025-07-30 11:23:30 +02:00
|
|
|
memory = Memory.from_json(memory_data, logger)
|
|
|
|
return cls(cgroup, cpu, memory)
|
2025-07-30 10:24:51 +02:00
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Options for podman container create."""
|
|
|
|
cgroup = self.cgroup.command()
|
|
|
|
cpu = self.cpu.command()
|
2025-07-30 11:23:30 +02:00
|
|
|
memory = self.memory.command()
|
|
|
|
return cgroup + cpu + memory
|
2025-07-30 10:24:51 +02:00
|
|
|
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
@dataclass
|
|
|
|
class Volume:
|
|
|
|
"""Container Volume."""
|
|
|
|
|
|
|
|
name: str
|
|
|
|
path: str
|
|
|
|
|
|
|
|
@classmethod
|
2025-08-04 20:45:52 +02:00
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> list:
|
2025-07-22 13:00:41 +02:00
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None:
|
2025-08-04 20:45:52 +02:00
|
|
|
return []
|
2025-07-22 13:00:41 +02:00
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("Volume key is present, but malformed.")
|
2025-08-04 20:45:52 +02:00
|
|
|
return []
|
2025-07-22 13:00:41 +02:00
|
|
|
return [cls(key, value) for key, value in val.items()]
|
|
|
|
|
2025-07-29 10:03:14 +02:00
|
|
|
def is_host_volume(self) -> bool:
|
|
|
|
"""Check if this Volume is a named or a host volume."""
|
|
|
|
return self.name.startswith("/") or self.name.startswith(".")
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
return f"--volume {self.name}:{self.path}"
|
|
|
|
|
|
|
|
def create(self) -> str:
|
|
|
|
"""Create volume, if it does not exist."""
|
2025-07-29 10:03:14 +02:00
|
|
|
if self.is_host_volume():
|
|
|
|
# A HOST-DIR starting with a slash or dot is interpreted as a
|
|
|
|
# directory or file on the host machine. Since the script should
|
|
|
|
# not modify things outside its own tree, i.e.
|
|
|
|
# /var/lib/containerctl, ignore it.
|
|
|
|
return ""
|
2025-07-22 13:00:41 +02:00
|
|
|
cmd = f"# Create volume {self.name}\n"
|
|
|
|
cmd += f"if ! podman volume exists '{self.name}' 2> /dev/null\n"
|
|
|
|
cmd += "then\n"
|
2025-07-29 09:42:22 +02:00
|
|
|
cmd += f"\tpodman volume create '{self.name}'\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
cmd += "fi\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def remove(self) -> str:
|
|
|
|
"""Remove volume if it exists."""
|
2025-07-29 10:03:14 +02:00
|
|
|
if self.is_host_volume():
|
|
|
|
# As with create, don't touch the host system
|
|
|
|
return ""
|
2025-07-22 13:00:41 +02:00
|
|
|
cmd = f"# Remove volume {self.name}\n"
|
|
|
|
cmd += f"if podman volume exists '{self.name}' 2> /dev/null\n"
|
|
|
|
cmd += "then\n"
|
|
|
|
cmd += f"\tpodman volume rm '{self.name}'\n"
|
|
|
|
cmd += "fi\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Secret:
|
|
|
|
"""Container Secret."""
|
|
|
|
|
|
|
|
name: str
|
|
|
|
secret_type: str
|
|
|
|
target: str
|
|
|
|
options: str
|
|
|
|
|
|
|
|
@classmethod
|
2025-08-04 20:45:52 +02:00
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> list:
|
2025-07-22 13:00:41 +02:00
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None:
|
2025-08-04 20:45:52 +02:00
|
|
|
return []
|
2025-07-22 13:00:41 +02:00
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("Secret key is present, but malformed!")
|
2025-08-04 20:45:52 +02:00
|
|
|
return []
|
2025-07-22 13:00:41 +02:00
|
|
|
secrets = []
|
|
|
|
for key in val:
|
|
|
|
if not isinstance(val[key], dict):
|
|
|
|
logger.log_warning(f"Secret {key} is malformed!")
|
|
|
|
continue
|
|
|
|
name = key
|
2025-07-30 10:57:52 +02:00
|
|
|
secret_type = maybe_or(val[key], "type", "")
|
|
|
|
target = maybe_or(val[key], "target", "")
|
|
|
|
options = maybe_or(val[key], "options", "")
|
2025-07-22 13:00:41 +02:00
|
|
|
if options is None:
|
|
|
|
options = ""
|
|
|
|
secrets.append(
|
|
|
|
cls(name, str(secret_type), str(target), str(options))
|
|
|
|
)
|
|
|
|
|
|
|
|
return secrets
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
cmd = (
|
2025-07-30 10:24:51 +02:00
|
|
|
f"--secret {self.name},:"
|
|
|
|
f"type={self.secret_type},"
|
|
|
|
f"target={self.target}"
|
2025-07-22 13:00:41 +02:00
|
|
|
)
|
2025-07-22 16:50:24 +02:00
|
|
|
# Not a password, ruff...
|
|
|
|
if self.secret_type == "mount" and self.options != "": # noqa: S105
|
2025-07-22 13:00:41 +02:00
|
|
|
cmd = f"{cmd},{self.options}"
|
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def create(self) -> str:
|
|
|
|
"""Create secret if it does not exist."""
|
|
|
|
cmd = f"# Create secret {self.name}\n"
|
|
|
|
cmd += f"if ! podman secret exists '{self.name}' 2> /deb/null\n"
|
|
|
|
cmd += "then\n"
|
|
|
|
cmd += "\thead /dev/urandom \\\n"
|
|
|
|
cmd += "\t\t| tr -dc A-Za-z0-9 \\\n"
|
|
|
|
cmd += "\t\t| head -c 128 \\\n"
|
|
|
|
cmd += f"\t\t| podman secret create '{self.name}' -\n"
|
|
|
|
cmd += "fi\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def remove(self) -> str:
|
|
|
|
"""Remove secret if it exists."""
|
|
|
|
cmd = f"# Remove secret {self.name}\n"
|
|
|
|
cmd += f"if podman secret exists '{self.name}' 2> /dev/null\n"
|
|
|
|
cmd += "then\n"
|
|
|
|
cmd += f"\tpodman secret rm '{self.name}'\n"
|
|
|
|
cmd += "fi\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Environment:
|
|
|
|
"""Container environment."""
|
|
|
|
|
|
|
|
variables: list
|
|
|
|
file: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None:
|
|
|
|
return cls([], "")
|
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("Environment key is present, but malformed!")
|
|
|
|
return cls([], "")
|
|
|
|
return cls([f"{key}='{value}'" for key, value in val.items()], "")
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
return f"--env-file={self.file}"
|
|
|
|
|
|
|
|
def create(self) -> str:
|
|
|
|
"""Create env file."""
|
2025-08-04 21:33:41 +02:00
|
|
|
cmd = ""
|
|
|
|
header = f"# Create env-file {self.file}\n"
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
for var in self.variables:
|
|
|
|
escaped_var = var.replace("'", "%b")
|
2025-07-26 21:34:36 +02:00
|
|
|
cmd += f"printf '{escaped_var}\\n' \"'\" \"'\" >> '{self.file}'\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
|
2025-08-04 21:33:41 +02:00
|
|
|
if cmd == "":
|
|
|
|
return ""
|
|
|
|
|
|
|
|
return header + cmd
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
def remove(self) -> str:
|
|
|
|
"""Remove env file."""
|
|
|
|
cmd = f"# Remove env-file {self.file}\n"
|
|
|
|
cmd += f"if [ -e '{self.file}' ]\n"
|
|
|
|
cmd += "then\n"
|
2025-07-29 09:42:22 +02:00
|
|
|
cmd += f"\trm '{self.file}'\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
cmd += "fi\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Ports:
|
|
|
|
"""Container Ports."""
|
|
|
|
|
|
|
|
tcp_ports: list
|
|
|
|
udp_ports: list
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None:
|
|
|
|
return cls([], [])
|
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("Ports key is present, but malformed!")
|
|
|
|
return cls([], [])
|
|
|
|
tcp_ports = maybe(val, "tcp")
|
|
|
|
udp_ports = maybe(val, "udp")
|
2025-08-04 21:33:41 +02:00
|
|
|
if tcp_ports is None:
|
|
|
|
tcp_ports = []
|
|
|
|
if udp_ports is None:
|
|
|
|
udp_ports = []
|
|
|
|
if not isinstance(tcp_ports, list) and not isinstance(udp_ports, list):
|
|
|
|
logger.log_warning("Port configuration is malformed!")
|
2025-07-22 13:00:41 +02:00
|
|
|
return cls([], [])
|
2025-08-04 21:33:41 +02:00
|
|
|
if not isinstance(tcp_ports, list):
|
|
|
|
logger.log_warning("tcp_ports configuration is malformed!")
|
|
|
|
return cls([], udp_ports)
|
2025-07-22 13:00:41 +02:00
|
|
|
if not isinstance(udp_ports, list):
|
2025-08-04 21:33:41 +02:00
|
|
|
logger.log_warning("udp_ports configuration is malformed!")
|
|
|
|
return cls(tcp_ports, [])
|
2025-07-22 13:00:41 +02:00
|
|
|
return cls(tcp_ports, udp_ports)
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
2025-07-22 16:15:47 +02:00
|
|
|
seperator = " \\\n"
|
2025-08-04 21:33:41 +02:00
|
|
|
tcp_ports = seperator.join(
|
2025-08-04 20:32:04 +02:00
|
|
|
[f"\t--publish {port}/tcp" for port in self.tcp_ports]
|
2025-07-22 16:15:47 +02:00
|
|
|
)
|
2025-08-04 21:33:41 +02:00
|
|
|
udp_ports = seperator.join(
|
2025-08-04 20:32:04 +02:00
|
|
|
[f"\t--publish {port}/udp" for port in self.udp_ports]
|
2025-07-22 16:15:47 +02:00
|
|
|
)
|
2025-08-04 21:33:41 +02:00
|
|
|
|
|
|
|
if tcp_ports == "" and udp_ports == "":
|
|
|
|
return ""
|
|
|
|
|
|
|
|
if tcp_ports == "":
|
|
|
|
return udp_ports + seperator
|
|
|
|
|
|
|
|
if udp_ports == "":
|
|
|
|
return tcp_ports + seperator
|
|
|
|
|
|
|
|
return tcp_ports + seperator + udp_ports + seperator
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Network:
|
|
|
|
"""Container Network."""
|
|
|
|
|
|
|
|
mode: str
|
|
|
|
options: list
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
2025-08-04 20:45:52 +02:00
|
|
|
if val is None:
|
|
|
|
return cls("", [])
|
|
|
|
|
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("Network configuration is malformed!")
|
2025-07-22 13:00:41 +02:00
|
|
|
return cls("", [])
|
|
|
|
mode = maybe(val, "mode")
|
|
|
|
options = maybe(val, "options")
|
|
|
|
if mode is None or options is None or not isinstance(options, list):
|
|
|
|
err = "Network configuration is missing or has malformed elements!"
|
|
|
|
logger.log_error(err)
|
|
|
|
return cls("", [])
|
|
|
|
return cls(str(mode), options)
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
if self.mode == "":
|
|
|
|
return ""
|
2025-08-04 21:33:41 +02:00
|
|
|
cmd = f"\t--network={self.mode}"
|
2025-07-22 16:50:24 +02:00
|
|
|
opts = ",".join(self.options)
|
2025-07-22 13:00:41 +02:00
|
|
|
if opts != "":
|
|
|
|
cmd += f":{opts}"
|
2025-08-04 21:33:41 +02:00
|
|
|
return cmd + " \\\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Image:
|
|
|
|
"""Container Image."""
|
|
|
|
|
|
|
|
registry: str
|
|
|
|
image: str
|
|
|
|
tag: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self | None:
|
|
|
|
"""Create from JSON."""
|
|
|
|
if val is None or not isinstance(val, dict):
|
|
|
|
logger.log_error("Image key either not present or malformed!")
|
|
|
|
return None
|
2025-07-30 10:57:52 +02:00
|
|
|
registry = maybe_or(val, "registry", "")
|
|
|
|
image = maybe_or(val, "image", "")
|
|
|
|
tag = maybe_or(val, "tag", "")
|
2025-07-22 13:00:41 +02:00
|
|
|
return cls(str(registry), str(image), str(tag))
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
return f"{self.registry}/{self.image}:{self.tag}"
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Capability:
|
|
|
|
"""Container Capability."""
|
|
|
|
|
|
|
|
cap: str
|
|
|
|
mode: str
|
|
|
|
|
|
|
|
@classmethod
|
2025-08-04 20:45:52 +02:00
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> list:
|
2025-07-22 13:00:41 +02:00
|
|
|
"""Create from JSON."""
|
2025-08-04 20:45:52 +02:00
|
|
|
if val is None:
|
|
|
|
return []
|
|
|
|
if not isinstance(val, dict):
|
2025-08-04 21:33:41 +02:00
|
|
|
logger.log_warning("Capabilities key is malformed!")
|
2025-08-04 20:45:52 +02:00
|
|
|
return []
|
2025-07-22 13:00:41 +02:00
|
|
|
add = [cls(value, "add") for value in val["add"]]
|
|
|
|
drop = [cls(value, "drop") for value in val["drop"]]
|
|
|
|
return add + drop
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
|
|
|
return f"--cap-{self.mode}={self.cap}"
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Dns:
|
|
|
|
"""Container DNS."""
|
|
|
|
|
|
|
|
servers: list
|
|
|
|
search: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, val: ConfigValue, logger: Log) -> Self:
|
|
|
|
"""Create from JSON."""
|
2025-08-04 20:45:52 +02:00
|
|
|
if val is None:
|
|
|
|
return cls([], "")
|
|
|
|
|
|
|
|
if not isinstance(val, dict):
|
|
|
|
logger.log_warning("DNS Key is malformed!")
|
2025-07-22 13:00:41 +02:00
|
|
|
return cls([], "")
|
2025-07-30 10:57:52 +02:00
|
|
|
search = maybe_or(val, "search", "")
|
2025-07-22 13:00:41 +02:00
|
|
|
servers = maybe(val, "servers")
|
|
|
|
if not isinstance(servers, list):
|
|
|
|
logger.log_error("Servers key is not an array!")
|
|
|
|
return cls([], "")
|
|
|
|
return cls(servers, str(search))
|
|
|
|
|
|
|
|
def command(self) -> str:
|
|
|
|
"""Option for podman container create."""
|
2025-07-22 15:58:53 +02:00
|
|
|
if len(self.servers) == 0 and self.search == "":
|
2025-07-22 13:00:41 +02:00
|
|
|
return ""
|
|
|
|
if len(self.servers) == 0:
|
2025-08-04 21:33:41 +02:00
|
|
|
return f"\t--dns-search={self.search} \\\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
if self.search == "":
|
2025-08-04 21:33:41 +02:00
|
|
|
return f"\t--dns={','.join(self.servers)} \\\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
|
2025-08-04 21:33:41 +02:00
|
|
|
cmd = f"\t--dns-search={self.search} \\\n\t--dns="
|
2025-07-22 16:50:24 +02:00
|
|
|
cmd += ",".join(self.servers)
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
|
|
|
class Container:
|
|
|
|
"""Container."""
|
|
|
|
|
|
|
|
name: str
|
|
|
|
image: Image
|
|
|
|
privileged: bool
|
|
|
|
read_only: bool
|
|
|
|
replace: bool
|
|
|
|
restart: str
|
|
|
|
pull_policy: str
|
2025-07-30 11:07:21 +02:00
|
|
|
timezone: str
|
2025-07-22 13:00:41 +02:00
|
|
|
network: Network
|
|
|
|
dns: Dns
|
|
|
|
ports: Ports
|
2025-08-04 20:45:52 +02:00
|
|
|
env: Environment
|
|
|
|
secrets: list
|
|
|
|
volumes: list
|
|
|
|
capabilities: list
|
2025-07-30 10:31:02 +02:00
|
|
|
accounting: Accounting
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
def __init__(self, json: dict, logger: Log | None = None) -> None:
|
|
|
|
"""Create from JSON."""
|
|
|
|
if logger is None:
|
|
|
|
logger = Log("/dev/stdout")
|
|
|
|
name = maybe(json, "name")
|
|
|
|
if name is None:
|
|
|
|
logger.log_error("No container name set, aborting!")
|
|
|
|
return
|
|
|
|
image = maybe(json, "image")
|
|
|
|
if image is None:
|
|
|
|
logger.log_error("No image set, aborting!")
|
|
|
|
return
|
|
|
|
privileged = maybe(json, "privileged")
|
|
|
|
read_only = maybe(json, "read_only")
|
|
|
|
replace = maybe(json, "replace")
|
2025-07-30 10:57:52 +02:00
|
|
|
pull_policy = maybe_or(json, "pull_policy", "always")
|
|
|
|
restart = maybe_or(json, "restart", "no")
|
2025-07-30 11:07:21 +02:00
|
|
|
timezone = maybe_or(json, "timezone", "local")
|
2025-07-22 13:00:41 +02:00
|
|
|
network = maybe(json, "network")
|
|
|
|
dns = maybe(json, "dns")
|
|
|
|
ports = maybe(json, "ports")
|
|
|
|
env = maybe(json, "env")
|
|
|
|
secrets = maybe(json, "secrets")
|
|
|
|
volumes = maybe(json, "volumes")
|
|
|
|
capabilities = maybe(json, "capabilities")
|
2025-07-30 10:31:02 +02:00
|
|
|
accounting = maybe(json, "accounting")
|
2025-07-22 13:00:41 +02:00
|
|
|
self.name = str(name)
|
|
|
|
self.image = Image.from_json(image, logger)
|
|
|
|
self.privileged = privileged is not None and bool(privileged)
|
|
|
|
self.read_only = read_only is not None and bool(read_only)
|
|
|
|
self.replace = replace is not None and bool(replace)
|
|
|
|
self.pull_policy = str(pull_policy)
|
|
|
|
self.restart = str(restart)
|
2025-07-30 11:07:21 +02:00
|
|
|
self.timezone = str(timezone)
|
2025-07-22 13:00:41 +02:00
|
|
|
self.network = Network.from_json(network, logger)
|
|
|
|
self.dns = Dns.from_json(dns, logger)
|
|
|
|
self.ports = Ports.from_json(ports, logger)
|
|
|
|
self.env = Environment.from_json(env, logger)
|
2025-08-04 21:42:18 +02:00
|
|
|
self.env.file = "/var/lib/containerctl/environment-files/"
|
|
|
|
self.env.file += f"{self.name}"
|
2025-07-22 13:00:41 +02:00
|
|
|
self.secrets = Secret.from_json(secrets, logger)
|
|
|
|
self.volumes = Volume.from_json(volumes, logger)
|
|
|
|
self.capabilities = Capability.from_json(capabilities, logger)
|
2025-07-30 10:31:02 +02:00
|
|
|
self.accounting = Accounting.from_json(accounting, logger)
|
2025-07-22 13:00:41 +02:00
|
|
|
|
|
|
|
def create_volumes(self) -> str:
|
|
|
|
"""Generate podman volume create commands."""
|
|
|
|
if self.volumes is None:
|
|
|
|
return ""
|
|
|
|
volumes = ""
|
|
|
|
for volume in self.volumes:
|
|
|
|
volumes += volume.create()
|
|
|
|
return volumes
|
|
|
|
|
|
|
|
def create_secrets(self) -> str:
|
|
|
|
"""Generate podman secret create commands."""
|
|
|
|
if self.secrets is None:
|
|
|
|
return ""
|
|
|
|
secrets = ""
|
|
|
|
for secret in self.secrets:
|
|
|
|
secrets += secret.create()
|
|
|
|
return secrets
|
|
|
|
|
2025-07-22 13:17:35 +02:00
|
|
|
def create_environment(self) -> str:
|
|
|
|
"""Wrap self.env.create()."""
|
|
|
|
if self.env is None:
|
|
|
|
return ""
|
|
|
|
return self.env.create()
|
|
|
|
|
2025-07-22 13:00:41 +02:00
|
|
|
def create_container(self) -> str:
|
2025-07-22 13:34:04 +02:00
|
|
|
"""Generate podman container create command."""
|
2025-07-22 16:15:47 +02:00
|
|
|
cmd = f"# Create container {self.name}\n"
|
2025-07-22 13:34:04 +02:00
|
|
|
cmd += "podman container crate \\\n"
|
|
|
|
cmd += f"\t--name={self.name} \\\n"
|
|
|
|
if self.privileged:
|
|
|
|
cmd += "\t--privileged \\\n"
|
|
|
|
if self.replace:
|
|
|
|
cmd += "\t--replace \\\n"
|
|
|
|
if self.read_only:
|
|
|
|
cmd += "\t--read-only \\\n"
|
|
|
|
cmd += f"\t--restart={self.restart} \\\n"
|
|
|
|
cmd += f"\t--pull={self.pull_policy} \\\n"
|
2025-07-30 11:07:21 +02:00
|
|
|
cmd += f"\t--tz={self.timezone} \\\n"
|
2025-08-04 21:33:41 +02:00
|
|
|
cmd += f"{self.network.command()}"
|
|
|
|
cmd += f"{self.dns.command()}"
|
2025-07-22 13:34:04 +02:00
|
|
|
cmd += f"{self.ports.command()}"
|
|
|
|
if self.env is not None:
|
|
|
|
cmd += f"\t{self.env.command()} \\\n"
|
|
|
|
for secret in self.secrets:
|
|
|
|
cmd += f"\t{secret.command()} \\\n"
|
|
|
|
for volume in self.volumes:
|
|
|
|
cmd += f"\t{volume.command()} \\\n"
|
|
|
|
for capability in self.capabilities:
|
|
|
|
cmd += f"\t{capability.command()} \\\n"
|
2025-07-30 10:31:02 +02:00
|
|
|
cmd += f"{self.accounting.command()}"
|
2025-07-22 13:34:04 +02:00
|
|
|
cmd += f"\t{self.image.command()}\n"
|
2025-07-22 13:00:41 +02:00
|
|
|
return cmd
|
|
|
|
|
|
|
|
def start_container(self) -> str:
|
|
|
|
"""Generate podman container start command."""
|
|
|
|
cmd = f"# Start container {self.name}\n"
|
|
|
|
cmd += f"podman container start {self.name}\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def stop_container(self) -> str:
|
|
|
|
"""Generate podman container stop command."""
|
|
|
|
cmd = f"# Stop container {self.name}\n"
|
|
|
|
cmd += f"podman container stop {self.name}\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def restart_container(self) -> str:
|
|
|
|
"""Generate podman container restart composite command."""
|
|
|
|
cmd = f"# Restart container {self.name}\n"
|
|
|
|
cmd += self.stop_container()
|
|
|
|
cmd += "sleep 5s\n"
|
|
|
|
cmd += self.start_container()
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def upgrade_container(self) -> str:
|
|
|
|
"""Generate podman container upgrade composite command."""
|
|
|
|
comment = f"# Upgrade container {self.name}\n"
|
|
|
|
remove_container = self.remove_container()
|
2025-07-22 13:22:38 +02:00
|
|
|
remove_env_file = self.remove_environment()
|
2025-07-22 13:00:41 +02:00
|
|
|
create_container = self.create_container()
|
2025-07-22 13:22:38 +02:00
|
|
|
create_env_file = self.create_environment()
|
2025-07-22 13:00:41 +02:00
|
|
|
start_container = self.start_container()
|
|
|
|
remove = f"{remove_container}\n{remove_env_file}\n"
|
2025-07-22 13:22:38 +02:00
|
|
|
create = f"{create_env_file}\n{create_container}\n"
|
|
|
|
create_and_start = create + "\n" + start_container
|
2025-07-22 13:00:41 +02:00
|
|
|
return f"{comment}\n{remove}\n{create_and_start}"
|
|
|
|
|
|
|
|
def purge_container(self) -> str:
|
|
|
|
"""Generate podman container purge composite command."""
|
|
|
|
remove_container = self.remove_container()
|
|
|
|
remove_volumes = self.remove_volumes()
|
|
|
|
remove_secrets = self.remove_secrets()
|
2025-07-22 13:22:38 +02:00
|
|
|
remove_env_file = self.remove_environment()
|
2025-07-22 13:00:41 +02:00
|
|
|
remove_data = f"{remove_volumes}\n{remove_secrets}\n{remove_env_file}"
|
|
|
|
return f"{remove_container}\n{remove_data}"
|
|
|
|
|
|
|
|
def remove_container(self) -> str:
|
|
|
|
"""Generate podman container remove command."""
|
|
|
|
cmd = self.stop_container()
|
|
|
|
cmd += f"\n# Remove container {self.name}\n"
|
|
|
|
cmd += f"podman container rm {self.name}\n"
|
|
|
|
return cmd
|
|
|
|
|
|
|
|
def remove_volumes(self) -> str:
|
|
|
|
"""Generate podman volume remove commands."""
|
|
|
|
if self.volumes is None:
|
|
|
|
return ""
|
|
|
|
volumes = ""
|
|
|
|
for volume in self.volumes:
|
|
|
|
volumes += volume.remove()
|
|
|
|
return volumes
|
|
|
|
|
|
|
|
def remove_secrets(self) -> str:
|
|
|
|
"""Generate podman secret remove commands."""
|
|
|
|
if self.secrets is None:
|
|
|
|
return ""
|
|
|
|
secrets = ""
|
|
|
|
for secret in self.secrets:
|
|
|
|
secrets += secret.remove()
|
|
|
|
return secrets
|
2025-07-22 13:17:35 +02:00
|
|
|
|
|
|
|
def remove_environment(self) -> str:
|
|
|
|
"""Wrap self.env.remove()."""
|
|
|
|
if self.env is None:
|
|
|
|
return ""
|
|
|
|
return self.env.remove()
|