weavesignal/deploy/quadlets/generate.sh
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

185 lines
8.7 KiB
Shell
Executable file

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
#
# Podman Quadlets for a WeaveSignal station, generated from the Compose bundle (ADR-0022: a
# documented route). The Compose file stays the one description of the station: this resolves
# it with the station's settings (`podman compose config`) and writes one systemd unit per
# container and per volume, the services' settings files, and a target that starts them all.
#
# ./generate.sh --env-file ~/.config/weavesignal/station.env --out ~/.config/weavesignal/quadlets
# ./generate.sh ... --create-secrets also store the secrets as Podman secrets
#
# The settings file is the one weavesignal.sh writes (`weavesignal.sh --no-start --env-file ...`).
# No secret is written into a unit or a settings file: each container is given its secrets as
# Podman secrets (`Secret=`), which --create-secrets makes from the settings file, passing each
# value on standard input, never on a command line. Volumes keep the Compose bundle's names
# (<project>_<volume>), so a station moved from Compose to Quadlets keeps its data.
#
# Then, as the user who runs the station:
# cp OUT/containers/systemd/* ~/.config/containers/systemd/
# cp OUT/systemd/* ~/.config/systemd/user/
# systemctl --user daemon-reload && systemctl --user start <project>.target
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
compose_dir=$(cd "$here/../compose" && pwd)
project=weavesignal
env_file=''
out=''
create=false
while [ $# -gt 0 ]; do
case $1 in
--env-file) env_file=${2:?}; shift ;;
--out) out=${2:?}; shift ;;
--project) project=${2:?}; shift ;;
--create-secrets) create=true ;;
-h|--help) sed -n '5,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "generate.sh: unknown option $1" >&2; exit 2 ;;
esac
shift
done
[ -n "$env_file" ] && [ -f "$env_file" ] || { echo "generate.sh: --env-file names no file (weavesignal.sh --no-start writes one)" >&2; exit 2; }
[ -n "$out" ] || { echo "generate.sh: --out is needed" >&2; exit 2; }
[[ $project =~ ^[a-z0-9][a-z0-9_-]*$ ]] || { echo "generate.sh: bad project name" >&2; exit 2; }
env_file=$(cd "$(dirname "$env_file")" && pwd)/$(basename "$env_file")
umask 077
mkdir -p "$out"
out=$(cd "$out" && pwd)
# A public station (PUBLIC_DOMAIN in the settings) adds the proxy: public.yaml, as weavesignal.sh does.
files=(-f compose.yaml)
grep -q '^PUBLIC_DOMAIN=.' "$env_file" && files+=(-f public.yaml)
resolved=$(cd "$compose_dir" && podman compose --env-file "$env_file" -p "$project" "${files[@]}" config --format json)
# The conversion reads the resolved bundle on standard input; the program itself comes on fd 3.
CREATE=$create PROJECT=$project OUT=$out ENV_FILE=$env_file python3 -I /dev/fd/3 <<< "$resolved" 3<<'PY'
import json, os, shlex, subprocess, sys
c = json.load(sys.stdin)
project, out = os.environ["PROJECT"], os.environ["OUT"]
env = {}
for line in open(os.environ["ENV_FILE"]):
k, sep, v = line.rstrip("\n").partition("=")
if sep and not k.startswith("#"):
env[k] = v
secret_keys = ("LIVEKIT_API_SECRET", "LISTENERS_SOURCE_PASSWORD", "POSTGRES_PASSWORD", "CONTROL_ADMIN_TOKEN", "CHAT_EMAIL_KEY", "CONTROL_EMAIL_KEY", "CONTROL_TOKEN_KEY")
secret_values = [env[k] for k in secret_keys if env.get(k)]
secret_by_value = {env[k]: k for k in secret_keys if env.get(k)}
def is_secret(value):
return any(s and s in value for s in secret_values)
units = os.path.join(out, "containers", "systemd")
confdir = os.path.join(out, "config")
sysdir = os.path.join(out, "systemd")
for d in (units, confdir, sysdir):
os.makedirs(d, exist_ok=True)
# The generated files' own header (REUSE reads the next lines as text, not as this file's licence).
# REUSE-IgnoreStart
header = ("# SPDX-License-Identifier: AGPL-3.0-only\n"
"# Generated by deploy/quadlets/generate.sh from the Compose bundle; regenerate, do not edit.\n")
# REUSE-IgnoreEnd
def write(path, text, mode=0o644):
with open(path, "w") as f:
f.write(text)
os.chmod(path, mode)
# The services settings files: Compose writes them from its inline configs; here they are files.
config_paths = {}
for name, cfg in c.get("configs", {}).items():
content = cfg["content"]
if is_secret(content):
sys.exit(f"generate.sh: the {name} settings would hold a secret; refusing to write it")
path = os.path.join(confdir, name)
write(path, content, 0o644)
config_paths[name] = path
def unit_name(service):
return f"{project}-{service}"
def volume_unit(volume):
return f"{project}-{volume}.volume"
for volume in c.get("volumes", {}):
write(os.path.join(units, volume_unit(volume)),
header + f"[Unit]\nDescription=WeaveSignal {project}: the {volume} volume\n\n"
f"[Volume]\nVolumeName={project}_{volume}\n")
secrets = []
names = []
for service, s in sorted(c["services"].items()):
name = unit_name(service)
names.append(name)
deps = sorted(s.get("depends_on", {}))
lines = [header, "[Unit]", f"Description=WeaveSignal {project}: {service}"]
if deps:
after = " ".join(unit_name(d) + ".service" for d in deps)
lines += [f"Requires={after}", f"After={after}"]
lines += [f"PartOf={project}.target", "", "[Container]", f"ContainerName={name}", f"Image={s['image']}"]
if s.get("network_mode") == "host":
lines.append("Network=host")
elif s.get("network_mode"):
sys.exit(f"generate.sh: {service}: network_mode {s['network_mode']} is not handled")
if s.get("user"):
lines.append(f"User={s['user']}")
if "label=disable" in s.get("security_opt", []):
lines.append("SecurityLabelDisable=true")
if s.get("entrypoint"):
lines.append("Entrypoint=" + " ".join(s["entrypoint"]))
for key, value in sorted((s.get("environment") or {}).items()):
if value is None:
continue
if is_secret(value):
# One Podman secret per setting; a value built from one (LiveKit's "key: secret")
# is its own secret, named after the service.
base = secret_by_value.get(value)
secret = f"{project}-{(base or service + '-' + key).lower().replace('_', '-')}"
if secret not in dict(secrets):
secrets.append((secret, value))
lines.append(f"Secret={secret},type=env,target={key}")
else:
lines.append("Environment=" + shlex.quote(f"{key}={value}"))
for v in s.get("volumes", []):
ro = ":ro" if v.get("read_only") else ""
if v["type"] == "volume":
lines.append(f"Volume={volume_unit(v['source'])}:{v['target']}{ro}")
elif v["type"] == "bind":
lines.append(f"Volume={v['source']}:{v['target']}{ro}")
else:
sys.exit(f"generate.sh: {service}: volume type {v['type']} is not handled")
for cfg in s.get("configs", []):
lines.append(f"Volume={config_paths[cfg['source']]}:{cfg['target']}:ro")
hc = s.get("healthcheck")
if hc and not hc.get("disable"):
test = hc["test"]
if test[0] == "CMD":
lines.append("HealthCmd=" + json.dumps(test[1:]))
elif test[0] == "CMD-SHELL":
lines.append("HealthCmd=" + test[1])
for k, q in (("interval", "HealthInterval"), ("timeout", "HealthTimeout"), ("start_period", "HealthStartPeriod")):
if hc.get(k):
lines.append(f"{q}={hc[k]}")
if hc.get("retries"):
lines.append(f"HealthRetries={hc['retries']}")
# Units that need this one wait until it is healthy, as Compose service_healthy does.
lines.append("Notify=healthy")
if s.get("command"):
lines.append("Exec=" + " ".join(shlex.quote(a) for a in s["command"]))
restart = s.get("restart", "no")
lines += ["", "[Service]", "Restart=" + ("always" if restart in ("always", "unless-stopped") else "on-failure" if restart.startswith("on-failure") else "no"),
"TimeoutStartSec=300", "", "[Install]", f"WantedBy={project}.target", ""]
write(os.path.join(units, f"{name}.container"), "\n".join(lines))
write(os.path.join(sysdir, f"{project}.target"),
header + f"[Unit]\nDescription=WeaveSignal station {project}\n"
+ "Wants=" + " ".join(n + ".service" for n in names) + "\n\n[Install]\nWantedBy=default.target\n")
if os.environ["CREATE"] == "true":
for secret, value in secrets:
subprocess.run(["podman", "secret", "create", "--replace", secret, "-"], input=value.encode(), check=True, stdout=subprocess.DEVNULL)
print(f"stored {len(secrets)} Podman secrets: " + ", ".join(s for s, _ in secrets))
else:
print("Podman secrets these units need (run again with --create-secrets to store them): " + ", ".join(s for s, _ in secrets))
print(f"wrote {len(names)} containers, {len(c.get('volumes', {}))} volumes and {len(config_paths)} settings files under {out}")
PY