# The control plane as a systemd unit. One VM, systemd, binaries from CI — not Kubernetes: a tmctl # child runs for HOURS and holds an exclusive lock on the book's files on the local disk, so any # orchestrator that can move a pod mid-run is hostile to this workload (PLATFORM_DIRECTION §3). # # This unit is also the honest answer to PD-13, orphaned engine processes: every tmctl the service # spawns lives in THIS unit's cgroup, so a restart or a crash of the platform cannot leave a # translation running with nobody watching it. The supervisor's process group handles the ordinary # stop; the cgroup handles the case where the supervisor is no longer there to ask. [Unit] Description=TextMachine control plane After=network-online.target postgresql.service Wants=network-online.target [Service] # Type=exec, not notify: the binary does not speak sd_notify, and claiming it does would make # systemd wait for a readiness signal that never comes. Type=exec ExecStart=/usr/local/bin/tmplatformd User=tmplatform Group=tmplatform # KillMode=mixed: SIGTERM to the main process only, so the platform runs its own drain and stops # its children the way the engine expects; SIGKILL to everything left when the timeout runs out. KillMode=mixed KillSignal=SIGTERM # Longer than the platform's own drain (15s) plus the engine's stop grace (30s), or systemd would # SIGKILL a tmctl mid-shutdown and leave its project lock behind. TimeoutStopSec=90 Restart=on-failure RestartSec=5s # Secrets as credentials, not as environment: an environment variable is visible in # /proc//environ and is inherited by every tmctl child. The config reads *_FILE first. LoadCredential=dsn:/etc/tmplatform/dsn LoadCredential=oidc_client_secret:/etc/tmplatform/oidc_client_secret Environment=TM_PLATFORM_DSN_FILE=%d/dsn Environment=TM_PLATFORM_OIDC_CLIENT_SECRET_FILE=%d/oidc_client_secret EnvironmentFile=/etc/tmplatform/env # Books live outside the repository and outside /var/lib by owner's decision (~/books); the unit # gets the one directory it may write and nothing else. ReadWritePaths=/srv/textmachine StateDirectory=tmplatform # Sandboxing. Free, and it bounds what a compromised process reaches. ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictSUIDSGID=yes RestrictRealtime=yes LockPersonality=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # These bound the unit's CGROUP, and by the argument at the top of this file every tmctl the # platform spawns lives in it. So they do not bound "the control plane" — they bound the control # plane plus every run in flight, together (PD-55). Two consequences follow, and both are decided # here rather than discovered in production: # # 1. The ceiling is sized as a machine backstop, not as a service bound. systemd.resource-control(5) # calls MemoryMax= "the last line of defense"; as a percentage it needs no knowledge of the box. # A 2G figure would have been a bound on the RUNS, and the OOM killer invoked inside the unit # picks the largest process — the engine, holding an exclusive lock on a book's files. That is # precisely the SIGKILL that TimeoutStopSec= above exists to avoid. # 2. OOMPolicy is set explicitly. The system default is `stop`: one OOM-killed tmctl would take the # control plane and every other run down with it, then land the unit in oom-kill failed state for # Restart= to pick up. `continue` logs the kill and keeps the service running, so the supervisor # survives to observe the child's exit. (What it does with that exit is the worker's job and the # worker does not exist yet — nothing calls Settle today, PD-43.) OOMScoreAdjust= cannot help # here: it is inherited by the children, so it cannot tell the engine apart from the platform. # # Bounding ONE run is the worker's job when it exists — a transient scope per run, not a knob here. MemoryMax=80% OOMPolicy=continue # Platform plus concurrent runs, each a Go process with a few dozen threads: room for roughly a # dozen runs on one VM, which is more than a single box will carry. TasksMax=512 LimitNOFILE=8192 [Install] WantedBy=multi-user.target