363 lines
14 KiB
Python
363 lines
14 KiB
Python
import base64
|
|
import copy
|
|
import ipaddress
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from deploy import aliyun_host as cloud
|
|
from deploy import build_asterisk_userdata as user_data
|
|
from deploy import render_asterisk as ast
|
|
|
|
|
|
def config():
|
|
return {
|
|
"region": cloud.REGION,
|
|
"public_ip": cloud.PUBLIC_IP,
|
|
"project_tag": "agent-call",
|
|
"image_id": "m-test",
|
|
"instance_type": "ecs.test",
|
|
"vswitch_id": "vsw-test",
|
|
"security_group_id": "sg-test",
|
|
"key_pair_name": "test-key",
|
|
"spot_price_limit": 0.1,
|
|
}
|
|
|
|
|
|
def instance(id="i-test", tagged=True):
|
|
return {
|
|
"InstanceId": id,
|
|
"Status": "Running",
|
|
"Tags": {
|
|
"Tag": [{"TagKey": "project", "TagValue": "agent-call"}] if tagged else []
|
|
},
|
|
}
|
|
|
|
|
|
class FakeCloud:
|
|
def __init__(self):
|
|
self.address = {
|
|
"IpAddress": cloud.PUBLIC_IP,
|
|
"AllocationId": "eip-test",
|
|
"Status": "Available",
|
|
"InstanceId": "",
|
|
"InstanceType": "EcsInstance",
|
|
}
|
|
self.rows = []
|
|
self.calls = []
|
|
self.fail_bind = False
|
|
self.timeout_create = False
|
|
|
|
def __call__(self, product, action, **params):
|
|
self.calls.append((action, params))
|
|
if action == "DescribeEipAddresses":
|
|
rows = [copy.deepcopy(self.address)] if self.address else []
|
|
return {"TotalCount": len(rows), "EipAddresses": {"EipAddress": rows}}
|
|
if action == "DescribeInstances":
|
|
rows = copy.deepcopy(self.rows)
|
|
if "InstanceIds" in params:
|
|
rows = [r for r in rows if r["InstanceId"] in params["InstanceIds"]]
|
|
return {"TotalCount": len(rows), "Instances": {"Instance": rows}}
|
|
if action == "RunInstances":
|
|
if self.timeout_create:
|
|
raise cloud.CloudError("simulated transport timeout")
|
|
self.rows = [instance("i-created")]
|
|
return {"InstanceIdSets": {"InstanceIdSet": ["i-created"]}}
|
|
if action == "AssociateEipAddress":
|
|
if self.fail_bind:
|
|
raise cloud.CloudError("simulated association failure")
|
|
self.address.update(InstanceId=params["InstanceId"], Status="InUse")
|
|
return {}
|
|
raise AssertionError("Unexpected cloud mutation: " + action)
|
|
|
|
def mutations(self):
|
|
return [name for name, _ in self.calls if not name.startswith("Describe")]
|
|
|
|
|
|
class CloudTests(unittest.TestCase):
|
|
def test_plan_is_read_only(self):
|
|
api = FakeCloud()
|
|
self.assertEqual(cloud.plan(config(), api)["action"], "create_and_bind")
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_reuse_owned_bound_instance(self):
|
|
api = FakeCloud()
|
|
api.rows = [instance()]
|
|
api.address.update(InstanceId="i-test", Status="InUse")
|
|
with tempfile.TemporaryDirectory() as d:
|
|
result = cloud.apply(config(), api, Path(d) / "state.json")
|
|
self.assertEqual(result["action"], "reuse")
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_foreign_attachment_is_never_stolen(self):
|
|
api = FakeCloud()
|
|
api.rows = [instance(tagged=False)]
|
|
api.address.update(InstanceId="i-test", Status="InUse")
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(config(), api)
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_no_fixed_ip_means_no_creation(self):
|
|
api = FakeCloud()
|
|
api.address = {}
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(config(), api)
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_existing_non_eip_needs_explicit_adoption(self):
|
|
api = FakeCloud()
|
|
api.address = {}
|
|
row = instance(tagged=False)
|
|
row["PublicIpAddress"] = {"IpAddress": [cloud.PUBLIC_IP]}
|
|
api.rows = [row]
|
|
cfg = config()
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(cfg, api)
|
|
cfg["adopt_instance_id"] = "i-test"
|
|
self.assertEqual(cloud.plan(cfg, api)["ip_kind"], "instance_public_ip")
|
|
|
|
def test_duplicate_candidates_stop(self):
|
|
api = FakeCloud()
|
|
api.rows = [instance("i-one"), instance("i-two")]
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(config(), api)
|
|
|
|
def test_create_spot_without_new_public_ip_then_bind(self):
|
|
api = FakeCloud()
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "state.json"
|
|
result = cloud.apply(config(), api, path, sleep=lambda _: None)
|
|
state = json.loads(path.read_text())
|
|
self.assertEqual(state["instance_id"], "i-created")
|
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
|
self.assertEqual(result["action"], "ready")
|
|
params = next(params for name, params in api.calls if name == "RunInstances")
|
|
self.assertEqual(params["Amount"], 1)
|
|
self.assertEqual(params["InternetMaxBandwidthOut"], 0)
|
|
self.assertEqual(params["SpotStrategy"], "SpotWithPriceLimit")
|
|
self.assertEqual(params["SpotPriceLimit"], 0.1)
|
|
self.assertEqual(params["SystemDisk.PerformanceLevel"], "PL1")
|
|
self.assertEqual(api.mutations(), ["RunInstances", "AssociateEipAddress"])
|
|
|
|
def test_automatic_spot_price_omits_price_limit(self):
|
|
cfg = config()
|
|
cfg["spot_strategy"] = "SpotAsPriceGo"
|
|
cfg.pop("spot_price_limit")
|
|
params = cloud.create_params(cfg)
|
|
self.assertEqual(params["SpotStrategy"], "SpotAsPriceGo")
|
|
self.assertNotIn("SpotPriceLimit", params)
|
|
|
|
def test_user_data_is_base64_encoded_without_logging_content(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
user_data = Path(d) / "bootstrap.sh"
|
|
user_data.write_bytes(b"#!/bin/sh\necho ready\n")
|
|
cfg = config()
|
|
cfg["user_data_file"] = str(user_data)
|
|
params = cloud.create_params(cfg)
|
|
self.assertEqual(
|
|
base64.b64decode(params["UserData"]), b"#!/bin/sh\necho ready\n"
|
|
)
|
|
|
|
def test_create_timeout_reuses_client_token(self):
|
|
api = FakeCloud()
|
|
api.timeout_create = True
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "state.json"
|
|
for _ in range(2):
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.apply(config(), api, path, sleep=lambda _: None)
|
|
tokens = [p["ClientToken"] for name, p in api.calls if name == "RunInstances"]
|
|
self.assertEqual(len(tokens), 2)
|
|
self.assertEqual(tokens[0], tokens[1])
|
|
|
|
def test_bind_failure_reuses_created_host(self):
|
|
api = FakeCloud()
|
|
api.fail_bind = True
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "state.json"
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.apply(config(), api, path, sleep=lambda _: None)
|
|
api.fail_bind = False
|
|
result = cloud.apply(config(), api, path, sleep=lambda _: None)
|
|
self.assertEqual(result["action"], "ready")
|
|
self.assertEqual(api.mutations().count("RunInstances"), 1)
|
|
|
|
def test_stopped_host_and_bad_budget_do_not_create(self):
|
|
api = FakeCloud()
|
|
cfg = config()
|
|
cfg["spot_price_limit"] = None
|
|
with tempfile.TemporaryDirectory() as d, self.assertRaises(cloud.CloudError):
|
|
cloud.apply(cfg, api, Path(d) / "state.json", sleep=lambda _: None)
|
|
self.assertEqual(api.mutations(), [])
|
|
api.rows = [instance()]
|
|
api.rows[0]["Status"] = "Stopped"
|
|
with tempfile.TemporaryDirectory() as d, self.assertRaises(cloud.CloudError):
|
|
cloud.apply(config(), api, Path(d) / "state.json", sleep=lambda _: None)
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_incomplete_inventory_does_not_create(self):
|
|
api = FakeCloud()
|
|
|
|
def incomplete(product, action, **params):
|
|
if action == "DescribeInstances":
|
|
return {"TotalCount": 2, "Instances": {"Instance": []}}
|
|
return api(product, action, **params)
|
|
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(config(), incomplete)
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_missing_allocation_id_stops_before_creation(self):
|
|
api = FakeCloud()
|
|
api.address.pop("AllocationId")
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(config(), api)
|
|
self.assertEqual(api.mutations(), [])
|
|
|
|
def test_binding_race_does_not_detach_foreign_host(self):
|
|
api = FakeCloud()
|
|
reads = 0
|
|
|
|
def raced(product, action, **params):
|
|
nonlocal reads
|
|
if action == "DescribeEipAddresses":
|
|
reads += 1
|
|
if reads == 2:
|
|
api.address.update(InstanceId="i-foreign", Status="InUse")
|
|
return api(product, action, **params)
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "state.json"
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.apply(config(), raced, path, sleep=lambda _: None)
|
|
self.assertEqual(json.loads(path.read_text())["instance_id"], "i-created")
|
|
self.assertEqual(api.mutations(), ["RunInstances"])
|
|
|
|
def test_region_mismatch(self):
|
|
cfg = config()
|
|
cfg["region"] = "cn-hangzhou"
|
|
with self.assertRaises(cloud.CloudError):
|
|
cloud.plan(cfg, FakeCloud())
|
|
|
|
|
|
class AsteriskTests(unittest.TestCase):
|
|
def cfg(self):
|
|
return {
|
|
"public_ip": cloud.PUBLIC_IP,
|
|
"transport": "udp",
|
|
"local_net": "10.1.0.0/16",
|
|
"primary": {
|
|
"host": "sip-a.test",
|
|
"auth_mode": "ip",
|
|
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
|
|
},
|
|
"backup": {
|
|
"host": "sip-b.test",
|
|
"auth_mode": "ip",
|
|
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
|
|
},
|
|
}
|
|
|
|
def test_build_user_data_pins_image_and_does_not_print_secret(self):
|
|
content = user_data.build(
|
|
self.cfg(),
|
|
"registry.example/asterisk@sha256:" + "a" * 64,
|
|
"x" * 32,
|
|
)
|
|
self.assertIn("ASTERISK_IMAGE=registry.example/asterisk@sha256:", content)
|
|
self.assertIn("pjsip set logger on", content)
|
|
self.assertIn("HTTP_CONF_B64=", content)
|
|
self.assertNotIn("password=" + "x" * 32, content)
|
|
with self.assertRaises(ValueError):
|
|
user_data.immutable_image("registry.example/asterisk:latest")
|
|
|
|
def test_bootstrap_enforces_rogee_key_only_ssh(self):
|
|
content = Path("deploy/asterisk_bootstrap.sh").read_text()
|
|
for marker in (
|
|
'ROGEE_USER="rogee"',
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPoGJTH1JrXbWu+ZhIgaHMhume0oWQiWmadhTwbk75Ny rogee@ipao.vip",
|
|
"PasswordAuthentication no",
|
|
"KbdInteractiveAuthentication no",
|
|
"PermitRootLogin no",
|
|
"AllowUsers rogee",
|
|
"systemctl enable --now ssh",
|
|
):
|
|
self.assertIn(marker, content)
|
|
|
|
def test_codec_profile_is_rendered_without_global_fallback(self):
|
|
cfg = self.cfg()
|
|
cfg["primary"]["codec_profile"] = {
|
|
"allowed": ["PCMU", "PCMA"],
|
|
"preferred": "PCMA",
|
|
}
|
|
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
|
self.assertIn("allow=alaw,ulaw", files["pjsip.conf"])
|
|
cfg["primary"]["codec_profile"] = {
|
|
"allowed": ["G729"],
|
|
"preferred": "G729",
|
|
}
|
|
with self.assertRaises(ValueError):
|
|
ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
|
|
|
def test_private_ari_fixed_nat_and_recording_config(self):
|
|
files = ast.render(self.cfg(), {"ARI_PASSWORD": "x" * 32})
|
|
self.assertEqual(len(files), 5)
|
|
self.assertIn("bindaddr=127.0.0.1", files["http.conf"])
|
|
self.assertIn("external_media_address=123.56.71.98", files["pjsip.conf"])
|
|
self.assertIn("context=deny-inbound", files["pjsip.conf"])
|
|
self.assertIn("strictrtp=yes", files["rtp.conf"])
|
|
|
|
def test_preserves_explicit_caller_mapping(self):
|
|
cfg = self.cfg()
|
|
cfg["primary"].update(
|
|
from_user="BD93205882", caller_id="BD93205882 <BD93205882>"
|
|
)
|
|
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
|
self.assertIn("from_user=BD93205882", files["pjsip.conf"])
|
|
self.assertIn("callerid=BD93205882 <BD93205882>", files["pjsip.conf"])
|
|
self.assertIn(
|
|
"Set(CALLERID(all)=BD93205882 <BD93205882>)", files["extensions.conf"]
|
|
)
|
|
self.assertIn(
|
|
"Dial(PJSIP/${EXTEN}@provider-primary,60)", files["extensions.conf"]
|
|
)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "generated"
|
|
ast.write_config(files, path)
|
|
self.assertEqual((path / "ari.conf").stat().st_mode & 0o777, 0o600)
|
|
with self.assertRaises(ValueError):
|
|
ast.write_config(files, path)
|
|
|
|
def test_reject_injection_public_ari_and_our_ip_as_provider(self):
|
|
for update in (
|
|
{"ari_bind": str(ipaddress.IPv4Address(0))},
|
|
{"ari_bind": "8.8.8.8"},
|
|
{"local_net": "0.0.0.0/0"},
|
|
):
|
|
cfg = self.cfg()
|
|
cfg.update(update)
|
|
with self.assertRaises(ValueError):
|
|
ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
|
for host in ("123.56.71.98", "host\n[evil]", ""):
|
|
cfg = self.cfg()
|
|
cfg["primary"]["host"] = host
|
|
with self.assertRaises(ValueError):
|
|
ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
|
|
|
def test_digest_registration(self):
|
|
cfg = self.cfg()
|
|
cfg["primary"].update(
|
|
auth_mode="digest", username="approved-user", register=True
|
|
)
|
|
files = ast.render(
|
|
cfg,
|
|
{"ARI_PASSWORD": "x" * 32, "SIP_PRIMARY_PASSWORD": "vendor-pass-123456"},
|
|
)
|
|
self.assertIn("outbound_auth=provider-primary-auth", files["pjsip.conf"])
|
|
self.assertIn("type=registration", files["pjsip.conf"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|