1
0
Fork 0

Compare commits

..

19 commits

Author SHA1 Message Date
5bcf2f846c
generate: generate: Bump generate version to 0.0.4
Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:25:07 +02:00
058af68169
generate: container: Implement Memory Accounting support
Add a Memory class and create it and its options from the Accounting
class.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:23:30 +02:00
6767d4e3af
example.container: Add accounting.memory example
Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:12:52 +02:00
e8d3cd96eb
containerrc: Expand schema to allow memory accounting
Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:12:38 +02:00
a8bf51020d
generate: container: Implement timezone support
Get timezone from the container config and fall back to local, which
uses the hosts timezone, if the config entry is missing or not a string.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:07:21 +02:00
3908ab3013
example.container: Add timezone example
Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:03:29 +02:00
0a46bba629
containerrc: Add support for timezone changing
Might be needed for some applications.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:02:56 +02:00
18bb31cb96
generate: generate: Bump generate version to 0.0.3
Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 11:00:13 +02:00
3ed71b48ef
generate: container: Switch some maybe() calls to maybe_or()
Some places can use maybe_or() intead of maybe(), so use it there.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:59:18 +02:00
487be8c49a
generate: container: Don't warn or crash on missing optional keys
The entire accounting section is optional, so silently ignore when it is missing.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:51:33 +02:00
658fc6465c
containerrc: Run %s/ /\t/g on containerc
Replace indentation with tabs, by replacing every two spaces with one
tab.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:45:31 +02:00
1c2224cb19
containerrc: Remove extra } from schema
Those two are not needed.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:44:31 +02:00
8464196aaa
containerrc: Add accounting to schema
Adds accounting and its entries to the JSON schema.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:42:25 +02:00
4224112c62
generate: container: Fix Cgroup.command formatting
--cgroup-conf was missing the seperator.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:34:13 +02:00
1cd43e0c95
generate: container: Fix maybe_or()
isinstance() sadly can not get the type itself, so type() needs to be
called on _or first.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:32:15 +02:00
93245d5bc6
genreate: container: Add accounting support to Container.create
Add support for accouting to __init__ and create.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:31:02 +02:00
2a7bb0115c
generate: container: Implement Accounting, Cgroup and Cpu
Implment the 'Accounting', 'Cgroup', and 'Cpu' classes to control the
contaienr cgroup and cpu usage.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:24:51 +02:00
57b983e876
example.container: Add resource accounting
Add 'accounting' and its child-objects 'cgroup' and 'cpu' to control
the container cgroup and cpu usage.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-30 10:22:25 +02:00
e100abf698
example.container: Show a host volume in the example config
Since that is now possible, show an example for a host volume in the
example configuration.

Signed-off-by: Enno Tensing <tenno@suij.in>
2025-07-29 10:07:38 +02:00
4 changed files with 460 additions and 160 deletions

View file

@ -1,143 +1,206 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "object",
"properties": {
"registry": {
"type": "string"
},
"image": {
"type": "string"
},
"tag": {
"type": "string"
}
},
"required": [
"registry",
"image",
"tag"
]
},
"privileged": {
"type": "boolean"
},
"read_only": {
"type": "boolean"
},
"replace": {
"type": "boolean"
},
"pull_policy": {
"type": "string"
},
"restart": {
"type": "string"
},
"network": {
"type": "object",
"properties": {
"mode": {
"type": "string"
},
"options": {
"type": "array",
"items": {}
}
},
"required": [
"mode",
"options"
]
},
"dns": {
"type": "object",
"properties": {
"search": {
"type": "string"
},
"servers": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"search",
"servers"
]
},
"ports": {
"type": "object",
"properties": {
"tcp": {
"type": "array",
"items": [
{
"type": "string"
}
]
},
"udp": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"tcp",
"udp"
]
},
"env": {
"type": "object"
}
},
"secrets": {
"type": "object"
},
"volumes": {
"type": "object"
},
"capabilities": {
"type": "object",
"properties": {
"add": {
"type": "array",
"items": [
{
"type": "string"
}
]
},
"drop": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"add",
"drop"
]
}
},
"required": [
"name",
"image"
]
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "object",
"properties": {
"registry": {
"type": "string"
},
"image": {
"type": "string"
},
"tag": {
"type": "string"
}
},
"required": [
"registry",
"image",
"tag"
]
},
"privileged": {
"type": "boolean"
},
"read_only": {
"type": "boolean"
},
"replace": {
"type": "boolean"
},
"pull_policy": {
"type": "string"
},
"restart": {
"type": "string"
},
"timezone": {
"type": "string"
},
"network": {
"type": "object",
"properties": {
"mode": {
"type": "string"
},
"options": {
"type": "array",
"items": {}
}
},
"required": [
"mode",
"options"
]
},
"dns": {
"type": "object",
"properties": {
"search": {
"type": "string"
},
"servers": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"search",
"servers"
]
},
"ports": {
"type": "object",
"properties": {
"tcp": {
"type": "array",
"items": [
{
"type": "string"
}
]
},
"udp": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"tcp",
"udp"
]
},
"env": {
"type": "object"
}
},
"secrets": {
"type": "object"
},
"volumes": {
"type": "object"
},
"capabilities": {
"type": "object",
"properties": {
"add": {
"type": "array",
"items": [
{
"type": "string"
}
]
},
"drop": {
"type": "array",
"items": [
{
"type": "string"
}
]
}
},
"required": [
"add",
"drop"
]
},
"accounting": {
"type": "object",
"propeties": {
"cgroup": {
"type": "object",
"properties": {
"config": {
"type": "string"
},
"parent": {
"type": "string"
},
"namespace": {
"type": "string"
},
"how": {
"type": "string"
}
}
},
"cpu": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"quota": {
"type": "string"
},
"shares": {
"type": "string"
},
"number": {
"type": "string"
},
"cpuset": {
"cpus": {
"type": "string"
},
"mems": {
"type": "string"
}
}
}
},
"memory": {
"type": "object",
"properties": {
"limit": {
"type": "string"
},
"reservation": {
"type": "string"
},
"swap": {
"type": "string"
}
}
}
}
},
"required": [
"name",
"image"
]
}

View file

@ -10,6 +10,7 @@
"replace": false,
"pull_policy": "always",
"restart": "always",
"timezone": "Etc/UTC",
"network": {
"mode": "podman1",
"options": []
@ -42,11 +43,34 @@
}
},
"volumes": {
"etc": "/etc:ro,noexec",
"/etc": "/etc:ro,noexec",
"var": "/var"
},
"capabilities": {
"add": [ "NET_RAW" ],
"drop": [ "CAP_SYS_ADMIN" ]
},
"accounting": {
"cgroup": {
"config": ["memory.high=1073741824"],
"parent": "/example-parent",
"namespace": "host",
"how": "enabled"
},
"cpu": {
"period": "100000",
"quota": "100000",
"shares": "1024",
"number": "4",
"cpuset": {
"cpus": "0-3,11-15",
"mems": "0,1"
}
},
"memory": {
"limit": "16g",
"reservation": "512m",
"swap": "20g"
}
}
}

View file

@ -29,7 +29,7 @@ class ConfigError(Exception):
return f"Configuration error: {self.message}"
def maybe(json: dict, key: str) -> str | dict | list | bool | None:
def maybe(json: dict, key: str) -> ConfigValue:
"""Maybe get a value."""
try:
return json[key]
@ -37,6 +37,14 @@ def maybe(json: dict, key: str) -> str | dict | list | bool | None:
return None
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)
if val is None or not isinstance(val, type(_or)):
return _or
return val
def trim(s: str) -> str:
"""Remove sequential whitespace."""
s = s.replace("\t ", "\t")
@ -46,6 +54,207 @@ def trim(s: str) -> str:
return s
@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."""
if val is None:
return cls([], "", "", "")
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 not isinstance(config, list):
logger.log_warning("Config key in cgroup Config is invalid!")
config = []
if not isinstance(parent, str):
logger.log_warning("Parent key in cgroup Config is invalid!")
parent = ""
if not isinstance(namespace, str):
logger.log_warning("Namespace key in cgroup Config is invalid!")
namespace = ""
if not isinstance(how, str):
logger.log_warning("How key in cgroup Config is invalid!")
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(
[f"\t--cgroup-conf={option}{seperator}" for option in self.config]
)
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."""
if val is None:
return cls("", "", "", "", "", "")
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
@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
@dataclass
class Accounting:
"""Resource Accounting."""
cgroup: Cgroup
cpu: Cpu
memory: Memory
@classmethod
def from_json(cls, data: ConfigValue, logger: Log) -> Self:
"""Create from JSON."""
if data is None:
return cls(
Cgroup.from_json(None, logger),
Cpu.from_json(None, logger),
Memory.from_json(None, logger),
)
cgroup_data = maybe(data, "cgroup")
cpu_data = maybe(data, "cpu")
memory_data = maybe(data, "memory")
cgroup = Cgroup.from_json(cgroup_data, logger)
cpu = Cpu.from_json(cpu_data, logger)
memory = Memory.from_json(memory_data, logger)
return cls(cgroup, cpu, memory)
def command(self) -> str:
"""Options for podman container create."""
cgroup = self.cgroup.command()
cpu = self.cpu.command()
memory = self.memory.command()
return cgroup + cpu + memory
@dataclass
class Volume:
"""Container Volume."""
@ -122,9 +331,9 @@ class Secret:
logger.log_warning(f"Secret {key} is malformed!")
continue
name = key
secret_type = maybe(val[key], "type")
target = maybe(val[key], "target")
options = maybe(val[key], "options")
secret_type = maybe_or(val[key], "type", "")
target = maybe_or(val[key], "target", "")
options = maybe_or(val[key], "options", "")
if options is None:
options = ""
secrets.append(
@ -136,9 +345,9 @@ class Secret:
def command(self) -> str:
"""Option for podman container create."""
cmd = (
f"--secret {self.name},:"
f"type={self.secret_type},"
f"target={self.target}"
f"--secret {self.name},:"
f"type={self.secret_type},"
f"target={self.target}"
)
# Not a password, ruff...
if self.secret_type == "mount" and self.options != "": # noqa: S105
@ -294,9 +503,9 @@ class Image:
if val is None or not isinstance(val, dict):
logger.log_error("Image key either not present or malformed!")
return None
registry = maybe(val, "registry")
image = maybe(val, "image")
tag = maybe(val, "tag")
registry = maybe_or(val, "registry", "")
image = maybe_or(val, "image", "")
tag = maybe_or(val, "tag", "")
return cls(str(registry), str(image), str(tag))
def command(self) -> str:
@ -341,7 +550,7 @@ class Dns:
if val is None or not isinstance(val, dict):
logger.log_error("DNS Key is either missing or malformed!")
return cls([], "")
search = maybe(val, "search")
search = maybe_or(val, "search", "")
servers = maybe(val, "servers")
if not isinstance(servers, list):
logger.log_error("Servers key is not an array!")
@ -373,6 +582,7 @@ class Container:
replace: bool
restart: str
pull_policy: str
timezone: str
network: Network
dns: Dns
ports: Ports
@ -380,6 +590,7 @@ class Container:
secrets: list | None
volumes: list | None
capabilities: list | None
accounting: Accounting
def __init__(self, json: dict, logger: Log | None = None) -> None:
"""Create from JSON."""
@ -396,12 +607,9 @@ class Container:
privileged = maybe(json, "privileged")
read_only = maybe(json, "read_only")
replace = maybe(json, "replace")
pull_policy = maybe(json, "pull_policy")
if pull_policy is None:
pull_policy = "always"
restart = maybe(json, "restart")
if restart is None:
restart = "no"
pull_policy = maybe_or(json, "pull_policy", "always")
restart = maybe_or(json, "restart", "no")
timezone = maybe_or(json, "timezone", "local")
network = maybe(json, "network")
dns = maybe(json, "dns")
ports = maybe(json, "ports")
@ -409,6 +617,7 @@ class Container:
secrets = maybe(json, "secrets")
volumes = maybe(json, "volumes")
capabilities = maybe(json, "capabilities")
accounting = maybe(json, "accounting")
self.name = str(name)
self.image = Image.from_json(image, logger)
self.privileged = privileged is not None and bool(privileged)
@ -416,6 +625,7 @@ class Container:
self.replace = replace is not None and bool(replace)
self.pull_policy = str(pull_policy)
self.restart = str(restart)
self.timezone = str(timezone)
self.network = Network.from_json(network, logger)
self.dns = Dns.from_json(dns, logger)
self.ports = Ports.from_json(ports, logger)
@ -425,6 +635,7 @@ class Container:
self.secrets = Secret.from_json(secrets, logger)
self.volumes = Volume.from_json(volumes, logger)
self.capabilities = Capability.from_json(capabilities, logger)
self.accounting = Accounting.from_json(accounting, logger)
def create_volumes(self) -> str:
"""Generate podman volume create commands."""
@ -463,6 +674,7 @@ class Container:
cmd += "\t--read-only \\\n"
cmd += f"\t--restart={self.restart} \\\n"
cmd += f"\t--pull={self.pull_policy} \\\n"
cmd += f"\t--tz={self.timezone} \\\n"
cmd += f"\t{self.network.command()} \\\n"
cmd += f"\t{self.dns.command()} \\\n"
cmd += f"{self.ports.command()}"
@ -474,6 +686,7 @@ class Container:
cmd += f"\t{volume.command()} \\\n"
for capability in self.capabilities:
cmd += f"\t{capability.command()} \\\n"
cmd += f"{self.accounting.command()}"
cmd += f"\t{self.image.command()}\n"
return cmd

View file

@ -14,7 +14,7 @@ from pathlib import Path
from container import ConfigError, Container
from log import Log
GENERATE_VERSION = "0.0.2"
GENERATE_VERSION = "0.0.4"
HEADER = f"""#!/bin/sh
# This script was generated by containerctl v{GENERATE_VERSION}
# Report bugs with _this script_ to <tenno+containerctl@suij.in>