ops: sync dispatcharr tooling and pause noisy workflows
This commit is contained in:
251
automation/bin/pangolin_upsert_dispatcharr.py
Normal file
251
automation/bin/pangolin_upsert_dispatcharr.py
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update the Pangolin resource/target for Dispatcharr.
|
||||
|
||||
Expected env vars:
|
||||
- PANGOLIN_API_URL e.g. https://api.paccoco.com/v1
|
||||
- PANGOLIN_API_KEY bearer token for Pangolin integration API
|
||||
- PANGOLIN_ORG_ID e.g. paccoco
|
||||
Optional env defaults:
|
||||
- PANGOLIN_SITE_ID default 4 (Plausible Deniability)
|
||||
- PANGOLIN_DOMAIN_ID default domain1
|
||||
|
||||
Default desired resource:
|
||||
- host/domain: tv.paccoco.com
|
||||
- site: Plausible Deniability (siteId 4)
|
||||
- target: dispatcharr:9191
|
||||
- Pangolin auth: disabled (sso=false)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
DEFAULT_API_URL = "https://api.paccoco.com/v1"
|
||||
DEFAULT_ORG_ID = "paccoco"
|
||||
DEFAULT_DOMAIN_ID = "domain1"
|
||||
DEFAULT_SITE_ID = 4
|
||||
DEFAULT_NAME = "dispatcharr"
|
||||
DEFAULT_SUBDOMAIN = "tv"
|
||||
DEFAULT_TARGET_IP = "dispatcharr"
|
||||
DEFAULT_TARGET_PORT = 9191
|
||||
USER_AGENT = "doris-pangolin-dispatcharr/1.0"
|
||||
|
||||
|
||||
def env(name: str, default: str | None = None) -> str | None:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def api_request(base_url: str, api_key: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
|
||||
url = base_url.rstrip("/") + path
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = request.Request(url, data=data, headers=headers, method=method.upper())
|
||||
try:
|
||||
with request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
return resp.status, json.loads(raw) if raw else None
|
||||
except error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
payload = raw
|
||||
return exc.code, payload
|
||||
|
||||
|
||||
def fail(msg: str, payload: Any = None) -> int:
|
||||
print(msg, file=sys.stderr)
|
||||
if payload is not None:
|
||||
if isinstance(payload, (dict, list)):
|
||||
print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
|
||||
else:
|
||||
print(payload, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def find_resource(resources: list[dict[str, Any]], *, name: str, full_domain: str) -> dict[str, Any] | None:
|
||||
for resource in resources:
|
||||
if resource.get("fullDomain") == full_domain:
|
||||
return resource
|
||||
for resource in resources:
|
||||
if resource.get("name") == name:
|
||||
return resource
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Upsert Pangolin Dispatcharr resource")
|
||||
parser.add_argument("--api-url", default=env("PANGOLIN_API_URL", DEFAULT_API_URL))
|
||||
parser.add_argument("--api-key", default=env("PANGOLIN_API_KEY"))
|
||||
parser.add_argument("--org-id", default=env("PANGOLIN_ORG_ID", DEFAULT_ORG_ID))
|
||||
site_id_default = env("PANGOLIN_SITE_ID", str(DEFAULT_SITE_ID)) or str(DEFAULT_SITE_ID)
|
||||
parser.add_argument("--site-id", type=int, default=int(site_id_default))
|
||||
parser.add_argument("--domain-id", default=env("PANGOLIN_DOMAIN_ID", DEFAULT_DOMAIN_ID))
|
||||
parser.add_argument("--name", default=DEFAULT_NAME)
|
||||
parser.add_argument("--subdomain", default=DEFAULT_SUBDOMAIN)
|
||||
parser.add_argument("--target-ip", default=DEFAULT_TARGET_IP)
|
||||
parser.add_argument("--target-port", type=int, default=DEFAULT_TARGET_PORT)
|
||||
parser.add_argument("--hc-path", default="/")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.api_key:
|
||||
return fail("Missing PANGOLIN_API_KEY / --api-key")
|
||||
|
||||
full_domain = f"{args.subdomain}.paccoco.com"
|
||||
|
||||
status, resources_resp = api_request(
|
||||
args.api_url,
|
||||
args.api_key,
|
||||
"GET",
|
||||
f"/org/{parse.quote(args.org_id)}/resources?pageSize=200",
|
||||
)
|
||||
if status != 200:
|
||||
return fail("Failed to list Pangolin resources", resources_resp)
|
||||
|
||||
resources = resources_resp.get("data", {}).get("resources", [])
|
||||
resource = find_resource(resources, name=args.name, full_domain=full_domain)
|
||||
|
||||
create_body = {
|
||||
"name": args.name,
|
||||
"subdomain": args.subdomain,
|
||||
"http": True,
|
||||
"protocol": "tcp",
|
||||
"domainId": args.domain_id,
|
||||
}
|
||||
|
||||
if resource is None:
|
||||
if args.dry_run:
|
||||
print(json.dumps({"action": "create_resource", "body": create_body}, indent=2))
|
||||
resource_id = None
|
||||
else:
|
||||
status, create_resp = api_request(
|
||||
args.api_url,
|
||||
args.api_key,
|
||||
"PUT",
|
||||
f"/org/{parse.quote(args.org_id)}/resource",
|
||||
create_body,
|
||||
)
|
||||
if status not in (200, 201):
|
||||
return fail("Failed to create Pangolin resource", create_resp)
|
||||
resource_id = create_resp.get("data", {}).get("resourceId") or create_resp.get("data", {}).get("resource", {}).get("resourceId")
|
||||
if not resource_id:
|
||||
return fail("Create resource succeeded but resourceId was missing", create_resp)
|
||||
status, resource_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
|
||||
if status != 200:
|
||||
return fail("Failed to read back created resource", resource_resp)
|
||||
resource = resource_resp.get("data", {}).get("resource") or resource_resp.get("data") or {}
|
||||
else:
|
||||
resource_id = resource["resourceId"]
|
||||
|
||||
resource_update = {
|
||||
"name": args.name,
|
||||
"niceId": resource.get("niceId") if resource else None,
|
||||
"subdomain": args.subdomain,
|
||||
"ssl": True,
|
||||
"sso": False,
|
||||
"blockAccess": False,
|
||||
"emailWhitelistEnabled": False,
|
||||
"applyRules": False,
|
||||
"domainId": args.domain_id,
|
||||
"enabled": True,
|
||||
"stickySession": False,
|
||||
"tlsServerName": None,
|
||||
"setHostHeader": None,
|
||||
"skipToIdpId": None,
|
||||
"headers": None,
|
||||
"maintenanceModeEnabled": False,
|
||||
"maintenanceModeType": "forced",
|
||||
"maintenanceTitle": None,
|
||||
"maintenanceMessage": None,
|
||||
"maintenanceEstimatedTime": None,
|
||||
"postAuthPath": None,
|
||||
}
|
||||
|
||||
target_body = {
|
||||
"siteId": args.site_id,
|
||||
"ip": args.target_ip,
|
||||
"port": args.target_port,
|
||||
"enabled": True,
|
||||
"hcEnabled": True,
|
||||
"hcPath": args.hc_path,
|
||||
"hcScheme": "http",
|
||||
"hcMode": "http",
|
||||
"hcHostname": args.target_ip,
|
||||
"hcPort": args.target_port,
|
||||
"hcInterval": 30,
|
||||
"hcUnhealthyInterval": 30,
|
||||
"hcTimeout": 10,
|
||||
"hcHeaders": None,
|
||||
"hcFollowRedirects": True,
|
||||
"hcMethod": "GET",
|
||||
"hcStatus": 200,
|
||||
"hcTlsServerName": None,
|
||||
"hcHealthyThreshold": 1,
|
||||
"hcUnhealthyThreshold": 3,
|
||||
"path": None,
|
||||
"pathMatchType": None,
|
||||
"rewritePath": None,
|
||||
"rewritePathType": None,
|
||||
}
|
||||
|
||||
existing_targets = resource.get("targets", []) if resource else []
|
||||
matching_target = None
|
||||
for target in existing_targets:
|
||||
if target.get("siteId") == args.site_id:
|
||||
matching_target = target
|
||||
break
|
||||
|
||||
if args.dry_run:
|
||||
print(json.dumps({
|
||||
"resource_id": resource_id,
|
||||
"full_domain": full_domain,
|
||||
"resource_update": resource_update,
|
||||
"target_action": "update" if matching_target else "create",
|
||||
"target_id": matching_target.get("targetId") if matching_target else None,
|
||||
"target_body": target_body,
|
||||
}, indent=2))
|
||||
return 0
|
||||
|
||||
if resource_id is not None:
|
||||
status, update_resp = api_request(args.api_url, args.api_key, "POST", f"/resource/{resource_id}", resource_update)
|
||||
if status not in (200, 201):
|
||||
return fail("Failed to update Pangolin resource", update_resp)
|
||||
|
||||
if matching_target:
|
||||
target_id = matching_target["targetId"]
|
||||
status, target_resp = api_request(args.api_url, args.api_key, "POST", f"/target/{target_id}", target_body)
|
||||
if status not in (200, 201):
|
||||
return fail("Failed to update Pangolin target", target_resp)
|
||||
else:
|
||||
status, target_resp = api_request(args.api_url, args.api_key, "PUT", f"/resource/{resource_id}/target", target_body)
|
||||
if status not in (200, 201):
|
||||
return fail("Failed to create Pangolin target", target_resp)
|
||||
|
||||
status, final_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
|
||||
if status != 200:
|
||||
return fail("Failed to read back final Pangolin resource", final_resp)
|
||||
payload = final_resp.get("data", {}).get("resource") or final_resp.get("data") or final_resp
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
|
||||
return fail("Unexpected state: resource_id is missing")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user