The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
185 lines
8.7 KiB
Shell
Executable file
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
|