feat(agent-call): add repeatable Asterisk bootstrap
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user