feat(agent-call): add repeatable Asterisk bootstrap

This commit is contained in:
2026-09-13 14:30:00 +08:00
parent 5c9165cb4b
commit d30314c7d1
6 changed files with 363 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# User-data template. build_asterisk_userdata.py prepends the image and config payloads.
set -euo pipefail
: "${ASTERISK_IMAGE:?generated user-data must set an immutable Asterisk image digest}"
: "${HTTP_CONF_B64:?missing http.conf payload}"
: "${ARI_CONF_B64:?missing ari.conf payload}"
: "${PJSIP_CONF_B64:?missing pjsip.conf payload}"
: "${RTP_CONF_B64:?missing rtp.conf payload}"
: "${EXTENSIONS_CONF_B64:?missing extensions.conf payload}"
ASTERISK_CONFIG_DIR="/opt/agent-call/asterisk/generated"
ASTERISK_CONFIG_GID="${ASTERISK_CONFIG_GID:-1000}"
install -d -m 0750 "$ASTERISK_CONFIG_DIR"
write_config() {
local name="$1" payload="$2" path="$ASTERISK_CONFIG_DIR/$1"
printf '%s' "$payload" | base64 --decode >"$path"
chgrp "$ASTERISK_CONFIG_GID" "$path"
chmod 0640 "$path"
}
write_config http.conf "$HTTP_CONF_B64"
write_config ari.conf "$ARI_CONF_B64"
write_config pjsip.conf "$PJSIP_CONF_B64"
write_config rtp.conf "$RTP_CONF_B64"
write_config extensions.conf "$EXTENSIONS_CONF_B64"
if ! command -v docker >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock >/dev/null 2>&1; do
sleep 5
done
until apt-get update; do
sleep 10
done
until apt-get install -y ca-certificates curl docker.io; do
sleep 10
done
fi
systemctl daemon-reload
systemctl enable --now docker
until docker info >/dev/null 2>&1; do
sleep 5
done
until docker pull "$ASTERISK_IMAGE"; do
sleep 15
done
docker rm -f agent-call-asterisk >/dev/null 2>&1 || true
docker volume create agent-call-recordings >/dev/null
docker run -d \
--name agent-call-asterisk \
--network host \
--restart unless-stopped \
--stop-timeout 60 \
--log-opt max-size=10m \
--log-opt max-file=3 \
-v "$ASTERISK_CONFIG_DIR/http.conf:/etc/asterisk/http.conf:ro" \
-v "$ASTERISK_CONFIG_DIR/ari.conf:/etc/asterisk/ari.conf:ro" \
-v "$ASTERISK_CONFIG_DIR/pjsip.conf:/etc/asterisk/pjsip.conf:ro" \
-v "$ASTERISK_CONFIG_DIR/rtp.conf:/etc/asterisk/rtp.conf:ro" \
-v "$ASTERISK_CONFIG_DIR/extensions.conf:/etc/asterisk/extensions.conf:ro" \
-v agent-call-recordings:/var/spool/asterisk/recording \
"$ASTERISK_IMAGE"
ready=0
for _ in $(seq 1 60); do
if docker exec agent-call-asterisk asterisk -rx 'core show version' >/dev/null 2>&1; then
ready=1
break
fi
sleep 2
done
if [[ "$ready" != 1 ]]; then
echo 'Asterisk did not become CLI-ready; inspect docker logs' >&2
exit 1
fi
docker exec agent-call-asterisk asterisk -rx 'pjsip show endpoint provider-primary'
install -d -m 0750 /var/lib/agent-call
date -u +%Y-%m-%dT%H:%M:%SZ >/var/lib/agent-call/bootstrap.done
chmod 0600 /var/lib/agent-call/bootstrap.done
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Build a secret-bearing ECS user-data file without printing its contents."""
import argparse
import base64
import json
import os
import re
import shlex
import sys
import tempfile
from pathlib import Path
try:
from .render_asterisk import render
except ImportError: # Direct execution: python3 deploy/build_asterisk_userdata.py
from render_asterisk import render
CONFIG_FILES = ("http.conf", "ari.conf", "pjsip.conf", "rtp.conf", "extensions.conf")
IMAGE_RE = re.compile(r"^[A-Za-z0-9._/-]+(?::[A-Za-z0-9_.-]+)?@sha256:[0-9a-f]{64}$")
def read_secret(path):
if path is None:
value = os.environ.get("ARI_PASSWORD", "")
else:
path = Path(path)
if path.is_symlink() or not path.is_file():
raise ValueError("ARI password file must be an existing non-symlink file")
value = path.read_text().rstrip("\n")
if not value:
raise ValueError("set ARI_PASSWORD or --ari-password-file")
return value
def immutable_image(value):
if not isinstance(value, str) or not IMAGE_RE.fullmatch(value):
raise ValueError("--image must be a registry image pinned by @sha256:<64 hex>")
return value
def build(cfg, image, password, config_gid=1000, template=None, environment=None):
if (
isinstance(config_gid, bool)
or not isinstance(config_gid, int)
or not 1 <= config_gid <= 65535
):
raise ValueError("config GID must be between 1 and 65535")
env = dict(os.environ if environment is None else environment)
env["ARI_PASSWORD"] = password
rendered = render(cfg, env)
payloads = {
name.upper().replace(".", "_") + "_B64": base64.b64encode(
rendered[name].encode()
).decode()
for name in CONFIG_FILES
}
variables = [
"#!/usr/bin/env bash",
"# Generated by deploy/build_asterisk_userdata.py; do not commit this file.",
f"ASTERISK_IMAGE={shlex.quote(image)}",
f"ASTERISK_CONFIG_GID={config_gid}",
]
variables.extend(
f"{key}={shlex.quote(value)}" for key, value in payloads.items()
)
variables.append("")
if template is None:
template = Path(__file__).with_name("asterisk_bootstrap.sh")
template_text = Path(template).read_text()
if template_text.startswith("#!"):
template_text = template_text.split("\n", 1)[1]
return "\n".join(variables) + template_text
def write_output(content, path):
path = Path(path)
if path.exists() or path.is_symlink():
raise ValueError("output already exists; choose a new local secret-bearing path")
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=".userdata-",
delete=False,
) as temporary:
temporary.write(content)
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(0o600)
temporary_path.replace(path)
path.chmod(0o600)
except Exception:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True, help="render_asterisk JSON config")
parser.add_argument("--image", help="immutable registry image@sha256:digest")
parser.add_argument("--ari-password-file")
parser.add_argument("--output", required=True, help="local secret-bearing user-data path")
parser.add_argument("--config-gid", type=int, default=1000)
args = parser.parse_args()
try:
cfg = json.loads(Path(args.config).read_text())
image = immutable_image(
args.image or cfg.get("asterisk_image") or os.environ.get("ASTERISK_IMAGE")
)
content = build(cfg, image, read_secret(args.ari_password_file), args.config_gid)
if len(content.encode()) > 16 * 1024:
raise ValueError("generated user-data exceeds the ECS 16 KiB limit")
write_output(content, args.output)
print(
f"wrote {args.output} ({len(content.encode())} bytes); "
"secret payload not printed"
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())