refactor: make engine domain-agnostic — identity from node.env, no hardcoded domain

Drop the hardcoded-domain FQDN regex and every hardcoded domain from commons.
Node identity is now explicit in node.env: APEX_NODE_FQDN (real FQDN,
traefik Host()), APEX_NODE_HOST (public host name — backup --host,
obs/notify instance), APEX_SUBNET. The short name (log prefix, sync/<node>
branch, messages) is derived as the first host label.

notify instance is just the host (no hardcoded prefix); the git bot identity
moves to APEX_GIT_AUTHOR_NAME/EMAIL (node.env) with generic defaults. Alloy
node+instance labels use APEX_NODE_HOST. Fallbacks keep a node working if it
omits a var (host<-fqdn<-hostname).

BREAKING CHANGE: removes APEX_NODE and APEX_CLUSTER env vars; node name is now derived from APEX_NODE_FQDN
This commit is contained in:
deytenit committed 2026-07-08 09:32:20 +03:00
1 parent 53d3bedca6
commit 39d419bfe6
13 files changed
+105 -107

No files matched your search

+2 -2
View File
@@ -14,7 +14,7 @@ action to its bash source and the behaviors that must match.
| configure | (new umbrella) | run base,ufw,crowdsec,cron,systemd,routing in order | | configure | (new umbrella) | run base,ufw,crowdsec,cron,systemd,routing in order |
| sync/repository | actions/sync/repository | sync/<node> branch; add -A + commons submodule; commit; rebase origin/main (was trunk in the monorepo); force-with-lease; telegram | | sync/repository | actions/sync/repository | sync/<node> branch; add -A + commons submodule; commit; rebase origin/main (was trunk in the monorepo); force-with-lease; telegram |
| sync/packages | actions/sync/packages | apt --just-print upgrade Inst list; docker+skopeo digest compare; telegram info | | sync/packages | actions/sync/packages | apt --just-print upgrade Inst list; docker+skopeo digest compare; telegram info |
| backup/run | actions/sync/tiers (restic-backup) | init-if-needed; label-driven backup via resticontainer (discovers `restic.*`-labelled services, runs pre-hooks/stops, snapshots the resolved host paths) --host <node>.apex.ermnvldmr.com --tag biweekly --compression max; guard on a fresh snapshot; forget --prune 7/4/12; telegram | | backup/run | actions/sync/tiers (restic-backup) | init-if-needed; label-driven backup via resticontainer (discovers `restic.*`-labelled services, runs pre-hooks/stops, snapshots the resolved host paths) --host $APEX_NODE_HOST --tag biweekly --compression max; guard on a fresh snapshot; forget --prune 7/4/12; telegram |
| tiers/link | actions/tiers/link | node + per-project @tierN symlinks; shared/ links; .env symlink | | tiers/link | actions/tiers/link | node + per-project @tierN symlinks; shared/ links; .env symlink |
| tiers/useradd | actions/tiers/useradd | noroot-<proj> system users; noroot-shared group; APEX_UID/GID block in .env | | tiers/useradd | actions/tiers/useradd | noroot-<proj> system users; noroot-shared group; APEX_UID/GID block in .env |
| tiers/chown | actions/tiers/chown | {tier}/{proj}=uid:gid; {tier}/shared/{proj}=uid:sgid+sticky; base=root:root | | tiers/chown | actions/tiers/chown | {tier}/{proj}=uid:gid; {tier}/shared/{proj}=uid:sgid+sticky; base=root:root |
@@ -24,7 +24,7 @@ action to its bash source and the behaviors that must match.
| compose | actions/compose | apex core first then services alpha; reverse on down; --dry-run; extra passthrough | | compose | actions/compose | apex core first then services alpha; reverse on down; --dry-run; extra passthrough |
## Deliberate deltas (rename + fixes) ## Deliberate deltas (rename + fixes)
- root->apex everywhere (CLI, container names apex-*, telegram instance com.ermnvldmr.apex.*, ufw markers APEX.*, networks direct/enclave/socket). - root->apex everywhere (CLI, container names apex-*, telegram instance = APEX_NODE_HOST, ufw markers APEX.*, networks direct/enclave/socket).
- No <node> arg; identity from FQDN + node.env. - No <node> arg; identity from FQDN + node.env.
- backup superseded by the resticontainer migration (see below): the old whole-tier-root - backup superseded by the resticontainer migration (see below): the old whole-tier-root
restic snapshot is replaced by per-service, `restic.*`-label-driven backups. restic snapshot is replaced by per-service, `restic.*`-label-driven backups.
+6 -6
View File
@@ -1,9 +1,9 @@
# apex.ermnvldmr.com # apex
The **commons** of the apex fleet: a stock-python engine (`apex <group>/<action>`) and The **commons** of an apex fleet: a stock-python engine (`apex <group>/<action>`) and
the shared core docker-compose, consumed by every node as the `commons/` git submodule. the shared core docker-compose, consumed by every node as the `commons/` git submodule.
The framework assumes no particular domain — every node declares its own identity in
Full documentation: [docs.ermnvldmr.com/en/apex](https://docs.ermnvldmr.com/en/apex/). `node.env`.
## Layout ## Layout
- `apex` — launcher (python3, stdlib only); node `init.sh` symlinks it onto `PATH`. - `apex` — launcher (python3, stdlib only); node `init.sh` symlinks it onto `PATH`.
@@ -17,8 +17,8 @@ Node-specific tooling lives in each node repo's `proprietaries/` — commons hol
what every node shares. what every node shares.
## Usage (on a node) ## Usage (on a node)
`apex` acts on the **local checkout** — no `<node>` argument. Identity comes from the `apex` acts on the **local checkout** — no `<node>` argument. Identity comes from
host FQDN (`<node>.a<x>.apex.ermnvldmr.com`) and `node.env`. `node.env` (`APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET`).
- `apex` / `apex --help` — list actions - `apex` / `apex --help` — list actions
- `apex configure/ufw` — render + apply this node's ufw config - `apex configure/ufw` — render + apply this node's ufw config
- `apex configure` — run every `configure/*` in order - `apex configure` — run every `configure/*` in order
+7 -7
View File
@@ -16,7 +16,7 @@ TITLE = "Restic Host Backup"
def run(ctx, args): def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
core = ctx.paths.core core = ctx.paths.core
host = f"{node}.apex.ermnvldmr.com" # restic --host: the per-node public name (matches the bucket) host = ctx.node.host # restic --host: the node's public host name (APEX_NODE_HOST)
tag = "biweekly" # restic --tag: snapshot cadence (this cron fires on the 5th & 20th) tag = "biweekly" # restic --tag: snapshot cadence (this cron fires on the 5th & 20th)
if not os.path.isfile(os.path.join(core, "docker-compose.yml")): if not os.path.isfile(os.path.join(core, "docker-compose.yml")):
log.error(f"Core compose not found in {core}"); raise SystemExit(1) log.error(f"Core compose not found in {core}"); raise SystemExit(1)
@@ -47,12 +47,12 @@ def run(ctx, args):
if restic("snapshots", check=False).returncode == 0: if restic("snapshots", check=False).returncode == 0:
log.info("Restic repository reachable.") log.info("Restic repository reachable.")
elif restic("init", check=False).returncode != 0: elif restic("init", check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic init failed", node); raise SystemExit(1) ctx.notify.error(TITLE, url, "restic init failed"); raise SystemExit(1)
before = host_snapshot_ids() before = host_snapshot_ids()
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...") log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
if restic("backup", "--host", host, "--tag", tag, "--compression", "max", if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False).returncode != 0: check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic backup failed", node); raise SystemExit(1) ctx.notify.error(TITLE, url, "restic backup failed"); raise SystemExit(1)
# Guard: the label-driven backup is a silent no-op if no service carries the # Guard: the label-driven backup is a silent no-op if no service carries the
# `restic.enable` label (resticontainer finds zero paths and skips restic). # `restic.enable` label (resticontainer finds zero paths and skips restic).
# Prove a fresh snapshot for this host actually landed before we prune/notify. # Prove a fresh snapshot for this host actually landed before we prune/notify.
@@ -60,18 +60,18 @@ def run(ctx, args):
if before is not None and after is not None and not (after - before): if before is not None and after is not None and not (after - before):
log.error("No new snapshot produced — refusing to report success.") log.error("No new snapshot produced — refusing to report success.")
ctx.notify.error(TITLE, url, ctx.notify.error(TITLE, url,
"backup produced no new snapshot (no restic.* labels discovered?)", node) "backup produced no new snapshot (no restic.* labels discovered?)")
raise SystemExit(1) raise SystemExit(1)
log.info("Applying retention (forget --prune)...") log.info("Applying retention (forget --prune)...")
if restic("forget", "--prune", "--keep-daily", "7", "--keep-weekly", "4", if restic("forget", "--prune", "--keep-daily", "7", "--keep-weekly", "4",
"--keep-monthly", "12", check=False).returncode != 0: "--keep-monthly", "12", check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic forget/prune failed", node); raise SystemExit(1) ctx.notify.error(TITLE, url, "restic forget/prune failed"); raise SystemExit(1)
log.success("Restic backup completed.") log.success("Restic backup completed.")
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}", node): if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}"):
raise SystemExit(1) # bash exited 1 when the success telegram failed to send raise SystemExit(1) # bash exited 1 when the success telegram failed to send
except SystemExit: except SystemExit:
raise raise
except Exception as e: except Exception as e:
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {e}", node) ctx.notify.error(TITLE, url, f"backup failed: {e}")
raise SystemExit(1) raise SystemExit(1)
+3 -3
View File
@@ -15,13 +15,13 @@ def run(ctx, args):
raise raise
except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Critical error: {e}", node) ctx.notify.error(TITLE, url, f"Critical error: {e}")
raise SystemExit(1) raise SystemExit(1)
def _check(ctx, args, log, node, url, s): def _check(ctx, args, log, node, url, s):
if not os.path.isfile("/etc/debian_version"): if not os.path.isfile("/etc/debian_version"):
log.error("Requires a Debian-based system.") log.error("Requires a Debian-based system.")
ctx.notify.error(TITLE, url, "Requires a Debian-based system.", node) ctx.notify.error(TITLE, url, "Requires a Debian-based system.")
raise SystemExit(1) raise SystemExit(1)
log.info("Updating package lists...") log.info("Updating package lists...")
@@ -66,6 +66,6 @@ def _check(ctx, args, log, node, url, s):
images = (f"*Images ({len(outdated)}):* " + ", ".join(f"`{i}`" for i in outdated)) if outdated else "*Images:* _none_" images = (f"*Images ({len(outdated)}):* " + ", ".join(f"`{i}`" for i in outdated)) if outdated else "*Images:* _none_"
# A failed send exits 1 (bash: telegram_info's return 1 tripped set -e). # A failed send exits 1 (bash: telegram_info's return 1 tripped set -e).
if not ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}", node): if not ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}"):
raise SystemExit(1) raise SystemExit(1)
log.success("Security update check completed.") log.success("Security update check completed.")
+9 -5
View File
@@ -3,6 +3,7 @@
import os import os
from datetime import datetime from datetime import datetime
from engine.descriptor import Meta, Arg from engine.descriptor import Meta, Arg
from engine.identity import read_env
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.", METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")]) args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
@@ -11,9 +12,12 @@ TITLE = "Annual Remote Sync"
def run(ctx, args): def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
repo, commons = ctx.paths.repo_root, ctx.paths.commons repo, commons = ctx.paths.repo_root, ctx.paths.commons
ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults)
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
env = dict(os.environ, env = dict(os.environ,
GIT_AUTHOR_NAME="Adam Jensen [bot]", GIT_AUTHOR_EMAIL="adam@ermnvldmr.com", GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
GIT_COMMITTER_NAME="Adam Jensen [bot]", GIT_COMMITTER_EMAIL="adam@ermnvldmr.com") GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
def git(*a, check=True): def git(*a, check=True):
return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env) return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env)
@@ -45,16 +49,16 @@ def run(ctx, args):
if git("rebase", "origin/main", check=False).returncode != 0: if git("rebase", "origin/main", check=False).returncode != 0:
log.error("Rebase conflict — aborting rebase; manual intervention required.") log.error("Rebase conflict — aborting rebase; manual intervention required.")
git("rebase", "--abort", check=False) git("rebase", "--abort", check=False)
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.", node) ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.")
raise SystemExit(1) raise SystemExit(1)
git("submodule", "update", "--init", "--recursive") git("submodule", "update", "--init", "--recursive")
git("push", "--force-with-lease", "origin", branch) git("push", "--force-with-lease", "origin", branch)
log.success(f"Pushed {branch}.") log.success(f"Pushed {branch}.")
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed.", node): if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."):
raise SystemExit(1) raise SystemExit(1)
except SystemExit: except SystemExit:
raise raise
except Exception as e: except Exception as e:
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Sync failed: {e}", node) ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1) raise SystemExit(1)
+2 -2
View File
@@ -29,7 +29,7 @@ def run(ctx, args):
ctx.sys.sudo(["find", abs_dest, "-type", "f", "-name", "*.pem", "-exec", "chmod", "600", "{}", "+"]) ctx.sys.sudo(["find", abs_dest, "-type", "f", "-name", "*.pem", "-exec", "chmod", "600", "{}", "+"])
ctx.sys.sudo(["chown", "-R", f"{os.getuid()}:{os.getgid()}", abs_dest]) ctx.sys.sudo(["chown", "-R", f"{os.getuid()}:{os.getgid()}", abs_dest])
log.success("Certificates extracted.") log.success("Certificates extracted.")
ctx.notify.success(TITLE, url, f"Certificates extracted for {node}", node) ctx.notify.success(TITLE, url, f"Certificates extracted for {node}")
except Exception as e: except Exception as e:
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}", node) log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
raise SystemExit(1) raise SystemExit(1)
+20 -9
View File
@@ -1,13 +1,12 @@
// ============================================================================ // ============================================================================
// APEX fleet observability agent — per-node Grafana Alloy config (shared). // APEX fleet observability agent — per-node Grafana Alloy config (shared).
// Committed, env-parameterized: every node runs this SAME file; per-node values // Committed, env-parameterized: every node runs this SAME file; per-node values
// come from sys.env(...) (APEX_NODE, push URLs, obs basic-auth creds). // come from sys.env(...) (APEX_NODE_HOST, push URLs, obs basic-auth creds).
// Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1). // Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1).
// ============================================================================ // ============================================================================
// Model: PUSH. Metrics -> remote_write to the central Prometheus (on icarus); // Model: PUSH. Metrics -> remote_write to a central Prometheus; logs -> loki.write
// logs -> loki.write to the central Loki (on icarus). daedalus/morpheus reach // to a central Loki. Push endpoints + basic-auth are per-node env
// them through the public gfn.ermnvldmr.com front (authelia-bypassed + basicauth // (APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, APEX_OBS_BASIC_AUTH_USER/PASS).
// on /loki and /prometheus/api/v1/write); icarus resolves gfn locally.
// ============================================================================ // ============================================================================
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -34,7 +33,14 @@ discovery.relabel "node_metrics" {
rule { rule {
action = "replace" action = "replace"
target_label = "node" target_label = "node"
replacement = sys.env("APEX_NODE") replacement = sys.env("APEX_NODE_HOST")
}
// Stamp a stable `instance` (the node's public host) instead of the default,
// which is the Alloy container id/address.
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
} }
} }
discovery.relabel "container_metrics" { discovery.relabel "container_metrics" {
@@ -42,7 +48,12 @@ discovery.relabel "container_metrics" {
rule { rule {
action = "replace" action = "replace"
target_label = "node" target_label = "node"
replacement = sys.env("APEX_NODE") replacement = sys.env("APEX_NODE_HOST")
}
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
} }
} }
@@ -77,7 +88,7 @@ loki.source.journal "systemd" {
path = "/var/log/journal" path = "/var/log/journal"
max_age = "12h" max_age = "12h"
labels = { labels = {
node = sys.env("APEX_NODE"), node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal", job = "systemd-journal",
} }
forward_to = [loki.write.default.receiver] forward_to = [loki.write.default.receiver]
@@ -98,7 +109,7 @@ discovery.relabel "container_logs" {
rule { rule {
action = "replace" action = "replace"
target_label = "node" target_label = "node"
replacement = sys.env("APEX_NODE") replacement = sys.env("APEX_NODE_HOST")
} }
} }
loki.source.docker "containers" { loki.source.docker "containers" {
+1 -1
View File
@@ -202,7 +202,7 @@ services:
- /etc/machine-id:/etc/machine-id:ro - /etc/machine-id:/etc/machine-id:ro
environment: environment:
<<: *shared-environment <<: *shared-environment
APEX_NODE: "${APEX_NODE?error}" APEX_NODE_HOST: "${APEX_NODE_HOST?error}" # node + instance metric/log labels
# push targets + machine auth (per-node scalars in apex.env / secret .env) # push targets + machine auth (per-node scalars in apex.env / secret .env)
APEX_LOKI_PUSH_URL: "${APEX_LOKI_PUSH_URL?error}" APEX_LOKI_PUSH_URL: "${APEX_LOKI_PUSH_URL?error}"
APEX_PROM_PUSH_URL: "${APEX_PROM_PUSH_URL?error}" APEX_PROM_PUSH_URL: "${APEX_PROM_PUSH_URL?error}"
+3 -4
View File
@@ -81,7 +81,7 @@ class Context:
self.ufw = Ufw(self.log, self.sys, self.host) self.ufw = Ufw(self.log, self.sys, self.host)
self.crowdsec = Crowdsec(self.log, self.sys, self.tpl, self.host) self.crowdsec = Crowdsec(self.log, self.sys, self.tpl, self.host)
self.systemd = Systemd(self.log, self.sys, self.tpl, self.host) self.systemd = Systemd(self.log, self.sys, self.tpl, self.host)
self.notify = Notify(self.log, name) self.notify = Notify(self.log, node.host if node else "global")
self._shadowed = shadowed self._shadowed = shadowed
self._commons = None self._commons = None
@@ -98,10 +98,9 @@ class Context:
v["APEX_REPO_ROOT"] = self.paths.repo_root v["APEX_REPO_ROOT"] = self.paths.repo_root
if self.node: if self.node:
v.update({ v.update({
"APEX_NODE": self.node.name, "APEX_NODE_HOST": self.node.host,
"APEX_CLUSTER": self.node.cluster,
"APEX_SUBNET": self.node.subnet,
"APEX_NODE_FQDN": self.node.fqdn, "APEX_NODE_FQDN": self.node.fqdn,
"APEX_SUBNET": self.node.subnet,
"APEX_TIER1": self.paths.tier1, "APEX_TIER2": self.paths.tier2, "APEX_TIER1": self.paths.tier1, "APEX_TIER2": self.paths.tier2,
"APEX_TIER3": self.paths.tier3, "APEX_TIER3": self.paths.tier3,
"APEX_TIER1_SHARED": self.paths.stier1, "APEX_TIER1_SHARED": self.paths.stier1,
+24 -36
View File
@@ -1,29 +1,29 @@
# engine/identity.py # engine/identity.py
"""Node identity from host FQDN + node.env (replaces bash root_require_node). """Node identity — fully explicit, from node.env. The framework assumes no domain
and no clustering: a node declares its real FQDN and its public host name, and the
short `name` is just the first label. The OS hostname is only a fallback for the FQDN.
Decision B: the FQDN <node>.a<x>.apex.ermnvldmr.com keeps the cluster; we parse node.env keys:
node+cluster from it. node.env owns APEX_SUBNET and mirrors APEX_CLUSTER; hostname APEX_NODE_FQDN real FQDN (traefik Host() routing). default: OS hostname
wins on drift. Falls back to the repo directory name when the FQDN is not yet APEX_NODE_HOST public host name (backup --host, obs/notify default: APEX_NODE_FQDN
apex-shaped (mid-cutover). `instance`); often the FQDN minus a cluster
segment, but that's the node's choice.
APEX_SUBNET the node's /24 for the `direct` network. required
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import re
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
_FQDN = re.compile(r"^(?P<node>[^.]+)\.(?P<cluster>a\d+)\.apex\.ermnvldmr\.com$")
_DIRNAME = re.compile(r"^(?P<node>[^.]+)\.apex\.ermnvldmr\.com")
@dataclass @dataclass
class Node: class Node:
name: str name: str # short label (first segment of the host), e.g. "icarus" — logs, branch, messages
cluster: str subnet: str # the node's direct-network /24
subnet: str fqdn: str # real FQDN — traefik Host() routing
fqdn: str host: str # public host name — backup --host, obs `instance`, notify instance
def read_env(path: str) -> Dict[str, str]: def read_env(path: str) -> Dict[str, str]:
@@ -41,36 +41,24 @@ def read_env(path: str) -> Dict[str, str]:
return out return out
def resolve(fqdn: str, repo_root: str) -> Tuple[Node, List[str]]: def resolve(hostname: str, repo_root: str) -> Tuple[Node, List[str]]:
"""Return (Node, warnings). Exits 66 (EX_NOINPUT) if APEX_SUBNET is missing.""" """Return (Node, warnings). Exits 66 (EX_NOINPUT) if APEX_SUBNET is missing."""
warns: List[str] = [] warns: List[str] = []
env = read_env(os.path.join(repo_root, "node.env")) env = read_env(os.path.join(repo_root, "node.env"))
m = _FQDN.match(fqdn or "") fqdn = env.get("APEX_NODE_FQDN", "") or (hostname or "").strip()
if m: if not fqdn:
name, cluster = m.group("node"), m.group("cluster") sys.stderr.write("identity: APEX_NODE_FQDN missing from node.env and no hostname\n")
env_cluster = env.get("APEX_CLUSTER") raise SystemExit(66)
if env_cluster and env_cluster != cluster: if not env.get("APEX_NODE_FQDN"):
warns.append( warns.append(f"APEX_NODE_FQDN not set in node.env; using hostname {fqdn!r}.")
f"cluster drift: hostname says {cluster!r}, node.env says "
f"{env_cluster!r}; hostname wins." host = env.get("APEX_NODE_HOST", "") or fqdn # node's public name; defaults to the FQDN
) name = host.split(".")[0] # short label for logs/branch/messages
else:
# Fallback: derive node from repo dir name; cluster from node.env.
warns.append(
f"FQDN {fqdn!r} is not apex-shaped; falling back to repo dir + node.env."
)
base = os.path.basename(os.path.realpath(repo_root))
dm = _DIRNAME.match(base)
name = dm.group("node") if dm else base
cluster = env.get("APEX_CLUSTER", "")
if not cluster:
sys.stderr.write("identity: no cluster in FQDN or node.env\n")
raise SystemExit(66)
subnet = env.get("APEX_SUBNET", "") subnet = env.get("APEX_SUBNET", "")
if not subnet: if not subnet:
sys.stderr.write("identity: APEX_SUBNET missing from node.env\n") sys.stderr.write("identity: APEX_SUBNET missing from node.env\n")
raise SystemExit(66) raise SystemExit(66)
return Node(name=name, cluster=cluster, subnet=subnet, fqdn=fqdn), warns return Node(name=name, subnet=subnet, fqdn=fqdn, host=host), warns
+4 -5
View File
@@ -1,9 +1,8 @@
# engine/lib/notify.py # engine/lib/notify.py
"""ctx.notify — Telegram notifications (port of root_core_telegram*). """ctx.notify — Telegram notifications.
instance renamed com.ermnvldmr.root.<node> -> com.ermnvldmr.apex.<node>. POSTs The alert `instance` is the node's public host (APEX_NODE_HOST), passed in from the
application/x-www-form-urlencoded via urllib (stdlib) — functionally identical to context. POSTs application/x-www-form-urlencoded via urllib (stdlib).
the bash curl multipart for Telegram sendMessage.
""" """
from __future__ import annotations from __future__ import annotations
import html import html
@@ -20,7 +19,7 @@ def build_text(title: str, message: str, node: str, level: str, ts: str) -> str:
severity = "critical" severity = "critical"
elif level == "WARN": elif level == "WARN":
severity = "warning" severity = "warning"
instance = f"com.ermnvldmr.apex.{node}" instance = node
msg = message if len(message) <= 200 else message[:200] + "..." msg = message if len(message) <= 200 else message[:200] + "..."
msg = html.escape(msg) msg = html.escape(msg)
safe_title = html.escape(title) safe_title = html.escape(title)
+20 -23
View File
@@ -3,40 +3,37 @@ import os, tempfile, unittest
from engine.identity import resolve, Node from engine.identity import resolve, Node
class TestIdentity(unittest.TestCase): class TestIdentity(unittest.TestCase):
def _envfile(self, text, dirname=None): def _envfile(self, text):
d = tempfile.mkdtemp() d = tempfile.mkdtemp()
if dirname:
d = os.path.join(d, dirname)
os.makedirs(d)
with open(os.path.join(d, "node.env"), "w") as f: with open(os.path.join(d, "node.env"), "w") as f:
f.write(text) f.write(text)
return d return d
def test_fqdn_is_source_of_truth(self): def test_explicit_host_and_fqdn(self):
repo = self._envfile("APEX_CLUSTER=a1\nAPEX_SUBNET=198.18.11.0/24\n") repo = self._envfile("APEX_NODE_FQDN=icarus.a1.example.com\n"
n, warns = resolve("daedalus.a1.apex.ermnvldmr.com", repo) "APEX_NODE_HOST=icarus.example.com\n"
self.assertEqual((n.name, n.cluster, n.subnet), ("daedalus", "a1", "198.18.11.0/24")) "APEX_SUBNET=198.18.17.0/24\n")
n, warns = resolve("build-laptop", repo) # hostname ignored when node.env has FQDN
self.assertEqual((n.name, n.host, n.fqdn, n.subnet),
("icarus", "icarus.example.com", "icarus.a1.example.com", "198.18.17.0/24"))
self.assertEqual(warns, []) self.assertEqual(warns, [])
def test_cluster_drift_warns_hostname_wins(self): def test_host_defaults_to_fqdn(self):
repo = self._envfile("APEX_CLUSTER=a9\nAPEX_SUBNET=198.18.11.0/24\n") repo = self._envfile("APEX_NODE_FQDN=n1.example.com\nAPEX_SUBNET=10.0.0.0/24\n")
n, warns = resolve("daedalus.a1.apex.ermnvldmr.com", repo) n, warns = resolve("ignored", repo)
self.assertEqual(n.cluster, "a1") # hostname wins self.assertEqual((n.name, n.host, n.fqdn), ("n1", "n1.example.com", "n1.example.com"))
self.assertTrue(any("drift" in w.lower() for w in warns)) self.assertEqual(warns, [])
def test_fallback_when_fqdn_not_apex_shaped(self): def test_fqdn_falls_back_to_hostname(self):
# repo dir basename carries the node name in fallback (real host layout: repo = self._envfile("APEX_SUBNET=10.0.0.0/24\n")
# $HOME/morpheus.apex.ermnvldmr.com) n, warns = resolve("box.local", repo)
repo = self._envfile("APEX_CLUSTER=a2\nAPEX_SUBNET=198.18.13.0/24\n", self.assertEqual((n.name, n.host, n.fqdn), ("box", "box.local", "box.local"))
dirname="morpheus.apex.ermnvldmr.com") self.assertTrue(any("APEX_NODE_FQDN" in w for w in warns))
n, warns = resolve("build-laptop", repo)
self.assertEqual((n.name, n.cluster, n.subnet), ("morpheus", "a2", "198.18.13.0/24"))
self.assertTrue(any("fqdn" in w.lower() for w in warns))
def test_missing_subnet_is_error(self): def test_missing_subnet_is_error(self):
repo = self._envfile("APEX_CLUSTER=a1\n") repo = self._envfile("APEX_NODE_FQDN=n1.example.com\n")
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
resolve("daedalus.a1.apex.ermnvldmr.com", repo) resolve("n1.example.com", repo)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+4 -4
View File
@@ -3,10 +3,10 @@ import unittest
from engine.lib.notify import build_text from engine.lib.notify import build_text
class TestNotify(unittest.TestCase): class TestNotify(unittest.TestCase):
def test_instance_uses_apex_prefix(self): def test_instance_is_the_host(self):
t = build_text("Title", "msg", "daedalus", "INFO", "2026-07-04 00:00:00 MSK") t = build_text("Title", "msg", "icarus.example.com", "INFO", "2026-07-04 00:00:00 MSK")
self.assertIn("com.ermnvldmr.apex.daedalus", t) self.assertIn("icarus.example.com", t)
self.assertIn("#instance_com_ermnvldmr_apex_daedalus", t) self.assertIn("#instance_icarus_example_com", t) # dots -> underscores in the hashtag
def test_html_escaped_and_truncated(self): def test_html_escaped_and_truncated(self):
t = build_text("A & B", "<script>" + "x" * 300, "d", "ERROR", "T") t = build_text("A & B", "<script>" + "x" * 300, "d", "ERROR", "T")