feat(deploy): emailgen systemd unit + bring orchestrator + emailgen into decnet.target
Plug emailgen into the systemd-supervised fleet: - New deploy/decnet-emailgen.service.j2 mirroring decnet-orchestrator's shape: simple service, restart-on-failure, docker supplementary group (driver shells `docker exec` to drop EMLs into the spool), the same hardening directives as the rest of the fleet. - decnet.target now Wants both decnet-emailgen.service and decnet-orchestrator.service. Orchestrator's absence from the target was a historical oversight — fixing it here while the file is open. `decnet init` already globs deploy/decnet-*.service.j2 so the new unit ships automatically; no init-side change needed. Emailgen-specific env knobs (DECNET_EMAILGEN_LLM, _MODEL, _PERSONAS, _TIMEOUT) are documented in the unit and operator-tunable via /opt/decnet/.env.local.
This commit is contained in:
54
deploy/decnet-emailgen.service.j2
Normal file
54
deploy/decnet-emailgen.service.j2
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=DECNET Emailgen (LLM-driven fake corporate email into IMAP/POP3 deckies)
|
||||||
|
Documentation=https://git.resacachile.cl/anti/DECNET/wiki/Workers#emailgen
|
||||||
|
After=network-online.target decnet-bus.service
|
||||||
|
Wants=network-online.target decnet-bus.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ user }}
|
||||||
|
Group={{ group }}
|
||||||
|
WorkingDirectory={{ install_dir }}
|
||||||
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
|
Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.emailgen.log
|
||||||
|
# LLM backend selection + model are operator-tunable via .env.local:
|
||||||
|
# DECNET_EMAILGEN_LLM=ollama|fake (default: ollama)
|
||||||
|
# DECNET_EMAILGEN_MODEL=llama3.1 (default: llama3.1)
|
||||||
|
# DECNET_EMAILGEN_TIMEOUT=60 (LLM wall-clock cap, seconds)
|
||||||
|
# DECNET_EMAILGEN_PERSONAS=/etc/decnet/email_personas.json
|
||||||
|
# (override the global persona pool)
|
||||||
|
ExecStart={{ venv_dir }}/bin/decnet emailgen run
|
||||||
|
StandardOutput=append:/var/log/decnet/decnet.emailgen.log
|
||||||
|
StandardError=append:/var/log/decnet/decnet.emailgen.log
|
||||||
|
|
||||||
|
# Emailgen drives `docker exec` against IMAP/POP3 decky containers to drop
|
||||||
|
# .eml files into the spool, identical to the SSH-flavoured orchestrator.
|
||||||
|
# It does NOT bind to the network, launch new containers, or write outside
|
||||||
|
# its own logs and install dir.
|
||||||
|
SupplementaryGroups=docker
|
||||||
|
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
|
||||||
|
# Security Hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
# /etc/decnet is included so `decnet emailgen import-personas` can write
|
||||||
|
# the canonical /etc/decnet/email_personas.json without the worker losing
|
||||||
|
# read access (it lives outside ReadWritePaths so writes from the worker
|
||||||
|
# itself are still blocked — only the operator-run CLI writes here).
|
||||||
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -18,7 +18,9 @@ Wants=decnet-bus.service \
|
|||||||
decnet-enrich.service \
|
decnet-enrich.service \
|
||||||
decnet-clusterer.service \
|
decnet-clusterer.service \
|
||||||
decnet-campaign-clusterer.service \
|
decnet-campaign-clusterer.service \
|
||||||
decnet-webhook.service
|
decnet-webhook.service \
|
||||||
|
decnet-orchestrator.service \
|
||||||
|
decnet-emailgen.service
|
||||||
After=decnet-bus.service
|
After=decnet-bus.service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
0
tests/deploy/__init__.py
Normal file
0
tests/deploy/__init__.py
Normal file
93
tests/deploy/test_emailgen_unit.py
Normal file
93
tests/deploy/test_emailgen_unit.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Smoke tests for the emailgen systemd unit + decnet.target wiring.
|
||||||
|
|
||||||
|
These don't exercise systemd (the test host wouldn't have it), they
|
||||||
|
just assert the static contents of ``deploy/decnet-emailgen.service.j2``
|
||||||
|
and ``deploy/decnet.target`` match what ``decnet init`` will install.
|
||||||
|
A regression here would only surface on a fresh host install — cheap
|
||||||
|
to catch at CI time.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent.parent
|
||||||
|
DEPLOY = REPO / "deploy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unit_text() -> str:
|
||||||
|
return (DEPLOY / "decnet-emailgen.service.j2").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def target_text() -> str:
|
||||||
|
return (DEPLOY / "decnet.target").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
# ── unit file ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_exists():
|
||||||
|
assert (DEPLOY / "decnet-emailgen.service.j2").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_uses_run_subcommand(unit_text):
|
||||||
|
"""`decnet emailgen` is a sub-app now — the unit must call `run`,
|
||||||
|
not bare `emailgen` (which still works but is implicit-default and
|
||||||
|
fragile to future changes)."""
|
||||||
|
assert "decnet emailgen run" in unit_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_has_docker_supplementary_group(unit_text):
|
||||||
|
"""Driver shells `docker exec` to drop EMLs in the spool — without
|
||||||
|
this group the worker can't reach the docker socket."""
|
||||||
|
assert "SupplementaryGroups=docker" in unit_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_orders_after_bus(unit_text):
|
||||||
|
"""Bus must come up first so emailgen's heartbeat publishes land."""
|
||||||
|
assert "After=network-online.target decnet-bus.service" in unit_text
|
||||||
|
assert "Wants=network-online.target decnet-bus.service" in unit_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_has_security_hardening(unit_text):
|
||||||
|
"""Same hardening shape as orchestrator.service — defence in depth."""
|
||||||
|
for directive in (
|
||||||
|
"NoNewPrivileges=yes",
|
||||||
|
"ProtectSystem=full",
|
||||||
|
"ProtectHome=read-only",
|
||||||
|
"PrivateTmp=yes",
|
||||||
|
"ProtectKernelTunables=yes",
|
||||||
|
"ProtectKernelModules=yes",
|
||||||
|
"ProtectControlGroups=yes",
|
||||||
|
"RestrictSUIDSGID=yes",
|
||||||
|
"LockPersonality=yes",
|
||||||
|
):
|
||||||
|
assert directive in unit_text, f"missing {directive}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_writes_to_log_dir(unit_text):
|
||||||
|
assert "/var/log/decnet/decnet.emailgen.log" in unit_text
|
||||||
|
assert "ReadWritePaths={{ install_dir }} /var/log/decnet" in unit_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_emailgen_unit_restart_on_failure(unit_text):
|
||||||
|
assert "Restart=on-failure" in unit_text
|
||||||
|
|
||||||
|
|
||||||
|
# ── target wiring ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_wants_emailgen(target_text):
|
||||||
|
"""A fresh `decnet init` must bring up emailgen with the rest of
|
||||||
|
the fleet."""
|
||||||
|
assert "decnet-emailgen.service" in target_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_wants_orchestrator(target_text):
|
||||||
|
"""Orchestrator was an oversight historically — bundling it in here
|
||||||
|
too while we're touching the file."""
|
||||||
|
assert "decnet-orchestrator.service" in target_text
|
||||||
Reference in New Issue
Block a user