Compare commits

..

No commits in common. "master" and "htz.mails_debian_13_squash" have entirely different histories.

183 changed files with 1549 additions and 12702 deletions

4
.envrc
View file

@ -2,6 +2,6 @@
PATH_add bin PATH_add bin
layout uv source_env ~/.local/share/direnv/pyenv
source_env ~/.local/share/direnv/venv
source_env ~/.local/share/direnv/bundlewrap source_env ~/.local/share/direnv/bundlewrap

5
.gitignore vendored
View file

@ -2,8 +2,3 @@
.venv .venv
.cache .cache
*.pyc *.pyc
.bw_debug_history
# CocoIndex Code (ccc)
/.cocoindex_code/
# bundlewrap git_deploy local-mirror map (operator-specific paths)
git_deploy_repos

108
AGENTS.md
View file

@ -1,108 +0,0 @@
# ckn-bw — agent & contributor guide
## What this repo is
A [BundleWrap](https://bundlewrap.org/) configuration-management repo
for ~22 personal/family-infra nodes. Nodes, groups, and bundles are
defined in plain Python; `bw apply` deploys the resulting state to
real machines.
Note: the root `README.md` is the maintainer's personal scratchpad,
not project documentation. Onboarding lives **here**, in `AGENTS.md`.
## Quickstart for agents
Five rules; follow these and you won't break things:
1. **Read-only by default.** Never run `bw apply`, `bw run`, or
`bw lock` without explicit user request — even with `-i`. Stick
to `bw test`, `bw nodes`, `bw groups`, `bw items`,
`bw metadata`, `bw hash`, `bw verify`, `bw debug`. See
[`docs/agents/commands.md`](docs/agents/commands.md) and the
fork's [safety envelope](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md).
2. **Never echo decrypted secrets.** Don't print, paste, or log the
value behind a `!password_for:`, `!decrypt:`, or
`!32_random_bytes_as_base64_for:` magic string — not even from
`bw debug` exploration. See
[`conventions.md#secrets`](docs/agents/conventions.md#secrets).
3. **Don't touch the do-not-modify list.** `.secrets.cfg*`, `.venv`,
`.cache`, `.bw_debug_history`, root `README.md`. Treat
`hooks/` and `items/` (custom item types) with extra care: a
broken hook or item type breaks every `bw` command repo-wide.
4. **Use the fork.** Bundlewrap is pinned to the `main` branch of
[`github.com/CroneKorkN/bundlewrap`](https://github.com/CroneKorkN/bundlewrap)
via `[tool.uv.sources]` in `pyproject.toml`; `uv sync` (run by
direnv on entry) installs it. Behavior tracks the fork's `main`;
the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
is the canonical bundlewrap-language reference. See
[`conventions.md#bundlewrap-version`](docs/agents/conventions.md#bundlewrap-version).
5. **Prefer adding helpers to `libs/`** over duplicating logic across
bundles. Repo-wide helpers go in
[`libs/`](libs/AGENTS.md), reachable as `repo.libs.<x>`.
## Layout
| Dir | What's there |
|---|---|
| [`bundles/`](bundles/AGENTS.md) | 103 bundles. One subdir per bundle (`items.py`, `metadata.py`, `files/`). |
| [`nodes/`](nodes/AGENTS.md) | One file per node (~22). `eval()`-loaded; demagified through `repo.vault`. |
| [`groups/`](groups/AGENTS.md) | Group definitions, organized by axis (`applications/`, `locations/`, `machine/`, `os/`). |
| [`libs/`](libs/AGENTS.md) | Shared Python helpers reachable as `repo.libs.<modulename>`. |
| [`hooks/`](hooks/AGENTS.md) | bw lifecycle hooks (`apply_start`, `test`, `node_apply_start`, …). |
| [`data/`](data/AGENTS.md) | Out-of-bundle data assets (apt keys, grafana dashboards, …). |
| [`items/`](items/AGENTS.md) | Custom item types (currently `download:`). |
| [`bin/`](bin/AGENTS.md) | Operator scripts; not invoked by bundlewrap. |
| [`docs/agents/`](docs/agents/conventions.md) | Repo conventions and command deltas. |
## How nodes, groups, and bundles fit together
- A **node** (`nodes/<location>.<role>.py`) declares the groups it
belongs to and any node-local bundles + metadata overrides.
- A **group** (`groups/<axis>/<x>.py`) attaches bundles and shared
metadata to its members. Groups inherit via `supergroups`.
- A **bundle** (`bundles/<x>/`) is one chunk of configuration:
`items.py` produces the items (files, services, packages),
`metadata.py` declares `defaults` and `@metadata_reactor` functions
that derive metadata from other metadata.
- The repo-root loaders (`nodes.py`, `groups.py`) walk these dirs and
`eval()` each file. `nodes.py` additionally **demagifies** the
result, resolving `!password_for:` etc. through `repo.vault`. See
[`conventions.md#eval-loaded-node-and-group-files`](docs/agents/conventions.md#eval-loaded-node-and-group-files)
for the constraints this places on editors.
- Metadata merges along: `all → location → os → machine →
applications → node`.
## Conventions you must know
| Topic | Where |
|---|---|
| Bundlewrap-language reference (item types, dep keywords, reactors) | Fork's [`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md) — read first if new to bundlewrap |
| Vault / demagify magic strings | [`conventions.md#secrets`](docs/agents/conventions.md#secrets) |
| Bundlewrap install (uv-pinned to the fork's `main`) | [`conventions.md#bundlewrap-version`](docs/agents/conventions.md#bundlewrap-version) |
| Group inheritance order, naming patterns | [`conventions.md#group-inheritance-order`](docs/agents/conventions.md#group-inheritance-order), [`#naming-conventions`](docs/agents/conventions.md#naming-conventions) |
| Repo-specific bw command deltas (apt keys, suspended nodes, vault echo) | [`commands.md`](docs/agents/commands.md) |
| Lib helpers | top-of-file docstrings in `libs/*.py` (`head -1 libs/*.py`) |
| Suspension idioms (`*.py_`, `_old/`, "for now") | [`conventions.md#suspension-and-soft-delete-idioms`](docs/agents/conventions.md#suspension-and-soft-delete-idioms) |
## Where to look for examples
When writing a new bundle, copy patterns from one that already does
the thing you need:
| Pattern | Look at |
|---|---|
| Vault calls inside metadata reactors | `bundles/dm-crypt/metadata.py` (compact, focused) |
| Mako-templated files | `bundles/bind/items.py` (DNS zonefile rendering) |
| Cross-bundle reactor writing | `bundles/nextcloud/metadata.py` (writes into `apt.packages`, `archive.paths`) |
| Custom `download:` items | `bundles/minecraft/items.py` |
| Node file (single-purpose) | `nodes/home.server.py` |
| Group with `supergroups` chain | `groups/os/debian-13.py` |
## Where this doc lives
- This file: `AGENTS.md` at the repo root.
- `CLAUDE.md` is a symlink to this file — both names point to the same
content so different tools can find it.
- The personal TODO scratchpad (`README.md`) is **separate** and not
project documentation.

View file

@ -1 +0,0 @@
AGENTS.md

View file

@ -13,6 +13,10 @@ Raspberry pi as soundcard
- OTG g_audio - OTG g_audio
- https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824 - https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824
# install bw fork
pip3 install --editable git+file:///Users/mwiegand/Projekte/bundlewrap-fork@main#egg=bundlewrap
# monitor timers # monitor timers
```sh ```sh

View file

@ -1,62 +0,0 @@
# bin/
## What's here
Operator scripts — invoked manually by the maintainer, **not** by
bundlewrap itself. Each is a standalone Python (or shell) script that
opens the repo via `Repository(dirname(dirname(realpath(__file__))))`.
Discovery is by `ls bin/` plus the `# purpose:` header line at the top
of each script:
```sh
head -2 bin/*
```
## Conventions
- **`# purpose:` header.** Every script under `bin/` starts with
`#!/usr/bin/env python3` (or appropriate shebang), then a
`# purpose: <one-line description>` comment. Baseline enforced by
`grep -L '^# purpose' bin/*`.
- **Self-contained.** A script must work when run from anywhere — it
resolves the repo via the script's own path, not `cwd`.
- **Read-only by default.** Most operator scripts query/print state
(`passwords-for`, `wireguard-client-config`). Mutating scripts
(`upgrade_and_restart_all`, `mikrotik-firmware-updater`,
`sync_1password`) are the exception, not the rule, and prompt for
confirmation.
## How to add a script
1. Start from [`bin/script_template`](script_template) — it carries
the canonical shebang + `# purpose:` header + `Repository(...)`
bootstrap.
2. Add the `# purpose:` line; lowercase, terse, include a `usage:`
example if the script takes arguments.
3. `chmod +x bin/<name>`.
4. The script can reach helpers via `bw.libs.<x>` exactly like a
bundle does.
## Pitfalls
- **`bin/` is not on `$PATH` by default.** Invoke as `bin/<name>` from
the repo root, or via `direnv` if `.envrc` exposes it.
- **Mutating scripts can hit Tier-3 territory** (per the fork's
safety envelope). Don't run `upgrade_and_restart_all`,
`mikrotik-firmware-updater`, or anything that does `node.run(...)`
without explicit user instruction. See the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
for the three-tier model.
- **Vault echo.** Scripts like `passwords-for` print decrypted values
by design; that's allowed for the human at the terminal but *not*
for the agent — never paste output into chat, ticket, or PR
description.
## See also
- [`script_template`](script_template) — canonical starter.
- [`docs/agents/conventions.md`](../docs/agents/conventions.md) —
vault rules.
- [`docs/agents/commands.md`](../docs/agents/commands.md) — read-only
bw-command guidance.

View file

@ -1,149 +0,0 @@
#!/usr/bin/env python3
# purpose: upgrade RouterOS and routerboard firmware on `bundle:routeros` (or any selector) — usage: mikrotik-firmware-updater [<selector>...] [--yes].
from argparse import ArgumentParser
from time import sleep
from bundlewrap.exceptions import RemoteException
from bundlewrap.utils.cmdline import get_target_nodes
from bundlewrap.utils.ui import io
from bundlewrap.repo import Repository
from os.path import realpath, dirname
# parse args
parser = ArgumentParser()
parser.add_argument("targets", nargs="*", default=['bundle:routeros'], help="bw nodes selector")
parser.add_argument("--yes", action="store_true", default=False, help="skip confirmation prompts")
args = parser.parse_args()
def wait_up(node):
sleep(5)
while True:
try:
node.run_routeros('/system/resource/print')
except RemoteException:
sleep(2)
continue
else:
io.debug(f"{node.name}: is up")
sleep(10)
return
def upgrade_switch_os(node):
# get versions for comparison
with io.job(f"{node.name}: checking OS version"):
response = node.run_routeros('/system/package/update/check-for-updates').raw[-1]
installed_os = bw.libs.version.Version(response['installed-version'])
latest_os = bw.libs.version.Version(response['latest-version'])
io.debug(f"{node.name}: installed: {installed_os} >= latest: {latest_os}")
# compare versions
if installed_os >= latest_os:
# os is up to date
io.stdout(f"{node.name}: os up to date ({installed_os})")
else:
# confirm os upgrade
if not args.yes and not io.ask(
f"{node.name}: upgrade os from {installed_os} to {latest_os}?", default=True
):
io.stdout(f"{node.name}: skipped by user")
return
# download os
with io.job(f"{node.name}: downloading OS"):
response = node.run_routeros('/system/package/update/download').raw[-1]
io.debug(f"{node.name}: OS upgrade download response: {response['status']}")
# install and wait for reboot
with io.job(f"{node.name}: upgrading OS"):
try:
response = node.run_routeros('/system/package/update/install').raw[-1]
except RemoteException:
pass
wait_up(node)
# verify new os version
with io.job(f"{node.name}: checking new OS version"):
new_os = bw.libs.version.Version(node.run_routeros('/system/package/update/check-for-updates').raw[-1]['installed-version'])
if new_os == latest_os:
io.stdout(f"{node.name}: OS successfully upgraded from {installed_os} to {new_os}")
else:
raise Exception(f"{node.name}: OS upgrade failed, expected {latest_os}, got {new_os}")
def upgrade_switch_firmware(node):
# get versions for comparison
with io.job(f"{node.name}: checking Firmware version"):
response = node.run_routeros('/system/routerboard/print').raw[-1]
current_firmware = bw.libs.version.Version(response['current-firmware'])
upgrade_firmware = bw.libs.version.Version(response['upgrade-firmware'])
io.debug(f"{node.name}: firmware installed: {current_firmware}, upgrade: {upgrade_firmware}")
# compare versions
if current_firmware >= upgrade_firmware:
# firmware is up to date
io.stdout(f"{node.name}: firmware is up to date ({current_firmware})")
else:
# confirm firmware upgrade
if not args.yes and not io.ask(
f"{node.name}: upgrade firmware from {current_firmware} to {upgrade_firmware}?", default=True
):
io.stdout(f"{node.name}: skipped by user")
return
# upgrade firmware
with io.job(f"{node.name}: upgrading Firmware"):
node.run_routeros('/system/routerboard/upgrade')
# reboot and wait
with io.job(f"{node.name}: rebooting"):
try:
node.run_routeros('/system/reboot')
except RemoteException:
pass
wait_up(node)
# verify firmware version
new_firmware = bw.libs.version.Version(node.run_routeros('/system/routerboard/print').raw[-1]['current-firmware'])
if new_firmware == upgrade_firmware:
io.stdout(f"{node.name}: firmware successfully upgraded from {current_firmware} to {new_firmware}")
else:
raise Exception(f"firmware upgrade failed, expected {upgrade_firmware}, got {new_firmware}")
def upgrade_switch(node):
with io.job(f"{node.name}: checking"):
# check if routeros
if node.os != 'routeros':
io.progress_advance(2)
io.stdout(f"{node.name}: skipped, unsupported os {node.os}")
return
# check switch reachability
try:
node.run_routeros('/system/resource/print')
except RemoteException as error:
io.progress_advance(2)
io.stdout(f"{node.name}: skipped, error {error}")
return
upgrade_switch_os(node)
io.progress_advance(1)
upgrade_switch_firmware(node)
io.progress_advance(1)
with io:
bw = Repository(dirname(dirname(realpath(__file__))))
nodes = get_target_nodes(bw, args.targets)
io.progress_set_total(len(nodes) * 2)
io.stdout(f"upgrading {len(nodes)} switches: {', '.join([node.name for node in sorted(nodes)])}")
for node in sorted(nodes):
upgrade_switch(node)

View file

@ -1,23 +0,0 @@
#!/usr/bin/env python3
# purpose: print node.password and selected metadata-key passwords for one node — usage: passwords-for <node>.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('node', help='Node to generate passwords for')
args = parser.parse_args()
bw = Repository(dirname(dirname(realpath(__file__))))
node = bw.get_node(args.node)
if node.password:
print(f"password: {node.password}")
for metadata_key in sorted([
'users/root/password',
]):
if value := node.metadata.get(metadata_key, None):
print(f"{metadata_key}: {value}")

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: send an RCON command to a left4dead2 server defined in node metadata — usage: rcon (list) | rcon <server> <command>.
from sys import argv from sys import argv
from os.path import realpath, dirname from os.path import realpath, dirname

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: starter template for new operator scripts under bin/.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname
bw = Repository(dirname(dirname(realpath(__file__)))) repo = Repository(dirname(dirname(realpath(__file__))))

View file

@ -1,133 +0,0 @@
#!/usr/bin/env python3
# purpose: upsert one 1Password login per `bundle:routeros` node, keyed on the bw node id.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
import json
import os
import subprocess
from dataclasses import dataclass
from typing import Optional, List
bw = Repository(dirname(dirname(realpath(__file__))))
VAULT=bw.vault.decrypt('encrypt$gAAAAABpLgX_xxb5NmNCl3cgHM0JL65GT6PHVXO5gwly7IkmWoEgkCDSuAcSAkNFB8Tb4RdnTdpzVQEUL1XppTKVto_O7_b11GjATiyQYiSfiQ8KZkTKLvk=').value
BW_TAG = "bw"
BUNDLEWRAP_FIELD_LABEL = "bundlewrap node id"
@dataclass
class OpResult:
stdout: str
stderr: str
returncode: int
def main():
for node in bw.nodes_in_group('routeros'):
upsert_node_item(
node_name=node.name,
node_uuid=node.metadata.get('id'),
username=node.username,
password=node.password,
url=f'http://{node.hostname}',
)
def run_op(args):
proc = subprocess.run(
["op", "--vault", VAULT] + args,
env=os.environ.copy(),
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(
f"op {' '.join(args)} failed with code {proc.returncode}:\n"
f"STDOUT:\n{proc.stdout}\n\nSTDERR:\n{proc.stderr}"
)
return OpResult(stdout=proc.stdout, stderr=proc.stderr, returncode=proc.returncode)
def op_item_list_bw():
out = run_op([
"item", "list",
"--tags", BW_TAG,
"--format", "json",
])
stdout = out.stdout.strip()
return json.loads(stdout) if stdout else []
def op_item_get(item_id):
args = ["item", "get", item_id, "--format", "json"]
return json.loads(run_op(args).stdout)
def op_item_create(title, node_uuid, username, password, url):
print(f"creating {title}")
return json.loads(run_op([
"item", "create",
"--category", "LOGIN",
"--title", title,
"--tags", BW_TAG,
"--url", url,
"--format", "json",
f"username={username}",
f"password={password}",
f"{BUNDLEWRAP_FIELD_LABEL}[text]={node_uuid}",
]).stdout)
def op_item_edit(item_id, title, username, password, url):
print(f"updating {title}")
return json.loads(run_op([
"item", "edit",
item_id,
"--title", title,
"--url", url,
"--format", "json",
f"username={username}",
f"password={password}",
]).stdout)
def find_node_item_id(node_uuid):
for summary in op_item_list_bw():
item_id = summary.get("id")
if not item_id:
continue
item = op_item_get(item_id)
for field in item.get("fields") or []:
label = field.get("label")
value = field.get("value")
if label == BUNDLEWRAP_FIELD_LABEL and value == node_uuid:
return item_id
return None
def upsert_node_item(node_name, node_uuid, username, password, url):
if item_id := find_node_item_id(node_uuid):
return op_item_edit(
item_id=item_id,
title=node_name,
username=username,
password=password,
url=url,
)
else:
return op_item_create(
title=node_name,
node_uuid=node_uuid,
username=username,
password=password,
url=url,
)
if __name__ == "__main__":
main()

View file

@ -1,217 +0,0 @@
#!/usr/bin/env python3
# purpose: add missing EXIF/QuickTime timestamps to photos in a directory using mdls + exiftool — usage: timestamp_icloud_photos_for_nextcloud -d <dir>.
from subprocess import check_output, CalledProcessError
from datetime import datetime, timedelta
from pathlib import Path
import json
from argparse import ArgumentParser
from concurrent.futures import ThreadPoolExecutor, as_completed
from os import cpu_count
from time import sleep
EXT_GROUPS = {
"quicktime": {".mp4", ".mov", ".heic", ".cr3"},
"exif": {".jpg", ".jpeg", ".cr2"},
}
DATETIME_KEYS = [
("Composite", "SubSecDateTimeOriginal"),
("Composite", "SubSecCreateDate"),
('ExifIFD', 'DateTimeOriginal'),
('ExifIFD', 'CreateDate'),
('XMP-xmp', 'CreateDate'),
('Keys', 'CreationDate'),
('QuickTime', 'CreateDate'),
('XMP-photoshop', 'DateCreated'),
]
def run(command):
return check_output(command, text=True).strip()
def mdls_timestamp(file):
for i in range(5): # retry a few times in case of transient mdls failures
try:
output = run(('mdls', '-raw', '-name', 'kMDItemContentCreationDate', file))
except CalledProcessError as e:
print(f"{file}: Error running mdls (attempt {i+1}/5): {e}")
continue
try:
return datetime.strptime(output, "%Y-%m-%d %H:%M:%S %z")
except ValueError as e:
print(f"{file}: Error parsing mdls output (attempt {i+1}/5): {e}")
continue
sleep(1)
raise RuntimeError(f"Failed to get mdls timestamp for {file} after 5 attempts")
def exiftool_data(file):
try:
output = run((
'exiftool',
'-j', # json
'-a', # unknown tags
'-u', # unknown values
'-g1', # group by category
'-time:all', # all time tags
'-api', 'QuickTimeUTC=1', # use UTC for QuickTime timestamps
'-d', '%Y-%m-%dT%H:%M:%S%z',
file,
))
except CalledProcessError as e:
print(f"Error running exiftool: {e}")
return None
else:
return json.loads(output)[0]
def exiftool_timestamp(file):
data = exiftool_data(file)
for category, key in DATETIME_KEYS:
try:
value = data[category][key]
return category, key, datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
except (TypeError, KeyError, ValueError) as e:
continue
print(f"⚠️ {file}: No timestamp found in exiftool: " + json.dumps(data, indent=2))
return None, None, None
def photo_has_embedded_timestamp(file):
mdls_ts = mdls_timestamp(file)
category, key, exiftool_ts = exiftool_timestamp(file)
if not exiftool_ts:
print(f"⚠️ {file}: No timestamp found in exiftool")
return False
# normalize timezone for comparison
exiftool_ts = exiftool_ts.astimezone(mdls_ts.tzinfo)
delta = abs(mdls_ts - exiftool_ts)
if delta < timedelta(hours=1): # allow for small differences
print(f"✅ {file}: {mdls_ts.isoformat()} (#{category}:{key})")
return True
else:
print(f"⚠️ {file}: {mdls_ts.isoformat()} != {exiftool_ts} (Δ={delta})")
return False
def photos_without_embedded_timestamps(directory):
executor = ThreadPoolExecutor(max_workers=cpu_count()//2)
try:
futures = {
executor.submit(photo_has_embedded_timestamp, file): file
for file in directory.iterdir()
if file.is_file()
if file.suffix.lower() not in {".aae"}
if not file.name.startswith('.')
}
for future in as_completed(futures):
file = futures[future]
has_ts = future.result() # raises immediately on first failed future
if has_ts:
file.rename(file.parent / 'ok' / file.name)
else:
yield file
except Exception:
executor.shutdown(wait=False, cancel_futures=True)
raise
else:
executor.shutdown(wait=True)
def exiftool_write(file, assignments):
print(f"🔵 {file}: Writing -- {assignments}")
return run((
"exiftool", "-overwrite_original",
"-api", "QuickTimeUTC=1",
*[
f"-{group}:{tag}={value}"
for group, tag, value in assignments
],
str(file),
))
def add_missing_timestamp(file):
data = exiftool_data(file)
mdls_ts = mdls_timestamp(file)
offset = mdls_ts.strftime("%z")
offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
exif_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
qt_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
qt_ts_tz = f"{qt_ts}{offset}"
ext = file.suffix.lower()
try:
if ext in {".heic"}:
exiftool_write(file, [
("ExifIFD", "DateTimeOriginal", qt_ts),
("ExifIFD", "CreateDate", qt_ts),
("ExifIFD", "OffsetTime", offset),
("ExifIFD", "OffsetTimeOriginal", offset),
("ExifIFD", "OffsetTimeDigitized", offset),
("QuickTime", "CreateDate", qt_ts_tz),
("Keys", "CreationDate", qt_ts_tz),
("XMP-xmp", "CreateDate", qt_ts_tz),
])
elif "QuickTime" in data or ext in {".mp4", ".mov", ".heic", ".cr3"}:
exiftool_write(file, [
("QuickTime", "CreateDate", qt_ts_tz),
("Keys", "CreationDate", qt_ts_tz),
])
elif "ExifIFD" in data or ext in {".jpg", ".jpeg", ".cr2", ".webp"}:
exiftool_write(file, [
("ExifIFD", "DateTimeOriginal", exif_ts),
("ExifIFD", "CreateDate", exif_ts),
("IFD0", "ModifyDate", exif_ts),
("ExifIFD", "OffsetTime", offset),
("ExifIFD", "OffsetTimeOriginal", offset),
("ExifIFD", "OffsetTimeDigitized", offset),
])
elif ext in {".png", ".gif", ".avif"}:
exiftool_write(file, [
("XMP-xmp", "CreateDate", qt_ts_tz),
("XMP-photoshop", "DateCreated", exif_ts),
])
else:
print(f"❌ {file}: unsupported type, skipped")
return
if photo_has_embedded_timestamp(file):
print(f"✅ {file}: Timestamp successfully added: {mdls_ts.isoformat()}")
file.rename(file.parent / 'processed' / file.name)
return
else:
category, key, exiftool_ts = exiftool_timestamp(file)
print(f"❌ {file}: Timestamp still wrong/missing after write '{category}:{key}:{exiftool_ts}': #{json.dumps(data, indent=4)}")
return
except CalledProcessError as e:
print(f"❌ {file}: Failed to write timestamp: {e}")
return
if __name__ == "__main__":
parser = ArgumentParser(description="Print timestamps of photos in the current directory.")
parser.add_argument("-d", "--directory", help="Directory to scan for photos")
args = parser.parse_args()
directory = Path(args.directory)
(directory/'ok').mkdir(exist_ok=True)
(directory/'processed').mkdir(exist_ok=True)
_photos_without_embedded_timestamps = list(photos_without_embedded_timestamps(directory))
print(f"{len(_photos_without_embedded_timestamps)} photos without embedded timestamps found.")
print("Press Enter to add missing timestamps...")
input()
for file in _photos_without_embedded_timestamps:
add_missing_timestamp(file)

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: apt-update and full-upgrade every non-dummy debian node, then reboot in WireGuard-aware order.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: wake one node via WoL by name — usage: wake <node>.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname

View file

@ -1,25 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: print or QR-render a WireGuard client config from htz.mails metadata — usage: wireguard-client-config <client>.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname
from sys import argv from sys import argv
from ipaddress import ip_network, ip_interface from ipaddress import ip_network, ip_interface
import argparse
if len(argv) != 3:
print(f'usage: {argv[0]} <node> <client>')
exit(1)
# get info from repo
repo = Repository(dirname(dirname(realpath(__file__)))) repo = Repository(dirname(dirname(realpath(__file__))))
server_node = repo.get_node('htz.mails') server_node = repo.get_node(argv[1])
available_clients = server_node.metadata.get('wireguard/clients').keys()
# parse args if argv[2] not in server_node.metadata.get('wireguard/clients'):
parser = argparse.ArgumentParser(description='Generate WireGuard client configuration.') print(f'client {argv[2]} not found in: {server_node.metadata.get("wireguard/clients").keys()}')
parser.add_argument('client', choices=available_clients, help='The client name to generate the configuration for.') exit(1)
args = parser.parse_args()
data = server_node.metadata.get(f'wireguard/clients/{argv[2]}')
# get cert
data = server_node.metadata.get(f'wireguard/clients/{args.client}')
vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network
allowed_ips = [ allowed_ips = [
vpn_network, vpn_network,
@ -45,15 +43,10 @@ Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:
PersistentKeepalive = 10 PersistentKeepalive = 10
''' '''
answer = input("print config or qrcode? [Cq]: ").strip().upper() print('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
match answer: print(conf)
case '' | 'C': print('<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<')
print('>>>>>>>>>>>>>>>')
print(conf) if input("print qrcode? [Yn]: ").upper() in ['', 'Y']:
print('<<<<<<<<<<<<<<<')
case 'Q':
import pyqrcode import pyqrcode
print(pyqrcode.create(conf).terminal(quiet_zone=1)) print(pyqrcode.create(conf).terminal(quiet_zone=1))
case _:
print(f'Invalid option "{answer}".')
exit(1)

View file

@ -1,204 +0,0 @@
# bundles/
## Before you start
Read [`docs/agents/conventions.md`](../docs/agents/conventions.md) first
— it covers vault calls, demagify, the `repo.libs.<x>` helpers, and the
files agents must not modify. Skipping it leads to subtly broken bundles
(vault calls in the wrong place, dict-in-set `TypeError` because of
unhashable nesting, etc.).
For bundlewrap-language reference (item types, dep keywords,
`metadata_reactor`, `defaults`, item-file template syntax) see the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
and its [`docs/content/guide/item_file_templates.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/docs/content/guide/item_file_templates.md).
## What's here
103 bundles. Each is a directory `bundles/<name>/` containing some of:
```
bundles/<name>/
├── items.py # the items this bundle creates (files, services, packages, …)
├── metadata.py # `defaults` + `@metadata_reactor` functions
├── files/ # static or templated file payloads referenced from items.py
└── README.md # one doc per bundle, for humans and agents (see "Per-bundle README" below)
```
## Conventions
- **Bundle names** are lowercase, hyphen-separated: `backup-server`,
`bind-acme`, `dm-crypt`. No underscores in new bundle names — see
[`conventions.md#naming-conventions`](../docs/agents/conventions.md#naming-conventions).
- **`items.py`** is plain Python; it produces `files = {...}`,
`pkg_apt = {...}`, `svc_systemd = {...}`, etc. dicts at module scope.
Cross-item dependencies use `needs` / `triggers` / `triggered_by`
see the fork's `AGENTS.md` for the full keyword cheat sheet.
- **`metadata.py`** uses `defaults = {...}` for static seed values and
`@metadata_reactor.provides(...)` for derived values. Reactors are
pure functions of `(metadata,)` — no side effects, no I/O.
- **Helpers go in [`libs/`](../libs/AGENTS.md)** when they're useful to
more than one bundle. Don't duplicate logic across bundles.
- **Custom item types** (e.g. `download:`) live in
[`items/`](../items/AGENTS.md), not per-bundle.
- **Bundles own application-wide knowledge; nodes carry only the few
per-host knobs the bundle actually needs.** When designing a bundle,
identify the per-node knobs (e.g. domain, uplink interface, a
vault-id suffix) and put everything else in `defaults`, or in a
reactor that derives from those knobs. Per-node random secrets
belong in `defaults` via `repo.vault.random_bytes_as_base64_for(...)`
keyed on the node — not in the node file. See
`bundles/left4me/metadata.py:10` (`secret_key` derived in defaults)
and `bundles/postgresql/metadata.py:4` (vault-derived `password_for`
at module scope).
## How to add a new bundle
1. `mkdir bundles/<name>/` (lowercase, hyphenated).
2. Write `items.py` and (if anything is configurable) `metadata.py`.
Use `repo.libs.hashable.hashable(...)` when you need to nest a dict
or set inside a metadata set; raw dicts/sets aren't hashable.
3. Drop static payloads into `bundles/<name>/files/`. For Mako-templated
files, declare `'content_type': 'mako'` on the `file:` item — see
the fork's
[item-file-templates guide](https://github.com/CroneKorkN/bundlewrap/blob/main/docs/content/guide/item_file_templates.md).
4. **Wire to nodes.** Either add an entry to the relevant
[`groups/<axis>/<x>.py`](../groups/AGENTS.md) (preferred for shared
bundles) or to the node's `bundles` list directly
([`nodes/AGENTS.md`](../nodes/AGENTS.md)).
5. **Verify, in this order:**
- `bw test` — repo-wide parse + cross-cutting hooks. Loads every
bundle, but reactors don't fire for nodes that haven't opted into
the bundle yet — bugs in new reactors stay hidden here.
- **Attach the bundle to a node** (via the node's `bundles` list, or
a group it belongs to). Until you do, the next steps don't actually
exercise the bundle.
- `bw test <node>` — exercises every reactor and item-graph edge for
that node. This is where most new-bundle bugs surface.
- `bw items <node> --blame` — confirm items materialise with the
right paths, authored by the expected bundle.
- `bw metadata <node> -k <a/b>` — spot-check derived metadata.
- `bw hash <node>` — preview vs current host state.
See [`docs/agents/commands.md#bundle-validation-workflow`](../docs/agents/commands.md#bundle-validation-workflow)
for the rationale.
6. Add a `bundles/<name>/README.md`. See "Per-bundle README" below
for what to cover.
## How to remove a bundle
1. `git grep '<name>'` in `nodes/`, `groups/`, and other `bundles/` to
find references.
2. Remove those references.
3. `rm -rf bundles/<name>/`.
4. `bw test` and `bw nodes` to confirm clean.
## Pitfalls
- **`metadata.py` is evaluated at load time** for *every* node, every
invocation of `bw`. Heavy work or I/O slows the whole repo. Keep
reactors pure and fast; pre-compute in `libs/` if you must.
- **Static files vs templates.** `bundles/<x>/files/<f>` is static
unless the matching `file:` item declares `content_type='mako'`
(or a templating extension triggers it). To check, read the matching
`file:` entry in `items.py`.
- **`file:` `source` defaults to the destination basename.** For a
destination of `/etc/foo/bar.conf` with no `source` key, bw looks
for `bundles/<bundle>/files/bar.conf`. Only declare `source`
explicitly when the basename you want differs (e.g. shipping a Mako
template named `bar.conf.mako` to a destination of
`/etc/foo/bar.conf`).
- **Reactors writing across namespaces.** Some bundles' reactors write
into other bundles' metadata namespaces (e.g. `nextcloud` writes
into `apt.packages`, `archive.paths`). When you change such a bundle,
every consumer's metadata changes too. The bundle's `README.md`
often calls these out — but the authoritative source is `metadata.py`
itself; grep `'<other-bundle>':` in the reactors when in doubt.
- **`bw hash` doesn't accept selectors.** Use `bw hash <node>` per
literal name; see the fork's runbook.
- **Reactors must read metadata.** If a reactor body returns a static
dict without calling `metadata.get(...)`, bw raises
`ValueError: <reactor> on <node> did not request any metadata, you
might want to use defaults instead` once a node consumes the bundle.
Fix: fold the contribution into `defaults`. The rule applies even
when the reactor writes into another bundle's namespace — a static
contribution to e.g. `nftables/output` belongs in `defaults`, where
bw merges it with other bundles' contributions.
- **`triggers``triggered: True` invariant.** Any item listed in
another's `triggers` list must declare `triggered: True`. bw
enforces this at `bw test` time: *"…triggered by …, but missing
'triggered' attribute"*. Corollary: an action can't be both in an
upstream `triggers` list AND self-healing every apply — pick one.
- **Triggered actions don't recover from partial failure.** When an
upstream item's apply succeeds but its triggered downstream action
fails, subsequent applies can't recover via the trigger chain —
upstream is "already in desired state" and never re-triggers. For
actions that must self-heal (pip installs, chowns, migrations),
drop `triggered: True` and gate the command with `unless: <fast-check>`.
`unless` is a shell command on the target host whose exit status
decides whether the main command runs (exit 0 = skip); it's checked
at fire time, after `triggered:` filtering.
## Per-bundle README
Each bundle has (or should have) a `README.md`. One doc per bundle,
written for humans and agents both. There's no fixed structure —
match the bundle's actual surface, write what helps a future reader
(or future you) avoid trial-and-error.
The existing READMEs vary in quality and shape. For orientation,
look at the bigger ones, not the two-line ones:
- [`bundles/flask/README.md`](flask/README.md) — title + one-sentence
purpose, a metadata example as a Python dict, then the contract
the consuming git repo has to satisfy + a logging pitfall. The
closest thing to a "balanced doc" in tree.
- [`bundles/dm-crypt/README.md`](dm-crypt/README.md) — same shape,
shorter: purpose + metadata example + one sentence on effect.
- [`bundles/apt/README.md`](apt/README.md) — relevant upstream URLs
at the top, then a Python metadata example with rich inline
comments (type / optionality / where keys come from).
- [`bundles/nextcloud/README.md`](nextcloud/README.md) — operational
scratchpad: iPhone-import recipe, preview-generator commands,
reset queries. Captures muscle-memory the maintainer would
otherwise re-learn each time.
Useful things to include, when relevant:
- A sentence or two on what the bundle does and when you'd attach it.
- A metadata example as a Python dict literal, with `#` comments
on each key (type, required vs default, units, where it comes
from). This is the cleanest way to communicate the schema and
matches how `metadata.py` actually looks.
- Anything non-obvious about wiring it up — required keys without
defaults, group-membership expectations, manual one-time steps.
- Cross-namespace metadata writes, when this bundle's reactors
populate another bundle's namespace. Easy to miss, cheap to flag.
- Gotchas, debug recipes, failure modes you've actually hit.
What to skip:
- An exhaustive item list — `items.py` is shorter and more accurate.
- Anything that would just rot — version numbers, "TODO" lists,
change notes. Use git history.
If a single paragraph is enough to say what's worth saying, write a
single paragraph. Verbosity isn't a goal.
Convention going forward is leave-as-you-go: any time you materially
edit a bundle, top up its README (or write one if it's missing).
Don't burn a session bulk-reformatting the existing ones — uneven
quality is part of what we accept in exchange for not blocking other
work.
## See also
- [`docs/agents/conventions.md`](../docs/agents/conventions.md) — repo
idioms (vault, demagify, naming, do-not-touch list).
- [`docs/agents/commands.md`](../docs/agents/commands.md) — repo-specific
command deltas.
- [`items/AGENTS.md`](../items/AGENTS.md) — custom item types
(`download:`); when to write a new one vs use `file:`.
- [`libs/AGENTS.md`](../libs/AGENTS.md) — shared helpers.
- Fork's [`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
— bundlewrap-language reference + safety envelope.

View file

@ -13,14 +13,16 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'exec': { 'exec': {
'apcupsd': { repo.libs.hashable.hashable({
'commands': ["sudo /usr/local/share/telegraf/apcupsd"], 'commands': ["sudo /usr/local/share/telegraf/apcupsd"],
'name_override': "apcupsd", 'name_override': "apcupsd",
'data_format': "influx", 'data_format': "influx",
'interval': '30s', 'interval': '30s',
'flush_interval': '30s', 'flush_interval': '30s',
}),
}, },
}, },
}, },

View file

@ -4,8 +4,6 @@ defaults = {
'apt-listchanges': { 'apt-listchanges': {
'installed': False, 'installed': False,
}, },
'ca-certificates': {},
'unattended-upgrades': {},
}, },
'config': { 'config': {
'DPkg': { 'DPkg': {
@ -23,10 +21,6 @@ defaults = {
}, },
}, },
'APT': { 'APT': {
'Periodic': {
'Update-Package-Lists': '1',
'Unattended-Upgrade': '1',
},
'NeverAutoRemove': { 'NeverAutoRemove': {
'^firmware-linux.*', '^firmware-linux.*',
'^linux-firmware$', '^linux-firmware$',
@ -54,11 +48,6 @@ defaults = {
'Error-Mode': 'any', 'Error-Mode': 'any',
}, },
}, },
'Unattended-Upgrade': {
'Origins-Pattern': {
"origin=*",
},
},
}, },
'sources': {}, 'sources': {},
}, },
@ -117,6 +106,33 @@ def signed_by(metadata):
} }
@metadata_reactor.provides(
'apt/config',
'apt/packages',
)
def unattended_upgrades(metadata):
return {
'apt': {
'config': {
'APT': {
'Periodic': {
'Update-Package-Lists': '1',
'Unattended-Upgrade': '1',
},
},
'Unattended-Upgrade': {
'Origins-Pattern': {
"origin=*",
},
},
},
'packages': {
'unattended-upgrades': {},
},
},
}
# @metadata_reactor.provides( # @metadata_reactor.provides(
# 'apt/config', # 'apt/config',
# 'apt/list_changes', # 'apt/list_changes',

View file

@ -33,7 +33,6 @@ def acme_zone(metadata):
str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip) str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip)
for other_node in repo.nodes for other_node in repo.nodes
if other_node.metadata.get('letsencrypt/domains', {}) if other_node.metadata.get('letsencrypt/domains', {})
and other_node.metadata.get('network/internal/ipv4', None)
}, },
*{ *{
str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip) str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip)

View file

@ -1,30 +0,0 @@
# bind
Authoritative DNS — primary plus optional `bind/master_node` slaves.
## Applying changes needs both nodes
The slave's bw-managed zone files are rendered from the master's
metadata at slave-apply time (see `bundles/bind/items.py:100`). When
you change a record on the master (adding a `letsencrypt/domains`
entry, a new vhost, etc.), the change is only published once you
apply BOTH:
```sh
bw apply htz.mails # primary (where the source records live)
bw apply ovh.secondary # secondary (renders its own zone files)
```
Until both have been applied, `bw verify ovh.secondary` will show
stale zones and consumers that hit the secondary (Let's Encrypt's
secondary-region validators in particular) will see NXDOMAIN. Even
though the slave's named.conf.local declares `type slave;`, don't
rely on bind's own AXFR catching up — the bw-rendered file on disk
is what `bw verify` measures.
## See also
- `bundles/bind-acme/` — the in-house ACME-update receiver.
- `bundles/letsencrypt/README.md` — DNS-01 prerequisites and the
negative-cache penalty (the most common operational consequence
of forgetting to apply the secondary).

View file

@ -49,13 +49,13 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'bind': { 'bind': [{
'default': {
'urls': ['http://localhost:8053/xml/v3'], 'urls': ['http://localhost:8053/xml/v3'],
'gather_memory_contexts': False, 'gather_memory_contexts': False,
'gather_views': True, 'gather_views': True,
}, }],
}, },
}, },
}, },

View file

@ -112,11 +112,6 @@ def process_recording(filename):
sample_num += samples_per_block - overlapping_samples sample_num += samples_per_block - overlapping_samples
# move to PROCESSED_RECORDINGS_DIR
os.makedirs(PROCESSED_RECORDINGS_DIR, exist_ok=True)
shutil.move(os.path.join(RECORDINGS_DIR, filename), os.path.join(PROCESSED_RECORDINGS_DIR, filename))
# write a spectrogram using the sound from start to end of the event # write a spectrogram using the sound from start to end of the event
def write_event(current_event, soundfile, samplerate): def write_event(current_event, soundfile, samplerate):

View file

@ -19,7 +19,5 @@ do
-t "3600" \ -t "3600" \
-c:a flac \ -c:a flac \
-compression_level 12 \ -compression_level 12 \
"recordings/current/$DATE.flac" "recordings/$DATE.flac"
mv "recordings/current/$DATE.flac" "recordings/$DATE.flac"
done done

View file

@ -8,7 +8,7 @@ urllib3.disable_warnings()
import os import os
HUE_IP = "${hue_ip}" # replace with your bridge IP HUE_IP = "10.0.0.134" # replace with your bridge IP
HUE_APP_KEY = "${hue_app_key}" # local only HUE_APP_KEY = "${hue_app_key}" # local only
HUE_DEVICE_ID = "31f58786-3242-4e88-b9ce-23f44ba27bbe" HUE_DEVICE_ID = "31f58786-3242-4e88-b9ce-23f44ba27bbe"
TEMPERATURE_LOG_DIR = "/opt/bootshorn/temperatures" TEMPERATURE_LOG_DIR = "/opt/bootshorn/temperatures"

View file

@ -7,15 +7,11 @@ directories = {
'owner': 'ckn', 'owner': 'ckn',
'group': 'ckn', 'group': 'ckn',
}, },
'/opt/bootshorn/temperatures': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/recordings': { '/opt/bootshorn/recordings': {
'owner': 'ckn', 'owner': 'ckn',
'group': 'ckn', 'group': 'ckn',
}, },
'/opt/bootshorn/recordings/current': { '/opt/bootshorn/temperatures': {
'owner': 'ckn', 'owner': 'ckn',
'group': 'ckn', 'group': 'ckn',
}, },
@ -38,7 +34,6 @@ files = {
'/opt/bootshorn/temperature': { '/opt/bootshorn/temperature': {
'content_type': 'mako', 'content_type': 'mako',
'context': { 'context': {
'hue_ip': repo.get_node('home.hue').hostname,
'hue_app_key': repo.vault.decrypt('encrypt$gAAAAABoc2WxZCLbxl-Z4IrSC97CdOeFgBplr9Fp5ujpd0WCCCPNBUY_WquHN86z8hKLq5Y04dwq8TdJW0PMSOSgTFbGgdp_P1q0jOBLEKaW9IIT1YM88h-JYwLf9QGDV_5oEfvnBCtO'), 'hue_app_key': repo.vault.decrypt('encrypt$gAAAAABoc2WxZCLbxl-Z4IrSC97CdOeFgBplr9Fp5ujpd0WCCCPNBUY_WquHN86z8hKLq5Y04dwq8TdJW0PMSOSgTFbGgdp_P1q0jOBLEKaW9IIT1YM88h-JYwLf9QGDV_5oEfvnBCtO'),
}, },
'owner': 'ckn', 'owner': 'ckn',

View file

@ -27,7 +27,7 @@ def ssh_keys(metadata):
'users': { 'users': {
'build-agent': { 'build-agent': {
'authorized_users': { 'authorized_users': {
f'build-server@{other_node.name}': {} f'build-server@{other_node.name}'
for other_node in repo.nodes for other_node in repo.nodes
if other_node.has_bundle('build-server') if other_node.has_bundle('build-server')
for architecture in other_node.metadata.get('build-server/architectures').values() for architecture in other_node.metadata.get('build-server/architectures').values()

View file

@ -14,7 +14,7 @@ def ssh_keys(metadata):
'users': { 'users': {
'build-ci': { 'build-ci': {
'authorized_users': { 'authorized_users': {
f'build-server@{other_node.name}': {} f'build-server@{other_node.name}'
for other_node in repo.nodes for other_node in repo.nodes
if other_node.has_bundle('build-server') if other_node.has_bundle('build-server')
}, },

View file

@ -8,7 +8,6 @@ defaults = {
'sources': { 'sources': {
'crystal': { 'crystal': {
# https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal # https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal
# curl -fsSL https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/Release.key
'urls': { 'urls': {
'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/', 'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/',
}, },

View file

@ -5,11 +5,6 @@ defaults = {
'needs': { 'needs': {
'zfs_dataset:tank/downloads' 'zfs_dataset:tank/downloads'
}, },
'authorized_users': {
f'build-server@{other_node.name}': {}
for other_node in repo.nodes
if other_node.has_bundle('build-server')
},
}, },
}, },
'zfs': { 'zfs': {
@ -19,13 +14,21 @@ defaults = {
}, },
}, },
}, },
}
@metadata_reactor.provides(
'systemd-mount'
)
def mount_certs(metadata):
return {
'systemd-mount': { 'systemd-mount': {
'/var/lib/downloads_nginx': { '/var/lib/downloads_nginx': {
'source': '/var/lib/downloads', 'source': '/var/lib/downloads',
'user': 'www-data', 'user': 'www-data',
}, },
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
@ -44,3 +47,20 @@ def nginx(metadata):
}, },
}, },
} }
@metadata_reactor.provides(
'users/downloads/authorized_users',
)
def ssh_keys(metadata):
return {
'users': {
'downloads': {
'authorized_users': {
f'build-server@{other_node.name}'
for other_node in repo.nodes
if other_node.has_bundle('build-server')
},
},
},
}

View file

@ -43,11 +43,11 @@ def units(metadata):
'Service': { 'Service': {
'Environment': { 'Environment': {
f'{k}={v}' f'{k}={v}'
for k, v in metadata.get(f'flask/{name}/env', {}).items() for k, v in conf.get('env', {}).items()
}, },
'User': metadata.get(f'flask/{name}/user'), 'User': conf['user'],
'Group': metadata.get(f'flask/{name}/group'), 'Group': conf['group'],
'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {metadata.get(f'flask/{name}/workers')} -b 127.0.0.1:{metadata.get(f'flask/{name}/port')} --timeout {metadata.get(f'flask/{name}/timeout')} {metadata.get(f'flask/{name}/app_module')}:app" 'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {conf['workers']} -b 127.0.0.1:{conf['port']} --timeout {conf['timeout']} {conf['app_module']}:app"
}, },
'Install': { 'Install': {
'WantedBy': { 'WantedBy': {
@ -55,7 +55,7 @@ def units(metadata):
} }
}, },
} }
for name in metadata.get('flask') for name, conf in metadata.get('flask').items()
} }
} }
} }

View file

@ -127,7 +127,7 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h'] panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h']
if 'display_name' in panel_config: if 'display_name' in panel_config:
panel['fieldConfig']['defaults']['displayName'] = panel_config['display_name'] panel['fieldConfig']['defaults']['displayName'] = '${'+panel_config['display_name']+'}'
if panel_config.get('stacked'): if panel_config.get('stacked'):
panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal' panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal'
@ -158,14 +158,13 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
host=monitored_node.name, host=monitored_node.name,
negative=query_config.get('negative', False), negative=query_config.get('negative', False),
boolean_to_int=query_config.get('boolean_to_int', False), boolean_to_int=query_config.get('boolean_to_int', False),
over=query_config.get('over', None), minimum=query_config.get('minimum', None),
filters={ filters={
'host': monitored_node.name, 'host': monitored_node.name,
**query_config['filters'], **query_config['filters'],
}, },
exists=query_config.get('exists', []), exists=query_config.get('exists', []),
function=query_config.get('function', None), function=query_config.get('function', None),
multiply=query_config.get('multiply', None),
).strip() ).strip()
}) })
@ -179,3 +178,4 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
] ]
} }

View file

@ -2,7 +2,7 @@ files = {
'/usr/local/share/telegraf/cpu_frequency': { '/usr/local/share/telegraf/cpu_frequency': {
'mode': '0755', 'mode': '0755',
'triggers': { 'triggers': {
'svc_systemd:telegraf.service:restart', 'svc_systemd:telegraf:restart',
}, },
}, },
} }

View file

@ -14,18 +14,17 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'sensors': { 'sensors': {repo.libs.hashable.hashable({
'default': {
'timeout': '2s', 'timeout': '2s',
}, })},
},
'exec': { 'exec': {
'cpu_frequency': { repo.libs.hashable.hashable({
'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"], 'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"],
'name_override': "cpu_frequency", 'name_override': "cpu_frequency",
'data_format': "influx", 'data_format': "influx",
}, }),
# repo.libs.hashable.hashable({ # repo.libs.hashable.hashable({
# 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"], # 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"],
# 'name_override': "cpu_temperature", # 'name_override': "cpu_temperature",
@ -35,4 +34,5 @@ defaults = {
}, },
}, },
}, },
},
} }

View file

@ -39,17 +39,6 @@ defaults = {
}, },
} }
if node.has_bundle('zfs'):
defaults['zfs'] = {
'datasets': {
'tank/influxdb': {
'mountpoint': '/var/lib/influxdb',
'recordsize': '8192',
'atime': 'off',
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'influxdb/password', 'influxdb/password',
'influxdb/admin_token', 'influxdb/admin_token',
@ -63,6 +52,26 @@ def admin_password(metadata):
} }
@metadata_reactor.provides(
'zfs/datasets',
)
def zfs(metadata):
if not node.has_bundle('zfs'):
return {}
return {
'zfs': {
'datasets': {
'tank/influxdb': {
'mountpoint': '/var/lib/influxdb',
'recordsize': '8192',
'atime': 'off',
},
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'dns', 'dns',
) )

View file

@ -1,22 +1,58 @@
https://github.com/SirPlease/L4D2-Competitive-Rework/blob/master/Dedicated%20Server%20Install%20Guide/README.md https://developer.valvesoftware.com/wiki/List_of_L4D2_Cvars
```python Dead Center c1m1_hotel
'tick60_maps': { Dead Center c1m2_streets
'port': 27030, Dead Center c1m3_mall
# add command line arguments Dead Center c1m4_atrium
'arguments': ['-tickrate 60'], Dark Carnival c2m1_highway
# stack overlays, first is uppermost Dark Carnival c2m2_fairgrounds
'overlays': ['tickrate', 'standard'], Dark Carnival c2m3_coaster
# server.cfg contents Dark Carnival c2m4_barns
'config': [ Dark Carnival c2m5_concert
# configs from overlays are accessible via server_${overlay}.cfg Swamp Fever c3m1_plankcountry
'exec server_tickrate.cfg', Swamp Fever c3m2_swamp
# add more options Swamp Fever c3m3_shantytown
'sv_minupdaterate 101', Swamp Fever c3m4_plantation
'sv_maxupdaterate 101', Hard Rain c4m1_milltown_a
'sv_mincmdrate 101', Hard Rain c4m2_sugarmill_a
'sv_maxcmdrate 101', Hard Rain c4m3_sugarmill_b
'sv_consistency 0', Hard Rain c4m4_milltown_b
], Hard Rain c4m5_milltown_escape
}, The Parish c5m1_waterfront_sndscape
``` The Parish c5m1_waterfront
The Parish c5m2_park
The Parish c5m3_cemetery
The Parish c5m4_quarter
The Parish c5m5_bridge
The Passing c6m1_riverbank
The Passing c6m2_bedlam
The Passing c6m3_port
The Sacrifice c7m1_docks
The Sacrifice c7m2_barge
The Sacrifice c7m3_port
No Mercy c8m1_apartment
No Mercy c8m2_subway
No Mercy c8m3_sewers
No Mercy c8m4_interior
No Mercy c8m5_rooftop
Crash Course c9m1_alleys
Crash Course c9m2_lots
Death Toll c10m1_caves
Death Toll c10m2_drainage
Death Toll c10m3_ranchhouse
Death Toll c10m4_mainstreet
Death Toll c10m5_houseboat
Dead Air c11m1_greenhouse
Dead Air c11m2_offices
Dead Air c11m3_garage
Dead Air c11m4_terminal
Dead Air c11m5_runway
Blood Harvest c12m1_hilltop
Blood Harvest c12m2_traintunnel
Blood Harvest c12m3_bridge
Blood Harvest c12m4_barn
Blood Harvest c12m5_cornfield
Cold Stream c13m1_alpinecreek
Cold Stream c13m2_southpinestream
Cold Stream c13m3_memorialbridge
Cold Stream c13m4_cutthroatcreek

View file

@ -1,13 +0,0 @@
#!/bin/bash
set -xeuo pipefail
function steam() {
# for systemd, so it can terminate the process (for other things sudo would have been enough)
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
export HOME=/opt/l4d2/steam
}
function workshop() {
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
steam /opt/l4d2/scripts/steam-workshop-download --out "/opt/l4d2/overlays/${overlay}/left4dead2/addons" "$@"
}

View file

@ -1,10 +0,0 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# https://github.com/SirPlease/L4D2-Competitive-Rework
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2
test -d /opt/l4d2/overlays/$overlay/left4dead2/cfg/cfgogl || \
curl -L https://github.com/SirPlease/L4D2-Competitive-Rework/archive/refs/heads/master.tar.gz | steam tar -xz --strip-components=1 -C /opt/l4d2/overlays/$overlay/left4dead2

View file

@ -1,128 +0,0 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/addons
cd /opt/l4d2/overlays/$overlay/left4dead2/addons
# https://l4d2center.com/maps/servers/l4d2center_maps_sync.sh.txt ->
# Exit immediately if a command exits with a non-zero status.
set -e
# Function to print error messages
error_exit() {
echo "Error: $1" >&2
exit 1
}
# Check if the current directory ends with /left4dead2/addons
current_dir=$(pwd)
expected_dir="/left4dead2/addons"
if [[ ! "$current_dir" == *"$expected_dir" ]]; then
error_exit "Script must be run from your L4D2 \"addons\" folder. Current directory: $current_dir"
fi
# Check for required commands
for cmd in curl md5sum 7z; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error_exit "Required command '$cmd' is not installed. Please install it and retry."
fi
done
# URL of the CSV file
CSV_URL="https://l4d2center.com/maps/servers/index.csv"
# Temporary file to store CSV
TEMP_CSV=$(mktemp)
# Ensure temporary file is removed on exit
trap 'rm -f "$TEMP_CSV"' EXIT
echo "Downloading CSV from $CSV_URL..."
curl -sSL -o "$TEMP_CSV" "$CSV_URL" || error_exit "Failed to download CSV."
declare -A map_md5
declare -A map_links
# Read CSV and populate associative arrays
{
# Skip the first line (header)
IFS= read -r header
while IFS=';' read -r Name Size MD5 DownloadLink || [[ $Name ]]; do
# Trim whitespace
Name=$(echo "$Name" | xargs)
MD5=$(echo "$MD5" | xargs)
DownloadLink=$(echo "$DownloadLink" | xargs)
# Populate associative arrays
map_md5["$Name"]="$MD5"
map_links["$Name"]="$DownloadLink"
done
} < "$TEMP_CSV"
# Get list of expected VPK files
expected_vpk=("${!map_md5[@]}")
# Remove VPK files not in expected list or with mismatched MD5
echo "Cleaning up existing VPK files..."
for file in *.vpk; do
# Check if it's a regular file
if [[ -f "$file" ]]; then
if [[ -z "${map_md5["$file"]}" ]]; then
echo "Removing unexpected file: $file"
rm -f "$file"
else
# Calculate MD5
echo "Calculating MD5 for existing file: $file..."
current_md5=$(md5sum "$file" | awk '{print $1}')
expected_md5="${map_md5["$file"]}"
if [[ "$current_md5" != "$expected_md5" ]]; then
echo "MD5 mismatch for $file. Removing."
rm -f "$file"
fi
fi
fi
done
# Download and extract missing or updated VPK files
echo "Processing required VPK files..."
for vpk in "${expected_vpk[@]}"; do
if [[ ! -f "$vpk" ]]; then
echo "Downloading and extracting $vpk..."
download_url="${map_links["$vpk"]}"
if [[ -z "$download_url" ]]; then
echo "No download link found for $vpk. Skipping."
continue
fi
encoded_url=$(echo "$download_url" | sed 's/ /%20/g')
# Download the .7z file to a temporary location
TEMP_7Z=$(mktemp --suffix=.7z)
curl -# -L -o "$TEMP_7Z" "$encoded_url"
# Check if the download was successful
if [[ $? -ne 0 ]]; then
echo "Failed to download $download_url. Skipping."
rm -f "$TEMP_7Z"
continue
fi
# Extract the .7z file
7z x -y "$TEMP_7Z" || { echo "Failed to extract $TEMP_7Z. Skipping."; rm -f "$TEMP_7Z"; continue; }
# Remove the temporary .7z file
rm -f "$TEMP_7Z"
else
echo "$vpk is already up to date."
fi
done
echo "Synchronization complete."

View file

@ -1,12 +0,0 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# Ions Vocalizer
workshop -i 698857882
# admin system
workshop --item 2524204971
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system"
steam echo "STEAM_1:0:12376499" > "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system/admins.txt"

View file

@ -1,25 +0,0 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# server config
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
# https://www.programmersought.com/article/513810199514/
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/cfg
steam cat <<'EOF' > /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
sv_minrate 100000
sv_maxrate 100000
nb_update_frequency 0.014
net_splitpacket_maxrate 50000
net_maxcleartime 0.0001
fps_max 0
EOF
# install tickrate enabler
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
for file in tickrate_enabler.dll tickrate_enabler.so tickrate_enabler.vdf
do
curl -L "https://github.com/SirPlease/L4D2-Competitive-Rework/raw/refs/heads/master/addons/${file}" -o "/opt/l4d2/overlays/${overlay}/left4dead2/addons/${file}"
done

View file

@ -1,13 +0,0 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# workshop --collection 121115793 # Back To School
# workshop --item 2957035482 # hehe30-part1
# workshop --item 2973628334 # hehe30-part2
# workshop --item 3013844371 # hehe30-part3
# workshop --item 3478461158 # 虚伪黎明(Dawn's Deception)
# workshop --item 3478934394 # 虚伪黎明(Dawn's Deception)PART2

View file

@ -1,13 +1,40 @@
// defaults hostname "CroneKorkN : ${name}"
hostname ${server_name} sv_contact "admin@sublimity.de"
sv_steamgroup "${','.join(steamgroups)}"
rcon_password "${rcon_password}"
motd_enabled 0 motd_enabled 0
rcon_password ${rcon_password}
sv_steamgroup "38347879"
mp_autoteambalance 0
sv_forcepreload 1
// server specific sv_cheats 1
% for line in config:
${line}
% endfor sv_consistency 0
sv_lan 0
sv_allow_lobby_connect_only 0
sv_gametypes "coop,realism,survival,versus,teamversus,scavenge,teamscavenge"
sv_minrate 30000
sv_maxrate 60000
sv_mincmdrate 66
sv_maxcmdrate 101
sv_logsdir "logs-${name}" //Folder in the game directory where server logs will be stored.
log on //Creates a logfile (on | off)
sv_logecho 0 //default 0; Echo log information to the console.
sv_logfile 1 //default 1; Log server information in the log file.
sv_log_onefile 0 //default 0; Log server information to only one file.
sv_logbans 1 //default 0;Log server bans in the server logs.
sv_logflush 0 //default 0; Flush the log files to disk on each write (slow).

View file

@ -1,72 +0,0 @@
#!/bin/bash
set -xeuo pipefail
# -- DEFINE FUNCTIONS AND VARIABLES -- #
function steam() {
# for systemd, so it can terminate the process (for other things sudo would have been enough)
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
export HOME=/opt/l4d2/steam
}
# -- PREPARE SYSTEM -- #
getent passwd steam >/dev/null || useradd -M -d /opt/l4d2 -s /bin/bash steam
mkdir -p /opt/l4d2 /tmp/dumps
chown steam:steam /opt/l4d2 /tmp/dumps
dpkg --add-architecture i386
apt update
DEBIAN_FRONTEND=noninteractive apt install -y libc6:i386 lib32z1
# workshop downloader
test -f /opt/l4d2/scripts/steam-workshop-download || \
steam wget -4 https://git.sublimity.de/cronekorkn/steam-workshop-downloader/raw/branch/master/steam-workshop-download -P /opt/l4d2/scripts
steam chmod +x /opt/l4d2/scripts/steam-workshop-download
# -- STEAM -- #
steam mkdir -p /opt/l4d2/steam
test -f /opt/l4d2/steam/steamcmd_linux.tar.gz || \
steam wget http://media.steampowered.com/installer/steamcmd_linux.tar.gz -P /opt/l4d2/steam
test -f /opt/l4d2/steam/steamcmd.sh || \
steam tar -xvzf /opt/l4d2/steam/steamcmd_linux.tar.gz -C /opt/l4d2/steam
# fix for: /opt/l4d2/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory
steam mkdir -p /opt/l4d2/steam/.steam # needs to be in steam users home dir
readlink /opt/l4d2/steam/.steam/sdk32 | grep -q ^/opt/l4d2/steam/linux32$ || \
steam ln -sf /opt/l4d2/steam/linux32 /opt/l4d2/steam/.steam/sdk32
readlink /opt/l4d2/steam/.steam/sdk64 | grep -q ^/opt/l4d2/steam/linux64$ || \
steam ln -sf /opt/l4d2/steam/linux64 /opt/l4d2/steam/.steam/sdk64
# -- INSTALL -- #
# erst die windows deps zu installieren scheint ein workaround für x64 zu sein?
steam mkdir -p /opt/l4d2/installation
steam /opt/l4d2/steam/steamcmd.sh \
+force_install_dir /opt/l4d2/installation \
+login anonymous \
+@sSteamCmdForcePlatformType windows \
+app_update 222860 validate \
+quit
steam /opt/l4d2/steam/steamcmd.sh \
+force_install_dir /opt/l4d2/installation \
+login anonymous \
+@sSteamCmdForcePlatformType linux \
+app_update 222860 validate \
+quit
# -- OVERLAYS -- #
for overlay_path in /opt/l4d2/scripts/overlays/*; do
overlay=$(basename "$overlay_path")
steam mkdir -p /opt/l4d2/overlays/$overlay
bash -xeuo pipefail "$overlay_path"
test -f /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg && \
steam cp /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg /opt/l4d2/overlays/$overlay/left4dead2/cfg/server_$overlay.cfg
done
# -- SERVERS -- #
#steam rm -rf /opt/l4d2/servers
steam mkdir -p /opt/l4d2/servers

View file

@ -1,75 +0,0 @@
#!/bin/bash
set -xeuo pipefail
name=""
port=""
configfile=""
overlays=""
arguments=""
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
name="$2"; shift 2
;;
-p|--port)
port="$2"; shift 2
;;
-c|--config)
configfile="$2"; shift 2
;;
-o|--overlay)
overlays="/opt/l4d2/overlays/$2:$overlays"; shift 2
;;
--)
shift
arguments+="$@"
break
;;
*)
echo "ERROR: unknown argument $1"; exit 1
;;
esac
done
[[ -n "${name}" ]] || { echo "ERROR: -n/--name missing"; exit 1; }
[[ -n "${port}" ]] || { echo "ERROR: -p/--port missing"; exit 1; }
# -- HELPER FUNCTIONS -- #
function steam() {
# für systemd, damit es den prozess beenden kann
setpriv --reuid=steam --regid=steam --init-groups "$@"
export HOME=/opt/l4d2/steam
}
# -- TIDY UP -- #
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
steam rm -rf "/opt/l4d2/servers/$name"
# -- CREATE DIRECTORIES -- #
steam mkdir -p \
"/opt/l4d2/servers/$name" \
"/opt/l4d2/servers/$name/work" \
"/opt/l4d2/servers/$name/upper" \
"/opt/l4d2/servers/$name/merged"
# -- MOUNT OVERLAYFS -- #
mount -t overlay overlay \
-o "lowerdir=$overlays/opt/l4d2/installation,upperdir=/opt/l4d2/servers/$name/upper,workdir=/opt/l4d2/servers/$name/work" \
"/opt/l4d2/servers/$name/merged"
# -- REPLACE SERVER.CFG -- #
if [[ -n "$configfile" ]]; then
cp "$configfile" "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
chown steam:steam "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
fi
# -- RUN L4D2 -- #
steam "/opt/l4d2/servers/$name/merged/srcds_run" -norestart -pidfile "/opt/l4d2/servers/$name/pid" -game left4dead2 -ip 0.0.0.0 -port "$port" +hostname "Crone_$name" +map c1m1_hotel $arguments

View file

@ -1,19 +0,0 @@
#!/bin/bash
set -xeuo pipefail
name=""
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
name="$2"; shift 2
;;
*)
echo "ERROR: unknown argument $1"; exit 1
;;
esac
done
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
steam rm -rf "/opt/l4d2/servers/$name"

View file

@ -1,105 +1,122 @@
users = { assert node.has_bundle('steam') and node.has_bundle('steam-workshop-download')
'steam': {
'home': '/opt/l4d2/steam',
'shell': '/bin/bash',
},
}
directories = { directories = {
'/opt/l4d2': { '/opt/steam/left4dead2-servers': {
'owner': 'steam', 'group': 'steam', 'owner': 'steam',
}, 'group': 'steam',
'/opt/l4d2/steam': { 'mode': '0755',
'owner': 'steam', 'group': 'steam',
},
'/opt/l4d2/configs': {
'owner': 'steam', 'group': 'steam',
'purge': True, 'purge': True,
}, },
'/opt/l4d2/scripts': { # Current zfs doesnt support zfs upperdir. The support was added in October 2022. Move upperdir - unused anyway -
'owner': 'steam', 'group': 'steam', # to another dir. Also move workdir alongside it, as it has to be on same fs.
}, '/opt/steam-zfs-overlay-workarounds': {
'/opt/l4d2/scripts/overlays': { 'owner': 'steam',
'owner': 'steam', 'group': 'steam', 'group': 'steam',
'mode': '0755',
'purge': True, 'purge': True,
}, },
} }
files = { # /opt/steam/steam/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory
'/opt/l4d2/setup': { symlinks = {
'mode': '755', '/opt/steam/steam/.steam/sdk32': {
'triggers': { 'target': '/opt/steam/steam/linux32',
'svc_systemd:left4dead2-initialize.service:restart', 'owner': 'steam',
}, 'group': 'steam',
}, }
'/opt/l4d2/start': {
'mode': '755',
'triggers': {
f'svc_systemd:left4dead2-{server_name}.service:restart'
for server_name in node.metadata.get('left4dead2/servers').keys()
},
},
'/opt/l4d2/stop': {
'mode': '755',
'triggers': {
f'svc_systemd:left4dead2-{server_name}.service:restart'
for server_name in node.metadata.get('left4dead2/servers').keys()
},
},
'/opt/l4d2/scripts/helpers': {
'source': 'scripts/helpers',
'mode': '755',
'triggers': {
'svc_systemd:left4dead2-initialize.service:restart',
},
},
} }
for overlay in node.metadata.get('left4dead2/overlays'): #
files[f'/opt/l4d2/scripts/overlays/{overlay}'] = { # SERVERS
'source': f'scripts/overlays/{overlay}', #
'mode': '755',
'triggers': { for name, config in node.metadata.get('left4dead2/servers').items():
'svc_systemd:left4dead2-initialize.service:restart',
}, #overlay
directories[f'/opt/steam/left4dead2-servers/{name}'] = {
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam-zfs-overlay-workarounds/{name}/upper'] = {
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam-zfs-overlay-workarounds/{name}/workdir'] = {
'owner': 'steam',
'group': 'steam',
} }
svc_systemd = { # conf
'left4dead2-initialize.service': { files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/cfg/server.cfg'] = {
'enabled': True,
'running': None,
'needs': {
'tag:left4dead2-packages',
'file:/opt/l4d2/setup',
'file:/usr/local/lib/systemd/system/left4dead2-initialize.service',
},
},
}
for server_name, config in node.metadata.get('left4dead2/servers').items():
files[f'/opt/l4d2/configs/{server_name}.cfg'] = {
'source': 'server.cfg',
'content_type': 'mako', 'content_type': 'mako',
'source': 'server.cfg',
'context': { 'context': {
'server_name': server_name, 'name': name,
'rcon_password': repo.vault.decrypt('encrypt$gAAAAABpAdZhxwJ47I1AXotuZmBvyZP1ecVTt9IXFkLI28JiVS74LKs9QdgIBz-FC-iXtIHHh_GVGxxKQZprn4UrXZcvZ57kCKxfHBs3cE2JiGnbWE8_mfs=').value, 'steamgroups': node.metadata.get('left4dead2/steamgroups'),
'config': config.get('config', []), 'rcon_password': config['rcon_password'],
}, },
'owner': 'steam', 'owner': 'steam',
'mode': '644', 'group': 'steam',
'triggers': { 'triggers': [
f'svc_systemd:left4dead2-{server_name}.service:restart', f'svc_systemd:left4dead2-{name}.service:restart',
}, ],
} }
svc_systemd[f'left4dead2-{server_name}.service'] = { # service
'enabled': True, svc_systemd[f'left4dead2-{name}.service'] = {
'running': True, 'needs': [
'tags': { f'file:/opt/steam/left4dead2-servers/{name}/left4dead2/cfg/server.cfg',
'left4dead2-servers', f'file:/usr/local/lib/systemd/system/left4dead2-{name}.service',
}, ],
'needs': { }
'svc_systemd:left4dead2-initialize.service',
f'file:/usr/local/lib/systemd/system/left4dead2-{server_name}.service', #
}, # ADDONS
#
# base
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons/readme.txt'] = {
'content_type': 'any',
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons'] = {
'owner': 'steam',
'group': 'steam',
'purge': True,
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
for id in [
*config.get('workshop', []),
*node.metadata.get('left4dead2/workshop'),
]:
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons/{id}.vpk'] = {
'content_type': 'any',
'owner': 'steam',
'group': 'steam',
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
# admin system
directories[f'/opt/steam/left4dead2-servers/{name}/left4dead2/ems/admin system'] = {
'owner': 'steam',
'group': 'steam',
'mode': '0755',
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/ems/admin system/admins.txt'] = {
'owner': 'steam',
'group': 'steam',
'mode': '0755',
'content': '\n'.join(sorted(node.metadata.get('left4dead2/admins'))),
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
} }

View file

@ -1,112 +1,110 @@
from re import match assert node.has_bundle('steam')
from os import path, listdir
from shlex import quote
defaults = { defaults = {
'apt': { 'steam': {
'packages': { 'games': {
'libc6_i386': { # installs libc6:i386 'left4dead2': 222860,
'tags': {'left4dead2-packages'},
},
'lib32z1': {
'tags': {'left4dead2-packages'},
},
'unzip': {
'tags': {'left4dead2-packages'},
},
'p7zip-full': { # l4d2center_maps_sync.sh
'tags': {'left4dead2-packages'},
},
}, },
}, },
'left4dead2': { 'left4dead2': {
'overlays': set(listdir(path.join(repo.path, 'bundles/left4dead2/files/scripts/overlays'))), 'servers': {},
'servers': { 'admins': set(),
# 'port': 27017, 'workshop': set(),
# 'overlays': ['competitive_rework'],
# 'arguments': ['-tickrate 60'],
# 'config': [
# 'exec server_original.cfg',
# 'sm_forcematch zonemod',
# ],
},
},
'nftables': {
'input': {
'udp dport { 27005, 27020 } accept',
},
},
'systemd': {
'units': {
'left4dead2-initialize.service': {
'Unit': {
'Description': 'initialize left4dead2',
'After': 'network-online.target',
},
'Service': {
'Type': 'oneshot',
'RemainAfterExit': 'yes',
'ExecStart': '/opt/l4d2/setup',
'StandardOutput': 'journal',
'StandardError': 'journal',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
},
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'left4dead2/servers',
)
def rconn_password(metadata):
# only works from localhost!
return {
'left4dead2': {
'servers': {
server: {
'rcon_password': repo.vault.password_for(f'{node.name} left4dead2 {server} rcon', length=24),
}
for server in metadata.get('left4dead2/servers')
},
},
}
@metadata_reactor.provides(
'steam-workshop-download',
'systemd/units', 'systemd/units',
) )
def server_units(metadata): def server_units(metadata):
units = {} units = {}
workshop = {}
for name, config in metadata.get('left4dead2/servers').items(): for name, config in metadata.get('left4dead2/servers').items():
assert match(r'^[A-z0-9-_-]+$', name) # mount overlay
assert 27000 <= config["port"] <= 27100 mountpoint = f'/opt/steam/left4dead2-servers/{name}'
for overlay in config.get('overlays', []): mount_unit_name = mountpoint[1:].replace('-', '\\x2d').replace('/', '-') + '.mount'
assert overlay in metadata.get('left4dead2/overlays'), f"unknown overlay {overlay}, known: {metadata.get('left4dead2/overlays')}" units[mount_unit_name] = {
'Unit': {
'Description': f"Mount left4dead2 server {name} overlay",
'Conflicts': {'umount.target'},
'Before': {'umount.target'},
},
'Mount': {
'What': 'overlay',
'Where': mountpoint,
'Type': 'overlay',
'Options': ','.join([
'auto',
'lowerdir=/opt/steam/left4dead2',
f'upperdir=/opt/steam-zfs-overlay-workarounds/{name}/upper',
f'workdir=/opt/steam-zfs-overlay-workarounds/{name}/workdir',
]),
},
'Install': {
'RequiredBy': {
f'left4dead2-{name}.service',
},
},
}
cmd = f'/opt/l4d2/start -n {name} -p {config["port"]}' # individual workshop
workshop_ids = config.get('workshop', set()) | metadata.get('left4dead2/workshop', set())
if 'config' in config: if workshop_ids:
cmd += f' -c /opt/l4d2/configs/{name}.cfg' workshop[f'left4dead2-{name}'] = {
'ids': workshop_ids,
for overlay in config.get('overlays', []): 'path': f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons',
cmd += f' -o {overlay}' 'user': 'steam',
'requires': {
if 'arguments' in config: mount_unit_name,
cmd += ' -- ' + ' '.join(config['arguments']) },
'required_by': {
f'left4dead2-{name}.service',
},
}
# left4dead2 server unit
units[f'left4dead2-{name}.service'] = { units[f'left4dead2-{name}.service'] = {
'Unit': { 'Unit': {
'Description': f'left4dead2 server {name}', 'Description': f'left4dead2 server {name}',
'After': {'left4dead2-initialize.service'}, 'After': {'steam-update.service'},
'Requires': {'left4dead2-initialize.service'}, 'Requires': {'steam-update.service'},
}, },
'Service': { 'Service': {
'Type': 'simple', 'User': 'steam',
'ExecStart': cmd, 'Group': 'steam',
'ExecStopPost': f'/opt/l4d2/stop -n {name}', 'WorkingDirectory': f'/opt/steam/left4dead2-servers/{name}',
'ExecStart': f'/opt/steam/left4dead2-servers/{name}/srcds_run -port {config["port"]} +exec server.cfg',
'Restart': 'on-failure', 'Restart': 'on-failure',
'Nice': -10,
'CPUWeight': 200,
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': 0,
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': {'multi-user.target'},
}, },
'triggers': {
f'svc_systemd:left4dead2-{name}.service:restart',
},
} }
return { return {
'steam-workshop-download': workshop,
'systemd': { 'systemd': {
'units': units, 'units': units,
}, },
@ -116,13 +114,14 @@ def server_units(metadata):
@metadata_reactor.provides( @metadata_reactor.provides(
'nftables/input', 'nftables/input',
) )
def nftables(metadata): def firewall(metadata):
ports = sorted(str(config["port"]) for config in metadata.get('left4dead2/servers').values()) ports = set(str(server['port']) for server in metadata.get('left4dead2/servers').values())
return { return {
'nftables': { 'nftables': {
'input': { 'input': {
f'ip protocol {{ tcp, udp }} th dport {{ {", ".join(ports)} }} accept' f"tcp dport {{ {', '.join(sorted(ports))} }} accept",
f"udp dport {{ {', '.join(sorted(ports))} }} accept",
}, },
}, },
} }

View file

@ -1,167 +0,0 @@
# left4me
L4D2 game-server management platform: a Flask web UI on gunicorn that
provisions per-instance srcds servers via templated systemd units, with
kernel-overlayfs layering for shared installations + per-overlay maps,
and uid-based DSCP/priority marking on the egress path so CAKE on the
external interface prioritizes srcds UDP over bulk traffic.
## Metadata
```python
'metadata': {
'left4me': {
'domain': 'whatever.tld', # required — the only per-node knob
# Everything below is optional and has a sensible default in the
# bundle. Override per-node only if the default is wrong:
# 'git_url': 'git@git.sublimity.de:cronekorkn/left4me',
# 'git_branch': 'master',
# 'gunicorn_workers': 1,
# 'gunicorn_threads': 32,
# 'job_worker_threads': 4,
# 'port_range_start': 27015,
# 'port_range_end': 27115,
# secret_key is auto-derived per node
# (repo.vault.random_bytes_as_base64_for f'{node.name} left4me secret_key').
},
},
```
The bundle's `derived_from_domain` reactor reads `left4me/domain` and
emits the corresponding `nginx/vhosts`, `letsencrypt/domains`,
`monitoring/services/left4me-web` (HTTPS health check), and the game-
port `nftables/input` accept rules. Backup paths
(`/var/lib/left4me`, `/etc/left4me`) are set-merged into `backup/paths`
from defaults. None of these need to be declared per-node.
## What this bundle does
The bundle delivers to `ovh.left4me` a mix of:
### Target-side symlinks into the left4me checkout
After `git_deploy:/opt/left4me/src` (root-owned — left4me cannot rewrite
its own deployment artifacts at runtime), ckn-bw creates symlinks from
canonical on-host paths into the checkout:
| On-host path | Source in checkout |
|---|---|
| `/etc/sudoers.d/left4me` | `deploy/files/etc/sudoers.d/left4me` |
| `/etc/sysctl.d/99-left4me.conf` | `deploy/files/etc/sysctl.d/99-left4me.conf` |
| `/etc/systemd/system/left4me-web.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` |
| `/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` |
| `/usr/local/libexec/left4me/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox}` | `deploy/scripts/libexec/*` |
| `/usr/local/sbin/left4me` | `deploy/scripts/sbin/left4me` |
The hardening drop-ins and sudoers are the application's own security
knowledge — they live in the left4me repo and are version-controlled there.
The privileged helpers are also application code. The symlink pattern
lets bw manage placement without duplicating content.
Design rationale:
`left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`.
### Reactor-emitted units (per-host shape)
Via `systemd/units` metadata in `metadata.py` (consumed by `bundles/systemd/`):
- `left4me-web.service` — gunicorn on `127.0.0.1:8000`; worker/thread
counts from `web.env.mako`. TLS terminates upstream.
- `left4me-server@.service` — per-instance srcds template; `SocketBindAllow=`
ranges from metadata.
- `l4d2-game.slice` / `l4d2-build.slice` — cgroup slices with per-host
`AllowedCPUs=` from `left4me/system_cpus`.
- `system.slice.d/99-left4me-cpuset.conf` + `user.slice.d/99-left4me-cpuset.conf`
— host CPU-set drop-ins, same source.
### bw `files{}` — templated env files
- `host.env.mako``/etc/left4me/host.env`
- `web.env.mako``/etc/left4me/web.env`
- `sandbox-resolv.conf``/etc/left4me/sandbox-resolv.conf`
### Action chains — deploy lifecycle
- `git_deploy``uv_sync` (`uv sync --frozen` against the workspace's
committed `uv.lock`; hatchling PEP 660 editable, doesn't touch source)
`alembic_upgrade``seed_overlays` + web restart.
- One-shot bootstrap: `install_uv` downloads a pinned `uv` binary
(SHA256-verified) into `/usr/local/bin` because `uv` isn't in Trixie's
apt archive. `unless`-gated, so it's a no-op once the version pin is
installed; re-runs only when the constant is bumped.
- Idempotent gates: `chmod-sudoers` (0440 root:root), `chmod-scripts` (0755 root:root).
- Post-git-deploy reloads: `systemctl daemon-reload`, `sysctl --system`.
- Post-apply self-test: `verify-hardening-dropins` (asserts the drop-ins are
loaded by the live units before declaring apply done).
### System user
`left4me` (uid/gid 980, home `/var/lib/left4me`, mode 0755) — the same uid
hosts the web app, gameservers, and the script-overlay sandbox unit (which
drops privileges via systemd-run with a fully hardened transient service).
Runtime mutable state lives under `/var/lib/left4me/`; `/opt/left4me/`
stays as a root-owned deploy-artifact root.
### nftables / nginx / monitoring
- Contributes uid-based DSCP/priority marks for srcds UDP egress to
`nftables/output` (via `defaults`).
- `derived_from_domain` reactor emits the corresponding `nginx/vhosts`,
`letsencrypt/domains`, and `monitoring/services/left4me-web` (HTTPS
health check).
## Gotchas
- **Requires `bundles/nftables` and `bundles/systemd` on the node.** The
bundle asserts membership at `bw test` time. On Debian-13 these ride
in via the `debian-13` group, so attaching the bundle to a Debian-13
node is enough.
- **`left4me-web.service` does not have `NoNewPrivileges=true`.** This is
intentional — workers `sudo` the privileged helpers; `NoNewPrivileges`
would block setuid escalation. Per-instance `server@.service` units
*do* have it.
- **CAKE shaping is configured separately**, via
`network/<iface>/cake` on the node (consumed by `bundles/network/`),
not by this bundle.
- **First-run admin user is manual.** After `bw apply`, ssh to the host and
bootstrap the admin via the `left4me` wrapper (it sources the env files,
drops to the `left4me` user, and runs the flask CLI):
`sudo left4me create-user <username> --admin` (prompts for password via
the flask CLI, or set `LEFT4ME_ADMIN_PASSWORD` first). The bundle
deliberately doesn't seed an admin to keep credentials out of the
metadata pipeline. The same `left4me` wrapper accepts any other flask
subcommand: `sudo left4me seed-script-overlays <dir>`,
`sudo left4me routes`, `sudo left4me shell`, etc.
- **CPU isolation is managed by this bundle**, driven by one required
per-node knob: `left4me/system_cpus` — a set of int CPU ids that
pins `system.slice` / `user.slice` / `l4d2-build.slice`. The
complement (`set(range(vm/threads)) - system_cpus`) pins
`l4d2-game.slice`. On HT hosts, list both SMT siblings of every
physical core you want to reserve for system, otherwise games end
up sharing L1/L2 with system. Find pairings via
`/sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list`. On
the prod node (`ovh.left4me`, 4 physical / 8 threads, pairings
(0,4) (1,5) (2,6) (3,7)) the node sets `'system_cpus': {0, 4}` to
reserve physical core 0 entirely. `l4d2-game.slice` and
`l4d2-build.slice` carry `AllowedCPUs=` inline on their unit
definitions; `system.slice` and `user.slice` get drop-ins registered
under `systemd/units` with the `'<parent>.d/<basename>.conf'` key
convention (same shape nginx and autologin use), landing at
`/usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf`.
The reactor raises if `system_cpus` includes CPUs outside
`[0, vm/threads)` or leaves no cores for games.
- **Kernel feature requirement:** kernel-overlayfs (`CONFIG_OVERLAY_FS`).
Standard on debian-13.
- **Game ports** open by the web app on demand in the range 27015-27115
(UDP+TCP). Add corresponding accept rules to `nftables/input` per
node if the host's policy is default-drop on input.
- **Pinned UIDs/GIDs (980/981).** Chosen for deterministic ownership
across rebuilds and backup restores. If you add another bundle that
pins UIDs in this repo, make sure it doesn't collide.
## Slice support requires `bundles/systemd` ≥ commit cc1c6a5
This bundle's `l4d2-game.slice` and `l4d2-build.slice` units rely on
`bundles/systemd/items.py` accepting the `.slice` extension. Older
revisions raised `Exception(f'unknown type slice')` at apply time.
The repo-wide `bw test` will catch this if it regresses.

View file

@ -1,6 +0,0 @@
# Managed by ckn-bw bundles/left4me. Local edits will be reverted.
# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes.
LEFT4ME_ROOT=/var/lib/left4me
# l4d2host invokes steamcmd by absolute path — bypasses PATH lookup so the
# script's `cd "$(dirname "$0")"` resolves next to the real install dir.
LEFT4ME_STEAMCMD=/var/lib/left4me/steam/steamcmd.sh

View file

@ -1,6 +0,0 @@
# Sandbox-only resolver config — bind-mounted into script-overlay sandboxes
# at /etc/resolv.conf. The host's resolver (often a private/LAN DNS server)
# is unreachable from inside the sandbox because IPAddressDeny= blocks
# egress to RFC1918 / loopback. Public resolvers keep DNS working.
nameserver 1.1.1.1
nameserver 8.8.8.8

View file

@ -1,14 +0,0 @@
# Managed by ckn-bw bundles/left4me. Local edits will be reverted.
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=${node.metadata.get('left4me/secret_key')}
JOB_WORKER_THREADS=${node.metadata.get('left4me/job_worker_threads')}
SESSION_COOKIE_SECURE=true
LEFT4ME_PORT_RANGE_START=${node.metadata.get('left4me/port_range_start')}
LEFT4ME_PORT_RANGE_END=${node.metadata.get('left4me/port_range_end')}
STEAM_WEB_API_KEY=${node.metadata.get('left4me/steam_web_api_key')}
# Log listener destination — MUST be non-loopback because Source silently
# drops logaddress destinations in 127.0.0.0/8. Derived from the node's
# external IPv4; kernel routes same-host traffic via lo internally but the
# destination IP in the packet header must not literally be 127.x.
LOG_LISTENER_ADDR=${node.metadata.get('network/external/ipv4').split('/')[0]}:28000
LOG_LISTENER_BIND=0.0.0.0:28000

View file

@ -1,439 +0,0 @@
# Items for the left4me bundle.
# Systemd units come from metadata via bundles/systemd/ — there are no
# .service or .slice files in this bundle's files/ tree. Cpuset drop-ins
# for system.slice / user.slice are likewise emitted via systemd/units
# in metadata.py (key: '<parent>.d/<basename>.conf').
directories = {
'/opt/left4me': {
# Deploy-artifact root. Only /opt/left4me/src lives here; runtime
# state (.venv, steamcmd) lives under /var/lib/left4me/. Root-owned
# so left4me cannot drop new files alongside src/ (e.g. an attacker
# with web-compromise can't plant a 'scripts.d/' loaded by future
# deploy logic).
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/opt/left4me/src': {
# Source checkout. Root-owned because the production install model
# is non-editable: pip_install copies the source to a left4me-owned
# tempdir before building, so the source tree on disk is never
# mutated at runtime and left4me only needs read access (which
# world-readable bits provide). Keeps left4me from being able to
# rewrite its own future hardening drop-ins / unit files under
# /opt/left4me/src/deploy/ (target-side symlink model in the
# deployment-responsibility reshape).
'owner': 'root',
'group': 'root',
},
'/etc/left4me': {
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/var/lib/left4me': {
# left4me's home dir — useradd creates with 0700; loosen to 0755 so
# the systemd-imposed FS view for transient script-sandbox units
# (running as left4me with TemporaryFileSystem=/var/lib + selective
# binds) can traverse on its way to the overlay bind targets.
'owner': 'left4me',
'group': 'left4me',
'mode': '0755',
},
'/var/lib/left4me/installation': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/overlays': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/instances': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/runtime': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/workshop_cache': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/tmp': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/steam': {'owner': 'left4me', 'group': 'left4me'},
# Note: the venv (/var/lib/left4me/.venv) is created by the
# left4me_create_venv action; declaring it here too would race with
# `python -m venv` which expects to create the directory itself.
'/usr/local/libexec/left4me': {
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/etc/systemd/system/left4me-web.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
'/etc/systemd/system/left4me-server@.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
}
groups = {
'left4me': {'gid': 980},
}
users = {
'left4me': {
'uid': 980,
'gid': 980,
'home': '/var/lib/left4me',
'shell': '/usr/sbin/nologin',
},
}
# UID/GID pinned in the system-package range (100-999, per Debian
# policy) so file ownership is deterministic across rebuilds and
# backup restores. 980 is unused elsewhere in this repo.
# (981 — formerly l4d2-sandbox — was collapsed into 980 on 2026-05-15;
# see left4me/docs/superpowers/plans/2026-05-15-uid-collapse.md.)
# Privileged helpers are delivered via target-side symlinks (see the
# `symlinks` dict below) pointing into the left4me checkout at
# `/opt/left4me/src/deploy/scripts/{libexec,sbin}/`. No verbatim copy
# in this bundle's files/ tree. Sudoers (further below) lists the
# specific paths that left4me may invoke as root NOPASSWD.
files = {
'/etc/left4me/sandbox-resolv.conf': {
'source': 'etc/left4me/sandbox-resolv.conf',
'mode': '0644',
'owner': 'root',
'group': 'root',
},
'/etc/left4me/host.env': {
'source': 'etc/left4me/host.env.mako',
'content_type': 'mako',
'mode': '0640',
'owner': 'root',
# group=left4me so the alembic + seed-overlays actions (which run as
# `sudo -u left4me sh -c '. /etc/left4me/host.env'`) can source it.
# Same pattern as web.env below.
'group': 'left4me',
'needs': [
'group:left4me',
],
},
'/etc/left4me/web.env': {
'source': 'etc/left4me/web.env.mako',
'content_type': 'mako',
'mode': '0640',
'owner': 'root',
'group': 'left4me',
'needs': [
'group:left4me',
],
},
}
symlinks = {
'/etc/sysctl.d/99-left4me.conf': {
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
'owner': 'root',
'group': 'root',
'needs': [
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_sysctl_reload',
],
},
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-web.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-server@.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/sudoers.d/left4me': {
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_sudoers',
'git_deploy:/opt/left4me/src',
],
# sudo follows symlinks; with the target file at root:root 0440
# in a root-owned source tree, sudo accepts it. No daemon-reload
# equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
},
}
# Helper script source paths (in left4me's checkout) → deployed-form paths.
# Each gets a symlink item merged into the symlinks dict above.
_LEFT4ME_LIBEXEC_SCRIPTS = (
'left4me-overlay',
'left4me-systemctl',
'left4me-journalctl',
'left4me-script-sandbox',
)
_LEFT4ME_SBIN_SCRIPTS = (
'left4me',
)
for _script in _LEFT4ME_LIBEXEC_SCRIPTS:
symlinks[f'/usr/local/libexec/left4me/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/libexec/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/usr/local/libexec/left4me',
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
for _script in _LEFT4ME_SBIN_SCRIPTS:
symlinks[f'/usr/local/sbin/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/sbin/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
actions = {
'left4me_sysctl_reload': {
'command': 'sysctl --system >/dev/null',
'triggered': True,
},
'left4me_daemon_reload': {
'command': 'systemctl daemon-reload',
'triggered': True,
'cascade_skip': False,
},
'left4me_verify_hardening_dropins_loaded': {
# Post-apply self-test: confirm systemd actually picked up the
# hardening drop-ins we shipped via symlink. Catches the failure
# mode where the symlink lands but daemon-reload didn't take or
# someone manually unlinked the drop-in. For the gameserver template
# we query an imaginary instance — systemd resolves drop-in paths
# for `name@instance.service` against the template (`name@.service.d/`),
# so the instance need not exist or ever have run.
'command': (
'systemctl show left4me-server@verify.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-server@.service.d/10-hardening.conf '
'&& '
'systemctl show left4me-web.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-web.service.d/10-hardening.conf'
),
'cascade_skip': False,
'needs': [
'action:left4me_daemon_reload',
'symlink:/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'symlink:/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
],
},
'left4me_chmod_sudoers': {
# sudo refuses sudoers.d entries that aren't 0440 (or 0400) root:root.
# git_deploy extracts as root with the in-repo file mode; this action
# is belt-and-braces in case the repo mode drifts. Idempotent via
# the `unless` gate.
'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
],
},
'left4me_dpkg_add_i386_arch': {
# steamcmd is 32-bit and pulls libc6:i386 + lib32z1 from the i386 arch.
# apt-get update is part of this action because newly-added foreign
# archs need a fresh package list before any :i386 package resolves.
'command': 'dpkg --add-architecture i386 && apt-get update',
'unless': 'dpkg --print-foreign-architectures | grep -qx i386',
'cascade_skip': False,
},
'left4me_install_steamcmd': {
# Steam's tarball is rolling with no published checksum, so we can't
# use download: (which requires a hash). Guard with a presence check
# on steamcmd.sh — steamcmd self-updates at runtime, so chasing the
# tarball version from bw isn't useful.
'command': (
'sudo -u left4me sh -c "'
'cd /var/lib/left4me/steam && '
'curl -fsSL https://media.steampowered.com/installer/steamcmd_linux.tar.gz | '
'tar -xz'
'"'
),
'unless': 'test -x /var/lib/left4me/steam/steamcmd.sh',
'cascade_skip': False,
'needs': [
'directory:/var/lib/left4me/steam',
'pkg_apt:curl',
'pkg_apt:libc6_i386', # bw pkg_apt convention: _ → :
'pkg_apt:lib32z1',
'user:left4me',
],
},
}
# steamcmd is invoked by absolute path (LEFT4ME_STEAMCMD in host.env),
# not via PATH lookup — see l4d2host/cli.py:install. We don't need to put
# anything in /usr/local/bin for it.
git_deploy = {
'/opt/left4me/src': {
'repo': node.metadata.get('left4me/git_url'),
'rev': node.metadata.get('left4me/git_branch'),
'triggers': [
# Re-sync the workspace whenever the checkout changes. uv reads
# the committed uv.lock at /opt/left4me/src and installs both
# workspace members (l4d2host, l4d2web) editable into
# /var/lib/left4me/.venv. Hatchling's PEP 660 editable install
# doesn't write to the source tree, so /opt/left4me/src stays
# root-owned and untouched. uv_sync cascades into
# alembic_upgrade → seed_overlays → web restart.
'action:left4me_uv_sync',
# alembic upgrade head is idempotent — keeping it as a direct
# trigger off git_deploy is belt-and-braces in case the
# uv_sync cascade is ever short-circuited.
'action:left4me_alembic_upgrade',
# Reload systemd unit definitions whenever the checkout changes;
# handles updates to hardening drop-in content without requiring
# a symlink change.
'action:left4me_daemon_reload',
],
},
}
actions['left4me_chmod_scripts'] = {
# sudo invokes the helpers by absolute path under /usr/local/...;
# those resolve to the checkout via the symlinks above. The target
# files must be executable (mode 0755). git_deploy extracts with
# the in-repo file modes; this action is belt-and-braces in case
# any helper's repo mode regresses to 0644.
'command': (
'chmod 0755 '
'/opt/left4me/src/deploy/scripts/libexec/* '
'/opt/left4me/src/deploy/scripts/sbin/*'
),
'unless': (
'! find /opt/left4me/src/deploy/scripts -type f \\! -perm 755 -print -quit 2>/dev/null | grep -q .'
),
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
],
}
actions['left4me_install_uv'] = {
# uv is not in Debian Trixie's apt archive (only experimental/sid).
# Pin to a specific release; download the tarball + its SHA256
# sibling from astral-sh/uv releases, verify, install to
# /usr/local/bin. Idempotent via `unless` — only re-runs when the
# pinned version changes (bump the constant in two places below).
# Pattern matches left4me_install_steamcmd (curl+tar) elsewhere in
# this bundle. Bump cadence: as needed; both dev (brew uv) and
# prod should track the same minor.
'command': """set -e
tmpdir=$(mktemp -d); trap "rm -rf $tmpdir" EXIT
base=https://github.com/astral-sh/uv/releases/download/0.11.8
tar=uv-x86_64-unknown-linux-gnu.tar.gz
curl -fsSL -o $tmpdir/$tar $base/$tar
curl -fsSL -o $tmpdir/$tar.sha256 $base/$tar.sha256
(cd $tmpdir && sha256sum -c $tar.sha256)
tar -xzf $tmpdir/$tar -C $tmpdir --strip-components=1
install -m 0755 $tmpdir/uv /usr/local/bin/uv
install -m 0755 $tmpdir/uvx /usr/local/bin/uvx
""",
'unless': '/usr/local/bin/uv --version 2>/dev/null | grep -qx "uv 0.11.8"',
'cascade_skip': False,
'needs': [
'pkg_apt:curl',
],
# No triggers — install_uv is a one-shot bootstrap. uv_sync needs
# it (via `needs`), so the dependency runs install_uv first on a
# clean host. After that, this action is a no-op on every apply
# unless the version pin changes.
}
actions['left4me_uv_sync'] = {
# The whole "install/refresh the workspace" deploy step, in one
# action. uv reads /opt/left4me/src/uv.lock + the workspace's
# pyproject.toml and installs both members (l4d2host, l4d2web)
# editable into /var/lib/left4me/.venv. Hatchling's PEP 660
# editable install drops a .pth pointing at the source tree — no
# writes to source, so the root-owned /opt/left4me/src stays clean.
#
# UV_PROJECT_ENVIRONMENT redirects uv's default venv path
# (<project>/.venv) to our writable runtime location. HOME is set
# explicitly so uv's cache lands in /var/lib/left4me/.cache/uv
# instead of the inherited sudo HOME (which can be unwritable for
# the left4me user). cd /var/lib/left4me ensures uv's project-config
# walk-up doesn't trip over an unreadable parent (e.g., /root or
# /home/ckn). --frozen requires uv.lock to be present and
# consistent with pyproject.toml — refuses to silently update the
# lockfile during deploy.
'command': (
'sudo -u left4me sh -c "'
'cd /var/lib/left4me && '
'env HOME=/var/lib/left4me '
'UV_PROJECT_ENVIRONMENT=/var/lib/left4me/.venv '
'/usr/local/bin/uv sync --frozen --project /opt/left4me/src'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
'action:left4me_install_uv',
'directory:/var/lib/left4me',
'user:left4me',
],
'triggers': [
'action:left4me_alembic_upgrade',
],
}
actions['left4me_alembic_upgrade'] = {
# Mirrors deploy-test-server.sh:239-242. Runs as left4me with both env
# files sourced; JOB_WORKER_ENABLED=false so a stray worker doesn't race
# with the migration.
'command': (
'sudo -u left4me sh -c "'
'cd /opt/left4me/src/l4d2web && '
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false '
'/var/lib/left4me/.venv/bin/alembic -c /opt/left4me/src/l4d2web/alembic.ini upgrade head'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'action:left4me_uv_sync',
'file:/etc/left4me/host.env',
'file:/etc/left4me/web.env',
],
'triggers': [
'action:left4me_seed_overlays',
'svc_systemd:left4me-web.service:restart',
],
}
actions['left4me_seed_overlays'] = {
# Idempotent: refreshes script bodies in place; existing overlay rows keep their ids.
'command': (
'sudo -u left4me sh -c "'
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false '
'/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app '
'seed-script-overlays /opt/left4me/src/examples/script-overlays'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'action:left4me_alembic_upgrade',
],
}

View file

@ -1,305 +0,0 @@
assert node.has_bundle('nftables')
assert node.has_bundle('systemd')
assert node.has_bundle('systemd-timers')
defaults = {
'left4me': {
# Application-wide defaults; node only overrides if it really needs to.
'git_url': 'https://git.sublimity.de/cronekorkn/left4me.git',
'git_branch': 'master',
'secret_key': repo.vault.random_bytes_as_base64_for(f'{node.name} left4me secret_key', length=32).value,
'gunicorn_workers': 1,
'gunicorn_threads': 32,
'job_worker_threads': 4,
# Steam Web API key for the live-state panel's GetPlayerSummaries
# lookups (persona names + avatars). Empty default — nodes override
# in their own metadata with the actual key. If left empty in prod,
# the live-state panel still works but falls back to RCON in-game
# names and placeholder avatars.
'steam_web_api_key': '',
# Whole 27000-block: covers Steam's defaults (27015 game, 27005
# client/RCON) plus headroom for ad-hoc ports without further
# nftables changes. Mirrored into LEFT4ME_PORT_RANGE_{START,END}
# by web.env.mako and into the nftables input rule by the
# nftables_input reactor below.
'port_range_start': 27000,
'port_range_end': 27999,
},
'apt': {
'packages': {
'p7zip-full': {},
'nftables': {},
'iproute2': {},
'curl': {},
'ca-certificates': {},
'python3': {},
'python3-dev': {},
# steamcmd is a 32-bit ELF; needs i386 multiarch + these libs.
# `_` → `:` is bundlewrap's pkg_apt convention for multiarch
# names (see pkg_apt.py:48).
'libc6_i386': { # installs libc6:i386
'needs': ['action:left4me_dpkg_add_i386_arch'],
},
'lib32z1': {
'needs': ['action:left4me_dpkg_add_i386_arch'],
},
},
},
'nftables': {
# Match deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft.
# Mark srcds UDP egress (uid left4me) with DSCP EF + skb priority 6
# so CAKE classifies it into the priority tin.
'output': {
'meta skuid "left4me" meta l4proto udp ip dscp set ef meta priority set 0006:0000',
'meta skuid "left4me" meta l4proto udp ip6 dscp set ef meta priority set 0006:0000',
},
},
'systemd': {
'services': {
'left4me-web.service': {
'enabled': True,
'running': True,
'needs': [
'action:left4me_alembic_upgrade',
'file:/etc/left4me/host.env',
'file:/etc/left4me/web.env',
],
},
# Note: left4me-server@.service is a TEMPLATE — instances are
# started on-demand by the web app via the left4me-systemctl
# helper. Don't enable/start it from here.
# The slices are installed (file present) but don't need
# enable/start — they're activated implicitly when a unit
# uses Slice=.
},
},
'backup': {
# Application-owned paths. Set-merged with backup group / node-level paths.
'paths': {
'/var/lib/left4me',
'/etc/left4me',
},
},
'systemd-timers': {
# Daily re-fetch of Steam Workshop metadata + .vpk downloads for any
# item whose author published an update. The CLI just inserts a
# `refresh_workshop_items` job; the web worker picks it up next.
# Idempotent — a re-fire while a refresh is already queued/running
# is a no-op (see l4d2web/cli.py:workshop_refresh).
'left4me-workshop-refresh': {
'command': '/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh',
'when': '*-*-* 04:00:00',
'persistent': True,
'user': 'left4me',
'working_dir': '/opt/left4me/src',
'environment_files': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'after': {
'network-online.target',
'left4me-web.service',
},
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx_vhosts(metadata):
# letsencrypt/domains and monitoring/services for the vhost are auto-
# populated by bundles/nginx/metadata.py. We just declare check_path:
# '/health' so the auto-check hits the Flask health endpoint, not '/'.
domain = metadata.get('left4me/domain')
return {
'nginx': {
'vhosts': {
domain: {
'content': 'nginx/proxy_pass.conf',
'context': {
'target': 'http://127.0.0.1:8000',
},
'check_path': '/health',
},
},
},
}
@metadata_reactor.provides(
'nftables/input',
)
def nftables_input(metadata):
port_start = metadata.get('left4me/port_range_start')
port_end = metadata.get('left4me/port_range_end')
return {
'nftables': {
'input': {
# Players connect via UDP. TCP on the same port range is RCON
# — only the local web app should reach it. Loopback bypasses
# the input chain (iifname lo accept in bundles/nftables/...),
# so 127.0.0.1 RCON works without an explicit TCP accept here.
# External TCP on these ports stays blocked by the default
# input-chain drop.
f'udp dport {port_start}-{port_end} accept',
},
},
}
@metadata_reactor.provides(
'systemd/units',
)
def systemd_units(metadata):
workers = metadata.get('left4me/gunicorn_workers')
threads = metadata.get('left4me/gunicorn_threads')
# cgroup-v2 cpuset. `system_cpus` (set of int CPU ids, declared per
# node) pins system/user/build; the complement pins l4d2-game. On HT
# hosts, list both siblings of a physical core so games don't share
# L1/L2 with system work — pairings via
# /sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list.
vm_threads = metadata.get('vm/threads', metadata.get('vm/cores'))
all_cpus = set(range(vm_threads))
system_cpus = metadata.get('left4me/system_cpus')
if not system_cpus <= all_cpus:
raise Exception(
f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'includes CPUs outside [0, {vm_threads})'
)
game_cpus = all_cpus - system_cpus
if not game_cpus:
raise Exception(
f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'leaves no cores for games'
)
system_cpus_string = ','.join(str(t) for t in sorted(system_cpus))
game_cpus_string = ','.join(str(t) for t in sorted(game_cpus))
# Drop-in for upstream system.slice / user.slice (units we don't own).
# Same '<parent>.d/<basename>.conf' convention as nginx and autologin.
cpuset_dropin = {'Slice': {'AllowedCPUs': system_cpus_string}}
return {
'systemd': {
'units': {
'left4me-web.service': {
'Unit': {
'Description': 'left4me web application',
'After': 'network-online.target',
'Wants': 'network-online.target',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'WorkingDirectory': '/opt/left4me/src',
'Environment': {
'HOME=/var/lib/left4me',
'PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
'EnvironmentFile': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'ExecStart': (
'/var/lib/left4me/.venv/bin/gunicorn '
f'--workers {workers} --threads {threads} '
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
),
'Restart': 'on-failure',
'RestartSec': '3',
# Web app writes broadly under /var/lib/left4me. Kept inline
# because it's web-specific (server@ uses BindPaths to bind
# only its instance dir).
'ReadWritePaths': '/var/lib/left4me',
# Hardening profile delivered via
# /etc/systemd/system/left4me-web.service.d/10-hardening.conf
# (target-side symlink into left4me/deploy/files/, owned by left4me).
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'left4me-server@.service': {
'Unit': {
'Description': 'left4me server instance %i',
'After': 'network-online.target',
'Wants': 'network-online.target',
'StartLimitBurst': '5',
'StartLimitIntervalSec': '60s',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'EnvironmentFile': (
'/etc/left4me/host.env',
'/var/lib/left4me/instances/%i/instance.env',
),
'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2',
'ExecStartPre': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i',
# +ip 0.0.0.0 binds RCON (TCP) to all interfaces incl. loopback;
# without this, Source auto-selects the primary IP and the web
# app's 127.0.0.1 RCON connect gets ECONNREFUSED. External TCP
# on the game port range is firewall-blocked in nftables_input.
'ExecStart': '/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +ip 0.0.0.0 +hostport ${L4D2_PORT} $L4D2_ARGS',
'ExecStopPost': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i',
'Restart': 'on-failure',
'RestartSec': '5',
# Resource control (baseline from prior performance work).
'Slice': 'l4d2-game.slice',
'Nice': '-5',
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': '4',
'OOMScoreAdjust': '-200',
'MemoryHigh': '1.5G',
'MemoryMax': '2G',
'TasksMax': '256',
'LimitNOFILE': '65536',
'KillSignal': 'SIGINT',
'TimeoutStopSec': '15s',
'LogRateLimitIntervalSec': '0',
# Hardening profile delivered via
# /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
# (target-side symlink into left4me/deploy/files/, owned by left4me).
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'l4d2-game.slice': {
'Unit': {
'Description': 'left4me game-server slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '1000',
'IOWeight': '1000',
'AllowedCPUs': game_cpus_string,
},
},
'l4d2-build.slice': {
'Unit': {
'Description': 'left4me script-sandbox build slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '10',
'IOWeight': '10',
'AllowedCPUs': system_cpus_string,
},
},
'system.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
'user.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
},
},
}

View file

@ -1,60 +1,9 @@
# letsencrypt https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script
Issues and renews Let's Encrypt certs via [dehydrated][upstream] with
DNS-01 against the in-house bind-acme server.
[upstream]: https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script
## First-apply behaviour
Immediately after `bw apply <node>`, nginx serves a **self-signed
cert** for each declared domain — generated by
`/etc/dehydrated/letsencrypt-ensure-some-certificate` so nginx has
something to start with. The real Let's Encrypt cert arrives at most
24h later when the systemd timer fires
(`/usr/bin/dehydrated --cron --accept-terms --challenge dns-01`). To
shortcut the wait:
```sh
ssh <node> 'sudo /usr/bin/dehydrated --cron --accept-terms --challenge dns-01'
ssh <node> 'sudo systemctl reload nginx'
```
## DNS-01 prerequisites
`hook.sh` does `nsupdate` against the bind-acme server (referenced
by `letsencrypt/acme_node`). For the challenge to succeed:
1. The acme node must be in the same metadata graph (so
`bw metadata <node> -k letsencrypt/acme_node` resolves).
2. **All NS servers** for the validated domain must serve the
`_acme-challenge.<domain>` CNAME — Let's Encrypt validates from
primary AND secondary geographic regions; both authoritative
servers must agree. If a secondary NS is also a bw-managed node,
`bw apply` it after adding the domain (see e.g. `ovh.secondary`).
3. The bind-acme node's TSIG key must be reachable. `hook.sh` is
rendered with the bind-acme server's `network/internal/ipv4`
for clients outside that LAN, the route must exist (typically via
wireguard `s2s` peer membership).
## Negative-cache penalty
If the first DNS-01 attempt fails (e.g. zone not yet applied to the
secondary NS), Let's Encrypt's resolvers cache NXDOMAIN for the SOA's
negative TTL (often 900s = 15 min). Subsequent attempts during that
window also fail and refresh the cache. Combined with LE's rate limit
of **5 failed authorisations per domain per hour**, recovery requires
you to **stop retrying** for ~15 minutes after fixing the DNS, then
make at most one attempt.
## nsupdate sample
For interactive testing of the bind-acme TSIG path:
```sh ```sh
printf "server 127.0.0.1 printf "server 127.0.0.1
zone acme.resolver.name. zone acme.resolver.name.
update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT \"hello\" update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT "hello"
send send
" | nsupdate -y hmac-sha512:acme:XXXXXX " | nsupdate -y hmac-sha512:acme:XXXXXX
``` ```

View file

@ -2,7 +2,7 @@ defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'dehydrated': {}, 'dehydrated': {},
'bind9-dnsutils': {}, 'dnsutils': {},
}, },
}, },
'letsencrypt': { 'letsencrypt': {

View file

@ -1,6 +1,4 @@
defaults = { defaults = {
'sysctl': { 'sysctl': {},
'net.ipv4.icmp_ratelimit': '100',
},
'modules-load': set(), 'modules-load': set(),
} }

View file

@ -82,7 +82,6 @@ def dns(metadata):
'dns': dns, 'dns': dns,
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'letsencrypt/domains', 'letsencrypt/domains',
) )

View file

@ -42,7 +42,7 @@ def user(metadata):
'users': { 'users': {
'sshmon': { 'sshmon': {
'authorized_users': { 'authorized_users': {
'nagios@' + metadata.get('monitoring/icinga2_node'): {}, 'nagios@' + metadata.get('monitoring/icinga2_node'),
} }
}, },
}, },
@ -50,7 +50,7 @@ def user(metadata):
'sshmon': { 'sshmon': {
conf['vars.command'] conf['vars.command']
for conf in metadata.get('monitoring/services').values() for conf in metadata.get('monitoring/services').values()
if conf.get('check_command') == 'sshmon' if conf['check_command'] == 'sshmon'
and conf.get('vars.sudo', None) and conf.get('vars.sudo', None)
}, },
}, },

View file

@ -37,12 +37,11 @@ def dhcp(metadata):
'modules-load', 'modules-load',
) )
def units(metadata): def units(metadata):
networks = metadata.get('network', {})
if node.has_bundle('systemd-networkd'): if node.has_bundle('systemd-networkd'):
units = {} units = {}
modules_load = set() modules_load = set()
for network_name, network_conf in networks.items(): for network_name, network_conf in metadata.get('network').items():
interface_type = network_conf.get('type', None) interface_type = network_conf.get('type', None)
# network # network
@ -117,7 +116,6 @@ def units(metadata):
'systemd/units', 'systemd/units',
) )
def queuing_disciplines(metadata): def queuing_disciplines(metadata):
networks = metadata.get('network', {})
if node.has_bundle('systemd-networkd'): if node.has_bundle('systemd-networkd'):
return { return {
'systemd': { 'systemd': {
@ -138,7 +136,7 @@ def queuing_disciplines(metadata):
'WantedBy': 'network-online.target', 'WantedBy': 'network-online.target',
}, },
} }
for network_name, network_conf in networks.items() for network_name, network_conf in metadata.get('network').items()
if 'qdisc' in network_conf if 'qdisc' in network_conf
}, },
}, },

View file

@ -1,209 +1,78 @@
#!/usr/bin/env python3 #!/bin/bash
import argparse
import base64
import hashlib
import os
import shutil
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
USER="$1"
ALLOWED_EXTS = { REL_SOURCE_PATH="/$1/files/$2"
".png", ".jpg", ".jpeg", ".heic", ".cr2", ".cr3", ".mp4", ".mov", ABS_SOURCE_PATH="/var/lib/nextcloud/$1/files/$2"
".webp", ".avif", ".gif",
}
DATETIME_KEYS = [ REL_DEST_PATH="/$1/files/$3"
("Composite", "SubSecDateTimeOriginal"), ABS_DEST_PATH="/var/lib/nextcloud/$1/files/$3"
("Composite", "SubSecCreateDate"),
("ExifIFD", "DateTimeOriginal"),
("ExifIFD", "CreateDate"),
("XMP-xmp", "CreateDate"),
("Keys", "CreationDate"),
("QuickTime", "CreateDate"),
("XMP-photoshop", "DateCreated"),
]
REL_UNSORTABLE_PATH="/$1/files/$4"
ABS_UNSORTABLE_PATH="/var/lib/nextcloud/$1/files/$4"
def run(command: list[str], check: bool = True) -> subprocess.CompletedProcess: echo "STARTING..."
return subprocess.run(command, text=True, capture_output=True, check=check)
chown -R www-data:www-data "$ABS_SOURCE_PATH"
chmod -R 770 "$ABS_SOURCE_PATH"
def exiftool_data(file: Path) -> dict | None: SCAN="FALSE"
result = run([ IFS=$'\n'
"exiftool", for f in `find "$ABS_SOURCE_PATH" -iname *.PNG -o -iname *.JPG -o -iname *.JPEG -o -iname *.HEIC -o -iname *.CR2 -o -iname *.CR3 -o -iname *.MP4 -o -iname *.MOV`; do
"-j", SCAN="TRUE"
"-a", echo "PROCESSING: $f"
"-u",
"-g1",
"-time:all",
"-api", "QuickTimeUTC=1",
"-d", "%Y-%m-%dT%H:%M:%S%z",
str(file),
], check=False)
if result.returncode != 0:
return None
try:
data = __import__("json").loads(result.stdout)
return data[0] if data else None
except Exception:
return None
EXIF=`exiftool "$f"`
def exiftool_timestamp(file: Path) -> datetime | None: if grep -q '^Create Date' <<< $EXIF
data = exiftool_data(file) then
if not data: DATETIME=`grep -m 1 "^Create Date" <<< $EXIF | cut -d: -f2- | xargs`
return None elif grep -q '^File Modification Date' <<< $EXIF
then
for category, key in DATETIME_KEYS: DATETIME=`grep -m 1 '^File Modification Date' <<< $EXIF | cut -d: -f2- | xargs`
try: else
value = data[category][key] RELPATH=$(realpath --relative-to="$ABS_SOURCE_PATH" "$f")
except (KeyError, TypeError): DIRNAME=$(dirname "$ABS_UNSORTABLE_PATH/$RELPATH")
continue echo "UNSORTABLE: $f"
try: mkdir -p "$DIRNAME"
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") mv "$f" "$DIRNAME"
except ValueError:
continue continue
fi
return None DATE=`cut -d' ' -f1 <<< $DATETIME`
TIME=`cut -d' ' -f2 <<< $DATETIME | cut -d'+' -f1`
YEAR=`cut -d':' -f1 <<< $DATE`
MONTH=`cut -d':' -f2 <<< $DATE`
DAY=`cut -d':' -f3 <<< $DATE`
HOUR=`cut -d':' -f1 <<< $TIME`
MINUTE=`cut -d':' -f2 <<< $TIME`
SECOND=`cut -d':' -f3 <<< $TIME`
def short_hash(file: Path) -> str: HASH=`sha256sum "$f" | xxd -r -p | base64 | head -c 3 | tr '/+' '_-'`
h = hashlib.sha256() EXT=`echo "${f##*.}" | tr '[:upper:]' '[:lower:]'`
with file.open("rb") as fh: if [[ "$EXT" = "cr2" ]] || [[ "$EXT" = "cr3" ]]
for chunk in iter(lambda: fh.read(1024 * 1024), b""): then
h.update(chunk) RAW="raw/"
digest = h.digest() else
b64 = base64.b64encode(digest).decode("ascii") RAW=""
return b64[:3].replace("/", "_").replace("+", "-") fi
FILE="$ABS_DEST_PATH/$YEAR-$MONTH/$RAW$YEAR$MONTH$DAY"-"$HOUR$MINUTE$SECOND"_"$HASH"."$EXT"
echo "DESTINATION: $FILE"
mkdir -p "$(dirname "$FILE")"
mv -v "$f" "$FILE"
done
if [ "$SCAN" == "TRUE" ]; then
echo "SCANNING..."
# find "$ABS_SOURCE_PATH/"* -type d -empty -delete >> /var/echo/nc-picsort.echo # nextcloud app bug when deleting folders
chown -R www-data:www-data "$ABS_DEST_PATH"
chown -R www-data:www-data "$ABS_UNSORTABLE_PATH"
chmod -R 770 "$ABS_DEST_PATH"
chmod -R 770 "$ABS_UNSORTABLE_PATH"
sudo -u www-data php /opt/nextcloud/occ files:scan --path "$REL_SOURCE_PATH"
sudo -u www-data php /opt/nextcloud/occ files:scan --path "$REL_UNSORTABLE_PATH"
sudo -u www-data php /opt/nextcloud/occ files:scan --path "$REL_DEST_PATH"
#sudo -u www-data php /opt/nextcloud/occ preview:pre-generate
fi
def build_destination(dest_root: Path, file: Path, ts: datetime) -> Path: echo "FINISH."
ext = file.suffix.lower().lstrip(".")
year = ts.strftime("%Y")
month = ts.strftime("%m")
day = ts.strftime("%d")
hour = ts.strftime("%H")
minute = ts.strftime("%M")
second = ts.strftime("%S")
hash_part = short_hash(file)
raw_subdir = "raw" if ext in {"cr2", "cr3"} else None
month_dir = dest_root / f"{year}-{month}"
if raw_subdir:
month_dir = month_dir / raw_subdir
filename = f"{year}{month}{day}-{hour}{minute}{second}_{hash_part}.{ext}"
return month_dir / filename
def move_unsortable(file: Path, source_root: Path, unsortable_root: Path) -> None:
relpath = file.relative_to(source_root)
target_dir = (unsortable_root / relpath).parent
target_dir.mkdir(parents=True, exist_ok=True)
shutil.chown(str(target_dir), user="www-data", group="www-data")
target = target_dir / file.name
if target.exists():
return
shutil.move(str(file), str(target))
shutil.chown(str(target), user="www-data", group="www-data")
def move_sorted(file: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.chown(str(target.parent), user="www-data", group="www-data")
shutil.move(str(file), str(target))
shutil.chown(str(target), user="www-data", group="www-data")
def process_file(file: Path, source_root: Path, dest_root: Path, unsortable_root: Path) -> tuple[Path, str]:
print(f"PROCESSING: {file}")
ts = exiftool_timestamp(file)
if ts is None:
print(f"UNSORTABLE: {file}")
move_unsortable(file, source_root, unsortable_root)
return file, "unsortable"
target = build_destination(dest_root, file, ts)
print(f"DESTINATION: {target}")
move_sorted(file, target)
return file, "sorted"
def scan_nextcloud(rel_source: str, rel_unsortable: str, rel_dest: str) -> None:
print("SCANNING...")
# run(["chown", "-R", "www-data:www-data", abs_source_path], check=True)
# run(["chmod", "-R", "770", abs_source_path], check=True)
# run(["chown", "-R", "www-data:www-data", abs_dest_path], check=True)
# run(["chown", "-R", "www-data:www-data", abs_unsortable_path], check=True)
# run(["chmod", "-R", "770", abs_dest_path], check=True)
# run(["chmod", "-R", "770", abs_unsortable_path], check=True)
run(["sudo", "-u", "www-data", "php", "/opt/nextcloud/occ", "files:scan", "--path", rel_source], check=True)
run(["sudo", "-u", "www-data", "php", "/opt/nextcloud/occ", "files:scan", "--path", rel_unsortable], check=True)
run(["sudo", "-u", "www-data", "php", "/opt/nextcloud/occ", "files:scan", "--path", rel_dest], check=True)
run(["systemctl", "start", "nextcloud-generate-new-previews.service"], check=True)
def iter_files(source_root: Path):
for path in source_root.rglob("*"):
if path.is_file() and path.suffix.lower() in ALLOWED_EXTS:
yield path
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Sort Nextcloud media files by embedded timestamp."
)
parser.add_argument("nc_user")
parser.add_argument("source_subdir")
parser.add_argument("dest_subdir")
parser.add_argument("unsortable_subdir")
parser.add_argument("--workers", type=int, default=os.cpu_count() or 1)
args = parser.parse_args()
nc_user = args.nc_user
source_subdir = args.source_subdir
dest_subdir = args.dest_subdir
unsortable_subdir = args.unsortable_subdir
rel_source_path = f"/{nc_user}/files/{source_subdir}"
abs_source_path = f"/var/lib/nextcloud/{nc_user}/files/{source_subdir}"
rel_dest_path = f"/{nc_user}/files/{dest_subdir}"
abs_dest_path = f"/var/lib/nextcloud/{nc_user}/files/{dest_subdir}"
rel_unsortable_path = f"/{nc_user}/files/{unsortable_subdir}"
abs_unsortable_path = f"/var/lib/nextcloud/{nc_user}/files/{unsortable_subdir}"
source_root = Path(abs_source_path)
dest_root = Path(abs_dest_path)
unsortable_root = Path(abs_unsortable_path)
print("STARTING...")
run(["chown", "-R", "www-data:www-data", str(source_root)], check=True)
run(["chmod", "-R", "770", str(source_root)], check=True)
files = list(iter_files(source_root))
if not files:
print("NO MATCHING FILES FOUND.")
print("FINISH.")
raise SystemExit(0)
with ThreadPoolExecutor(max_workers=max(1, args.workers)) as executor:
futures = {
executor.submit(process_file, file, source_root, dest_root, unsortable_root): file
for file in files
}
for future in as_completed(futures):
future.result()
scan_nextcloud(rel_source_path, rel_unsortable_path, rel_dest_path)
print("FINISH.")

View file

@ -1,71 +0,0 @@
Nextcloud
=========
import iphone pictures
----------------------
Use Photos app on macOS
- select library in the left sidebar
- select the pictures
- in menu bar open File > Export Unmodified Original for X Photos
The only reliable way to get some files creation time is being lost with rsync, so
we need to embed those timestamps on macos first:
```sh
PHOTOS_PATH="/Users/mwiegand/Desktop/photos"
bin/timestamp_icloud_photos_for_nextcloud -d "$PHOTOS_PATH"
rsync -avh --progress --rsync-path="sudo rsync" "$PHOTOS_PATH/" ckn@10.0.0.2:/var/lib/nextcloud/ckn/files/SofortUpload/AutoSort/
```
preview generator
-----------------
```
sudo -u www-data php /opt/nextcloud/occ preview:generate-all -w "$(nproc)" -n -vvv
```
This index speeds up preview generator dramatically:
```sh
CREATE INDEX CONCURRENTLY oc_filecache_path_hash_idx
ON oc_filecache (path_hash);
```
delete previews:
```sh
psql nextcloud -x -c "DELETE FROM oc_previews;"
rm -rf /var/lib/nextcloud/appdata_oci6dw1woodz/preview/*
```
https://docs.nextcloud.com/server/stable/admin_manual/configuration_files/previews_configuration.html#maximum-preview-size
```php
'preview_max_x' => 1920,
'preview_max_y' => 1920,
'preview_max_scale_factor' => 4,
```
https://github.com/nextcloud/previewgenerator?tab=readme-ov-file#i-dont-want-to-generate-all-the-preview-sizes
```sh
sudo -u www-data php /opt/nextcloud/occ config:app:set --value="64 256" previewgenerator squareSizes
sudo -u www-data php /opt/nextcloud/occ config:app:set --value="" previewgenerator fillWidthHeightSizes # changed
sudo -u www-data php /opt/nextcloud/occ config:app:set --value="" previewgenerator widthSizes
sudo -u www-data php /opt/nextcloud/occ config:app:set --value="" previewgenerator heightSizes
sudo -u www-data php /opt/nextcloud/occ config:app:set preview jpeg_quality --value="75"
sudo -u www-data php /opt/nextcloud/occ config:app:set --value=0 --type=integer previewgenerator job_max_previews # in favour of systemd timer
```
gen previews
```sh
php /opt/nextcloud/occ preview:generate-all --workers="$(nproc)" --no-interaction -vvv
```
check preview geenration
```sh
find /var/lib/nextcloud/appdata_oci6dw1woodz/preview
# /var/lib/nextcloud/appdata_oci6dw1woodz/preview/6/9/1/f/7/b/4/2822419/64-64-crop.jpg
# /var/lib/nextcloud/appdata_oci6dw1woodz/preview/6/9/1/f/7/b/4/2822419/256-256-crop.jpg
# /var/lib/nextcloud/appdata_oci6dw1woodz/preview/6/9/1/f/7/b/4/2822419/1280-1920-max.jpg
du -sh /var/lib/nextcloud/appdata_oci6dw1woodz/preview
# 28G /var/lib/nextcloud/appdata_oci6dw1woodz/preview
```

View file

@ -0,0 +1,5 @@
#!/bin/bash
php /opt/nextcloud/occ files:scan --all
php /opt/nextcloud/occ files:scan-app-data
#php /opt/nextcloud/occ preview:generate-all

View file

@ -146,3 +146,15 @@ actions['nextcloud_add_missing_inidces'] = {
f'action:extract_nextcloud', f'action:extract_nextcloud',
], ],
} }
# RESCAN
files['/opt/nextcloud_rescan'] = {
'source': 'rescan',
'owner': 'www-data',
'group': 'www-data',
'mode': '550',
'needs': [
'action:extract_nextcloud',
],
}

View file

@ -1,5 +1,5 @@
from shlex import quote import string
from uuid import UUID
defaults = { defaults = {
'apt': { 'apt': {
@ -85,35 +85,11 @@ defaults = {
'user': 'www-data', 'user': 'www-data',
'kill_mode': 'process', 'kill_mode': 'process',
}, },
'nextcloud-scan-app-data': { 'nextcloud-rescan': {
'command': '/usr/bin/php /opt/nextcloud/occ files:scan-app-data', 'command': '/opt/nextcloud_rescan',
'when': 'yearly', 'when': 'Sun 00:00:00',
'user': 'www-data', 'user': 'www-data',
}, },
'nextcloud-scan-files': {
'command': '/usr/bin/php /opt/nextcloud/occ files:scan --all',
'when': 'weekly',
'user': 'www-data',
'after': {
'nextcloud-scan-app-data.service',
},
},
'nextcloud-generate-all-previews': {
'command': '/bin/bash -c ' + quote('php /opt/nextcloud/occ preview:generate-all --workers="$(nproc)" --no-interaction -vvv'),
'when': 'monthly',
'user': 'www-data',
'after': {
'nextcloud-scan-files.service',
},
},
'nextcloud-generate-new-previews': {
'command': '/usr/bin/php /opt/nextcloud/occ preview:pre-generate --no-interaction -vvv',
'when': '*:0/5', # every 5 minutes
'user': 'www-data',
'after': {
'nextcloud-generate-all-previews.service',
},
},
}, },
} }
@ -158,18 +134,10 @@ def config(metadata):
'127.0.0.1', '127.0.0.1',
metadata.get('nextcloud/hostname'), metadata.get('nextcloud/hostname'),
], ],
'enabledPreviewProviders': [
'OC\\Preview\\Image',
'OC\\Preview\\Movie',
'OC\\Preview\\HEIC',
],
'preview_max_x': 1920,
'preview_max_y': 1920,
'preview_max_scale_factor': 4,
'log_type': 'syslog', 'log_type': 'syslog',
'syslog_tag': 'nextcloud', 'syslog_tag': 'nextcloud',
'logfile': '', 'logfile': '',
'loglevel': 2, 'loglevel': 3,
'default_phone_region': 'DE', 'default_phone_region': 'DE',
'versions_retention_obligation': 'auto, 90', 'versions_retention_obligation': 'auto, 90',
'simpleSignUpLink.shown': False, 'simpleSignUpLink.shown': False,

View file

@ -1,36 +0,0 @@
# nginx
Webserver. Per-node vhosts in `nginx/vhosts`; per-vhost templates in
`data/nginx/*.conf`.
## How port 80 is served
The bundle ships a fixed `80.conf` to
`/etc/nginx/sites-available/80.conf` (picked up by the
`sites-enabled/` symlink) that handles **all** port-80 traffic
across vhosts:
1. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`) are
served from `/var/lib/dehydrated/acme-challenges/`.
2. All other port-80 requests are 301-redirected to
`https://$host$request_uri`.
Per-vhost templates only declare `listen 443 ssl http2;`, so they
don't need their own port-80 server blocks. If you need vhost-
specific port-80 behaviour (e.g. plain-HTTP without redirect),
override 80.conf or add a per-vhost block.
## Required metadata
- `vm/cores` — read directly by `items.py` for `worker_processes`.
No default; `bw items <node>` raises at item-build time if missing.
Typically supplied by the `vm` bundle / hetzner-vm group; double-
check on bare-metal hosts.
- `nginx/vhosts` — dict of vhost-name → vhost-config.
- `nginx/modules` — list of dynamic modules to load.
## Cross-namespace
`items.py` reads `letsencrypt/domains` to skip emitting a per-vhost
HTTPS block when LE hasn't declared the domain yet — keeps the
bundle loadable on a node where letsencrypt isn't fully wired up.

View file

@ -32,13 +32,12 @@ http {
% endif % endif
# Always defined: serves both WS-enabled vhosts (Connection: upgrade for % if has_websockets:
# ws clients) and SSE/keep-alive vhosts (Connection: "" lets nginx manage
# the upstream connection for keep-alive, instead of forcing "close").
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' ''; '' close;
} }
% endif
include /etc/nginx/sites-enabled/*; include /etc/nginx/sites-enabled/*;
} }

View file

@ -64,7 +64,7 @@ files = {
'svc_systemd:nginx:restart', 'svc_systemd:nginx:restart',
}, },
}, },
'/etc/nginx/sites-available/80.conf': { '/etc/nginx/sites/80.conf': {
'triggers': { 'triggers': {
'svc_systemd:nginx:restart', 'svc_systemd:nginx:restart',
}, },

View file

@ -25,9 +25,9 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'postfix': { 'postfix': [{}],
'default': {},
}, },
}, },
}, },

View file

@ -31,17 +31,6 @@ defaults = {
'grafana_rows': set(), 'grafana_rows': set(),
} }
if node.has_bundle('zfs'):
defaults['zfs'] = {
'datasets': {
'tank/postgresql': {
'mountpoint': '/var/lib/postgresql',
'recordsize': '8192',
'atime': 'off',
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'postgresql/conf', 'postgresql/conf',
@ -89,17 +78,37 @@ def apt(metadata):
@metadata_reactor.provides( @metadata_reactor.provides(
'telegraf/inputs/postgresql/default', 'zfs/datasets',
) )
def telegraf(metadata): def zfs(metadata):
if not node.has_bundle('zfs'):
return {}
return { return {
'telegraf': { 'zfs': {
'inputs': { 'datasets': {
'postgresql': { 'tank/postgresql': {
'default': { 'mountpoint': '/var/lib/postgresql',
'address': f'postgres://root:{root_password}@localhost:5432/postgres', 'recordsize': '8192',
'databases': sorted(list(node.metadata.get('postgresql/databases').keys())), 'atime': 'off',
}, },
},
},
}
@metadata_reactor.provides(
'telegraf/config/inputs/postgresql',
)
def telegraf(metadata):
return {
'telegraf': {
'config': {
'inputs': {
'postgresql': [{
'address': f'postgres://root:{root_password}@localhost:5432/postgres',
'databases': sorted(list(node.metadata.get('postgresql/databases').keys())),
}],
}, },
}, },
}, },

View file

@ -18,7 +18,7 @@ defaults = {
'sources': { 'sources': {
'proxmox-ve': { 'proxmox-ve': {
'options': { 'options': {
'Architectures': 'amd64', 'aarch': 'amd64',
}, },
'urls': { 'urls': {
'http://download.proxmox.com/debian/pve', 'http://download.proxmox.com/debian/pve',

View file

@ -6,24 +6,35 @@ defaults = {
}, },
} }
if node.has_bundle('zfs'):
defaults['zfs'] = { @metadata_reactor.provides(
'kernel_params': { 'telegraf/config/agent',
'zfs_txg_timeout': 300, )
def telegraf(metadata):
return {
'telegraf': {
'config': {
'agent': {
'flush_interval': '30s',
'interval': '30s',
},
},
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'telegraf/agent', 'zfs/kernel_params',
'zfs/datasets',
) )
def telegraf(metadata): def zfs(metadata):
metadata.get('telegraf/agent') # only override if telegraf bundle is present if not node.has_bundle('zfs'):
return {}
return { return {
'telegraf': { 'zfs': {
'agent': { 'kernel_params': {
'flush_interval': '30s', 'zfs_txg_timeout': 300,
'interval': '1m',
}, },
}, },
} }

View file

@ -1,13 +1,3 @@
defaults = {
'systemd-timers': {
'raspberrymatic-cert': {
'command': '/opt/raspberrymatic-cert',
'when': 'daily',
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'letsencrypt/domains', 'letsencrypt/domains',
) )
@ -21,3 +11,17 @@ def letsencrypt(metadata):
}, },
}, },
} }
@metadata_reactor.provides(
'systemd-timers/raspberrymatic-cert',
)
def systemd_timers(metadata):
return {
'systemd-timers': {
'raspberrymatic-cert': {
'command': '/opt/raspberrymatic-cert',
'when': 'daily',
}
},
}

View file

@ -9,11 +9,11 @@ $config['enable_installer'] = true;
$config['db_dsnw'] = '${database['provider']}://${database['user']}:${database['password']}@${database['host']}/${database['name']}'; $config['db_dsnw'] = '${database['provider']}://${database['user']}:${database['password']}@${database['host']}/${database['name']}';
$config['imap_host'] = 'ssl://${imap_host}'; $config['imap_host'] = 'ssl://${imap_host}';
$config['imap_port'] = 993; $config['imap_port'] = 993;
#$config['imap_debug'] = true; $config['smtp_host'] = 'tls://localhost';
$config['smtp_host'] = 'tls://${imap_host}';
$config['smtp_port'] = 587; $config['smtp_port'] = 587;
$config['smtp_user'] = '%u'; $config['smtp_user'] = '%u';
$config['smtp_pass'] = '%p'; $config['smtp_pass'] = '%p';
#$config['imap_debug'] = true;
#$config['smtp_debug'] = true; #$config['smtp_debug'] = true;
$config['support_url'] = ''; $config['support_url'] = '';
$config['des_key'] = '${des_key}'; $config['des_key'] = '${des_key}';

File diff suppressed because it is too large Load diff

View file

@ -1,11 +0,0 @@
files = {
# https://mikrotik.com/download/tools
'/usr/share/snmp/mibs/MIKROTIK-MIB.txt': {
'source': 'mikrotik.mib',
'content_type': 'binary',
'mode': '0644',
'needed_by': {
'svc_systemd:telegraf.service',
},
},
}

View file

@ -1,444 +0,0 @@
input_defaults = {
"agents": [
f"udp://{routeros_node.hostname}:161"
for routeros_node in repo.nodes_in_group("routeros")
],
"agent_host_tag": "source",
"version": 2,
"community": "public",
"max_repetitions": 5, # supposedly less spiky loads
"tags": {
"operating_system": "routeros",
},
}
defaults = {
'apt': {
'packages': {
'snmp': {},
'snmp-mibs-downloader': {},
},
},
"telegraf": {
"processors": {
"enum": {
"mikrotik_host_mapping":{
# - measurements get switch ip as agent_host tag
# - wie define a value mapping ip -> node name
# - agent_host gets translated and written into host tag
"tagpass": {
"operating_system": ["routeros"],
},
"mapping": [
{
"tags": ["source"],
"dest": "host",
"default": "unknown",
"value_mappings": {
routeros_node.hostname: routeros_node.name
for routeros_node in repo.nodes_in_group("routeros")
},
},
],
},
},
},
"inputs": {
"snmp": {
"mikrotik_switches_fast": {
"interval": "2m",
"collection_jitter": "20s",
**input_defaults,
"table": [
# CPU load (HR-MIB)
{
"name": "mikrotik_cpu",
"oid": "HOST-RESOURCES-MIB::hrProcessorTable",
"field": [
{
"name": "frw_id",
"oid": "HOST-RESOURCES-MIB::hrProcessorFrwID",
"is_tag": True,
},
{
"name": "load",
"oid": "HOST-RESOURCES-MIB::hrProcessorLoad",
},
],
},
# Storage (HR-MIB)
{
"name": "mikrotik_storage",
"oid": "HOST-RESOURCES-MIB::hrStorageTable",
"field": [
{
"name": "index",
"oid": "HOST-RESOURCES-MIB::hrStorageIndex",
"is_tag": True,
},
{
"name": "type",
"oid": "HOST-RESOURCES-MIB::hrStorageType",
"is_tag": True,
},
{
"name": "descr",
"oid": "HOST-RESOURCES-MIB::hrStorageDescr",
"is_tag": True,
},
{
"name": "alloc_unit",
"oid": "HOST-RESOURCES-MIB::hrStorageAllocationUnits",
},
{
"name": "size",
"oid": "HOST-RESOURCES-MIB::hrStorageSize",
},
{
"name": "used",
"oid": "HOST-RESOURCES-MIB::hrStorageUsed",
},
{
"name": "alloc_failures",
"oid": "HOST-RESOURCES-MIB::hrStorageAllocationFailures",
},
],
},
# MikroTik Health (table)
{
"name": "mikrotik_health",
"oid": "MIKROTIK-MIB::mtxrGaugeTable",
"field": [
{
"name": "sensor",
"oid": "MIKROTIK-MIB::mtxrGaugeName",
"is_tag": True,
},
{
"name": "value",
"oid": "MIKROTIK-MIB::mtxrGaugeValue",
},
{
"name": "unit",
"oid": "MIKROTIK-MIB::mtxrGaugeUnit",
"is_tag": True,
},
],
},
],
},
"mikrotik_switches_slow": {
"interval": "7m",
"collection_jitter": "2m",
**input_defaults,
"table": [
# Interface statistics (standard IF-MIB)
{
"name": "mikrotik_interface_generic",
"oid": "IF-MIB::ifTable",
"field": [
# 6: ethernetCsmacd (physischer Ethernet-Port)
# 24: softwareLoopback
# 53: propVirtual (oft VLANs bei MikroTik)
# 131: tunnel
# 135: l2vlan
# 161: ieee8023adLag (Bonding/LACP)
# 209: bridge
{
"name": "ifType",
"oid": "IF-MIB::ifType",
"is_tag": True,
},
# Labels (optional but recommended)
{
"name": "ifName",
"oid": "IF-MIB::ifName",
"is_tag": True,
},
{
"name": "ifAlias",
"oid": "IF-MIB::ifAlias",
"is_tag": True,
},
# Bytes (64-bit)
{
"name": "in_octets",
"oid": "IF-MIB::ifHCInOctets",
},
{
"name": "out_octets",
"oid": "IF-MIB::ifHCOutOctets",
},
# Packets (64-bit unicast)
{
"name": "in_ucast_pkts",
"oid": "IF-MIB::ifHCInUcastPkts",
},
{
"name": "out_ucast_pkts",
"oid": "IF-MIB::ifHCOutUcastPkts",
},
{
"name": "in_mcast_pkts",
"oid": "IF-MIB::ifHCInMulticastPkts",
},
{
"name": "in_bcast_pkts",
"oid": "IF-MIB::ifHCInBroadcastPkts",
},
{
"name": "out_mcast_pkts",
"oid": "IF-MIB::ifHCOutMulticastPkts",
},
{
"name": "out_bcast_pkts",
"oid": "IF-MIB::ifHCOutBroadcastPkts",
},
# Drops / Errors
{
"name": "in_discards",
"oid": "IF-MIB::ifInDiscards",
},
{
"name": "out_discards",
"oid": "IF-MIB::ifOutDiscards",
},
{
"name": "in_errors",
"oid": "IF-MIB::ifInErrors",
},
{
"name": "out_errors",
"oid": "IF-MIB::ifOutErrors",
},
],
},
# Interface PoE
{
"name": "mikrotik_poe",
"oid": "MIKROTIK-MIB::mtxrPOETable",
"field": [
{
"name": "ifName",
"oid": "IF-MIB::ifName",
"is_tag": True,
},
{
"name": "ifAlias",
"oid": "IF-MIB::ifAlias",
"is_tag": True,
},
{
"name": "ifindex",
"oid": "MIKROTIK-MIB::mtxrPOEInterfaceIndex",
"is_tag": True,
},
{
"name": "status",
"oid": "MIKROTIK-MIB::mtxrPOEStatus",
},
{
"name": "voltage",
"oid": "MIKROTIK-MIB::mtxrPOEVoltage",
},
{
"name": "current",
"oid": "MIKROTIK-MIB::mtxrPOECurrent",
},
{
"name": "power",
"oid": "MIKROTIK-MIB::mtxrPOEPower",
},
],
},
],
},
"mikrotik_switches_very_slow": {
"interval": "20m",
"collection_jitter": "5m",
**input_defaults,
"table": [
# Interface statistics (MikroTik-specific mib)
{
"name": "mikrotik_interface_detailed",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTable",
"field": [
# Join key / label (usually identical to IF-MIB ifName)
{
"name": "ifName",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsName",
"is_tag": True,
},
# join IF-MIB for better labels
{
"name": "ifAlias",
"oid": "IF-MIB::ifAlias",
"is_tag": True,
},
# =========================
# Physical layer (L1/L2)
# =========================
# CRC/FCS errors → very often cabling, connectors, SFPs, signal quality (EMI)
{
"name": "rx_fcs_errors",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxFCSError",
},
# Alignment errors → typically duplex mismatch or PHY problems
{
"name": "rx_align_errors",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxAlignError",
},
# Code errors → PHY encoding errors (signal/SFP/PHY)
{
"name": "rx_code_errors",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxCodeError",
},
# Carrier errors → carrier lost (copper issues, autoneg, PHY instability)
{
"name": "rx_carrier_errors",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxCarrierError",
},
# Jabber → extremely long invalid frames (faulty NIC/PHY, very severe)
{
"name": "rx_jabber",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxJabber",
},
# ==================================
# Length / framing anomalies (diagnostic)
# ==================================
# Frames shorter than minimum (noise, collisions, broken sender)
{
"name": "rx_too_short",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxTooShort",
},
# Frames longer than allowed (MTU mismatch, framing errors)
{
"name": "rx_too_long",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxTooLong",
},
# Fragments (often collision-related or duplex mismatch)
{
"name": "rx_fragment",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxFragment",
},
# Generic length errors
{
"name": "rx_length_errors",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxLengthError",
},
# ==================
# Drops (real packet loss)
# ==================
# RX drops (queue/ASIC/policy/overload) → highly alert-worthy
{
"name": "rx_drop",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxDrop",
},
# TX drops (buffer/queue exhaustion, scheduling, ASIC limits)
{
"name": "tx_drop",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxDrop",
},
# =========================================
# Duplex / collision indicators
# (should be zero on full-duplex links)
# =========================================
# Total collisions (relevant only for half-duplex or misconfigurations)
{
"name": "tx_collisions",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxCollision",
},
# Late collisions → almost always duplex mismatch / bad autoneg
{
"name": "tx_late_collisions",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxLateCollision",
},
# Aggregate collision counter (context)
{
"name": "tx_total_collisions",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxTotalCollision",
},
# Excessive collisions → persistent duplex problems
{
"name": "tx_excessive_collisions",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxExcessiveCollision",
},
# ==================
# Flow control (diagnostic)
# ==================
# Pause frames received (peer throttling you)
{
"name": "rx_pause",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsRxPause",
},
# Pause frames sent (you throttling the peer)
{
"name": "tx_pause",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxPause",
},
# Pause frames actually honored
{
"name": "tx_pause_honored",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsTxPauseHonored",
},
# ==========
# Stability
# ==========
# Link-down events (loose cables, bad SFPs, PoE power drops, reboots)
{
"name": "link_downs",
"oid": "MIKROTIK-MIB::mtxrInterfaceStatsLinkDowns",
},
],
},
],
},
},
},
},
}
# @metadata_reactor.provides(
# 'telegraf/processors/enum',
# )
# def tag_important_ports(metadata):
# # We want a graph, only for important ports. We deem ports important, if they have untagged vlans configured.
# return {
# "telegraf": {
# "processors": {
# "enum": {
# f"mikrotik_port_mapping_{routeros_node.name}":{
# "tagpass": {
# "agent_host": [routeros_node.hostname],
# },
# "mapping": [
# {
# "tag": "ifName",
# "dest": "is_infra",
# "default": "false",
# "value_mappings": {
# port_name: "true"
# for port_name, port_conf in repo.libs.mikrotik.get_netbox_config_for(routeros_node)['interfaces'].items()
# if port_conf['mode'] == "tagged-all" or port_conf['tagged_vlans']
# if port_conf['type'] != "lag"
# },
# },
# ],
# }
# for routeros_node in repo.nodes_in_group("switches-mikrotik")
# },
# },
# },
# }

View file

@ -27,15 +27,15 @@ routeros['/system/identity'] = {
# for topic in LOGGING_TOPICS: # for topic in LOGGING_TOPICS:
# routeros[f'/system/logging?action=memory&topics={topic}'] = {} # routeros[f'/system/logging?action=memory&topics={topic}'] = {}
routeros['/snmp'] = { # routeros['/snmp'] = {
'enabled': True, # 'enabled': True,
} # }
routeros['/snmp/community?name=public'] = { # routeros['/snmp/community?name=public'] = {
'addresses': '0.0.0.0/0', # 'addresses': '0.0.0.0/0',
'disabled': False, # 'disabled': False,
'read-access': True, # 'read-access': True,
'write-access': False, # 'write-access': False,
} # }
routeros['/system/clock'] = { routeros['/system/clock'] = {
'time-zone-autodetect': False, 'time-zone-autodetect': False,
@ -55,7 +55,7 @@ for vlan_name, vlan_id in node.metadata.get('routeros/vlans').items():
'vlan-id': vlan_id, 'vlan-id': vlan_id,
'interface': 'bridge', 'interface': 'bridge',
'tags': { 'tags': {
'routeros-vlans', 'routeros-vlan',
}, },
} }
@ -68,33 +68,10 @@ for vlan_name, vlan_id in node.metadata.get('routeros/vlans').items():
'routeros-vlan-ports', 'routeros-vlan-ports',
}, },
'needs': { 'needs': {
'tag:routeros-vlans', 'tag:routeros-vlan',
}, },
} }
for port_name, port_conf in node.metadata.get('routeros/ports').items():
untagged_vlan = node.metadata.get('routeros/vlan_groups')[port_conf.get('vlan_group')]['untagged']
routeros[f'/interface/bridge/port?interface={port_name}'] = {
'disabled': False,
'bridge': 'bridge',
'pvid': node.metadata.get('routeros/vlans')[untagged_vlan],
'tags': {
'routeros-ports'
},
'needs': {
'tag:routeros-vlan-ports',
},
}
routeros[f'/interface?name={port_name}'] = {
'_comment': port_conf.get('description', ''),
}
if comment := port_conf.get('comment', None):
routeros[f'/interface/bridge/port?interface={port_name}']['_comment'] = comment
routeros[f'/interface?name={port_name}']['_comment'] = comment
# create IPs # create IPs
for ip, ip_conf in node.metadata.get('routeros/ips').items(): for ip, ip_conf in node.metadata.get('routeros/ips').items():
routeros[f'/ip/address?address={ip}'] = { routeros[f'/ip/address?address={ip}'] = {
@ -103,8 +80,7 @@ for ip, ip_conf in node.metadata.get('routeros/ips').items():
'routeros-ip', 'routeros-ip',
}, },
'needs': { 'needs': {
'tag:routeros-vlans', 'tag:routeros-vlan',
'tag:routeros-ports'
}, },
} }
@ -114,8 +90,7 @@ routeros['/interface/bridge?name=bridge'] = {
'priority': node.metadata.get('routeros/bridge_priority'), 'priority': node.metadata.get('routeros/bridge_priority'),
'protocol-mode': 'rstp', 'protocol-mode': 'rstp',
'needs': { 'needs': {
'tag:routeros-vlans', 'tag:routeros-vlan',
'tag:routeros-ports',
'tag:routeros-vlan-ports', 'tag:routeros-vlan-ports',
'tag:routeros-ip', 'tag:routeros-ip',
}, },
@ -127,7 +102,7 @@ routeros['/interface/vlan'] = {
'id-by': 'name', 'id-by': 'name',
}, },
'needed_by': { 'needed_by': {
'tag:routeros-vlans', 'tag:routeros-vlan',
} }
} }
@ -139,6 +114,6 @@ routeros['/interface/bridge/vlan'] = {
}, },
}, },
'needed_by': { 'needed_by': {
'tag:routeros-vlans', 'tag:routeros-vlan',
} }
} }

View file

@ -11,22 +11,24 @@ defaults = {
}, },
'smartctl': {}, 'smartctl': {},
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'exec': { 'exec': {
'smartctl_power_mode': { h({
'commands': [ 'commands': [
f'sudo /usr/local/share/telegraf/smartctl_power_mode', f'sudo /usr/local/share/telegraf/smartctl_power_mode',
], ],
'data_format': 'influx', 'data_format': 'influx',
'interval': '20s', 'interval': '20s',
}, }),
'smartctl_errors': { h({
'commands': [ 'commands': [
f'sudo /usr/local/share/telegraf/smartctl_errors', f'sudo /usr/local/share/telegraf/smartctl_errors',
], ],
'data_format': 'influx', 'data_format': 'influx',
'interval': '6h', 'interval': '6h',
} })
},
}, },
}, },
}, },

View file

@ -10,11 +10,7 @@ directories = {
'purge': True, 'purge': True,
'mode': '0755', 'mode': '0755',
'skip': dont_touch_sshd, 'skip': dont_touch_sshd,
}, }
'/etc/ssh/ssh_config.d': {
'mode': '0755',
'skip': dont_touch_sshd,
},
} }
files = { files = {

View file

@ -19,7 +19,7 @@ def users(metadata):
'allow_users': set( 'allow_users': set(
name name
for name, conf in metadata.get('users').items() for name, conf in metadata.get('users').items()
if conf.get('authorized_keys', []) or conf.get('authorized_users', {}) if conf.get('authorized_keys', []) or conf.get('authorized_users', [])
), ),
}, },
} }

55
bundles/steam/README.md Normal file
View file

@ -0,0 +1,55 @@
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/master/Dedicated%20Server%20Install%20Guide/README.md
mkdir /opt/steam /tmp/dumps
useradd -M -d /opt/steam -s /bin/bash steam
chown steam:steam /opt/steam /tmp/dumps
dpkg --add-architecture i386
apt update
apt install libc6:i386 lib32z1
sudo su - steam -s /bin/bash
#--------
wget http://media.steampowered.com/installer/steamcmd_linux.tar.gz
tar -xvzf steamcmd_linux.tar.gz
# fix: /opt/steam/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory
mkdir /opt/steam/.steam && ln -s /opt/steam/linux32 /opt/steam/.steam/sdk32
# erst die windows deps zu installieren scheint ein workaround für x64 zu sein?
./steamcmd.sh \
+force_install_dir /opt/steam/left4dead2 \
+login anonymous \
+@sSteamCmdForcePlatformType windows \
+app_update 222860 validate \
+quit
./steamcmd.sh \
+force_install_dir /opt/steam/left4dead2 \
+login anonymous \
+@sSteamCmdForcePlatformType linux \
+app_update 222860 validate \
+quit
# download admin system
wget -4 https://git.sublimity.de/cronekorkn/steam-workshop-downloader/raw/branch/master/steam-workshop-download
chmod +x steam-workshop-download
./steam-workshop-download 2524204971 --out /opt/steam/left4dead2/left4dead2/addons
mkdir -p "/opt/steam/left4dead2/left4dead2/ems/admin system"
echo "STEAM_1:0:12376499" > "/opt/steam/left4dead2/left4dead2/ems/admin system/admins.txt"
/opt/steam/left4dead2/srcds_run -game left4dead2 -ip 0.0.0.0 -port 27015 +map c1m1_hotel
cat <<'EOF' > /opt/steam/left4dead2/left4dead2/cfg/server.cfg
hostname "CKNs Server"
motd_enabled 0
sv_steamgroup "38347879"
#sv_steamgroup_exclusive 0
sv_minrate 60000
sv_maxrate 0
net_splitpacket_maxrate 60000
sv_hibernate_when_empty 0
EOF

54
bundles/steam/items.py Normal file
View file

@ -0,0 +1,54 @@
users = {
'steam': {
'home': '/opt/steam/steam',
},
}
directories = {
'/opt/steam': {
'owner': 'steam',
'group': 'steam',
'needs': [
'zfs_dataset:tank/steam',
],
},
'/opt/steam/steam': {
'owner': 'steam',
'group': 'steam',
},
}
for game in node.metadata.get('steam/games'):
directories[f'/opt/steam/{game}'] = {
'owner': 'steam',
'group': 'steam',
'needed_by': [
'svc_systemd:steam-update.service',
],
}
files = {
'/opt/steam/steam/steamcmd_linux.tar.gz': {
'content_type': 'download',
'source': 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz',
'owner': 'steam',
'group': 'steam',
},
}
actions = {
'extract_steamcmd': {
'command': """su - steam -c 'tar xfvz /opt/steam/steam/steamcmd_linux.tar.gz --directory /opt/steam/steam'""",
'unless': 'test -f /opt/steam/steam/steamcmd.sh',
'needs': [
'file:/opt/steam/steam/steamcmd_linux.tar.gz',
],
},
}
svc_systemd['steam-update.service'] = {
'running': None,
'enabled': True,
'needs': {
'file:/usr/local/lib/systemd/system/steam-update.service',
}
}

52
bundles/steam/metadata.py Normal file
View file

@ -0,0 +1,52 @@
defaults = {
'apt': {
'packages': {
'lib32gcc-s1': {},
'unzip': {},
},
},
'steam': {
'games': {
'left4dead2': 222860,
},
},
'zfs': {
'datasets': {
'tank/steam': {
'mountpoint': '/opt/steam',
'backup': False,
},
},
},
}
@metadata_reactor.provides(
'systemd/units',
)
def initial_unit(metadata):
return {
'systemd': {
'units': {
'steam-update.service': {
'Unit': {
'Description': 'steam: install and update games',
'After': 'network-online.target',
},
'Service': {
'Type': 'oneshot',
'User': 'steam',
'Group': 'steam',
'WorkingDirectory': '/opt/steam',
'ExecStart': {
f'/opt/steam/steam/steamcmd.sh +force_install_dir /opt/steam/{game} +login anonymous +app_update {id} validate +quit'
for game, id in metadata.get('steam/games').items()
}
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
},
},
}

View file

@ -44,8 +44,6 @@ def systemd(metadata):
units[f'{name}.service']['Service']['KillMode'] = config['kill_mode'] units[f'{name}.service']['Service']['KillMode'] = config['kill_mode']
if config.get('RuntimeMaxSec'): if config.get('RuntimeMaxSec'):
units[f'{name}.service']['Service']['RuntimeMaxSec'] = config['RuntimeMaxSec'] units[f'{name}.service']['Service']['RuntimeMaxSec'] = config['RuntimeMaxSec']
if config.get('environment_files'):
units[f'{name}.service']['Service']['EnvironmentFile'] = config['environment_files']
services[f'{name}.timer'] = {} services[f'{name}.timer'] = {}

View file

@ -1,8 +1,5 @@
from bundlewrap.utils.dicts import merge_dict from bundlewrap.utils.dicts import merge_dict
files = {}
svc_systemd = {}
directories = { directories = {
'/usr/local/lib/systemd/system': { '/usr/local/lib/systemd/system': {
'purge': True, 'purge': True,
@ -33,7 +30,7 @@ for name, unit in node.metadata.get('systemd/units').items():
'svc_systemd:systemd-networkd.service:restart', 'svc_systemd:systemd-networkd.service:restart',
], ],
} }
elif extension in ['timer', 'service', 'mount', 'swap', 'target', 'slice']: elif extension in ['timer', 'service', 'mount', 'swap', 'target']:
path = f'/usr/local/lib/systemd/system/{name}' path = f'/usr/local/lib/systemd/system/{name}'
dependencies = { dependencies = {
'triggers': [ 'triggers': [
@ -45,9 +42,6 @@ for name, unit in node.metadata.get('systemd/units').items():
else: else:
raise Exception(f'unknown type {extension}') raise Exception(f'unknown type {extension}')
for attribute in ['needs', 'needed_by', 'triggers', 'triggered_by']:
if attribute in unit:
dependencies.setdefault(attribute, []).extend(unit.pop(attribute))
files[path] = { files[path] = {
'content': repo.libs.systemd.generate_unitfile(unit), 'content': repo.libs.systemd.generate_unitfile(unit),

View file

@ -15,7 +15,7 @@ defaults = {
@metadata_reactor.provides( @metadata_reactor.provides(
'telegraf/inputs/exec', 'telegraf/config/inputs/exec',
) )
def telegraf(metadata): def telegraf(metadata):
return { return {
@ -23,11 +23,11 @@ def telegraf(metadata):
'config': { 'config': {
'inputs': { 'inputs': {
'exec': { 'exec': {
'tasmota_charge': { repo.libs.hashable.hashable({
'commands': ["/usr/local/share/telegraf/tasmota_charge"], 'commands': ["/usr/local/share/telegraf/tasmota_charge"],
'name_override': "tasmota_charge", 'name_override': "tasmota_charge",
'data_format': "influx", 'data_format': "influx",
}, }),
}, },
}, },
}, },

View file

@ -1,53 +1,19 @@
import tomlkit import tomlkit
import json
from bundlewrap.metadata import MetadataJSONEncoder
def inner_dict_to_list(dict_of_dicts):
"""
Example:
{
'cpu': {
'default': {'something': True},
'another': {'something': False},
},
}
becomes
{
'cpu': [
{'something': True},
{'something': False},
],
}
"""
return {
key: [value for _, value in sorted(dicts.items())]
for key, dicts in sorted(dict_of_dicts.items())
}
files = { files = {
"/etc/telegraf/telegraf.conf": { '/etc/telegraf/telegraf.conf': {
'owner': 'telegraf', 'content': tomlkit.dumps(
'group': 'telegraf', json.loads(json.dumps(
'mode': '0440', node.metadata.get('telegraf/config'),
'needs': [ cls=MetadataJSONEncoder,
"pkg_apt:telegraf", )),
sort_keys=True,
),
'triggers': [
'svc_systemd:telegraf:restart',
], ],
'content': tomlkit.dumps({
'agent': node.metadata.get('telegraf/agent'),
'inputs': inner_dict_to_list(node.metadata.get('telegraf/inputs')),
'processors': inner_dict_to_list(node.metadata.get('telegraf/processors')),
'outputs': inner_dict_to_list(node.metadata.get('telegraf/outputs')),
}),
'triggers': {
'svc_systemd:telegraf.service:restart',
},
},
'/etc/default/telegraf': {
'content': 'TELEGRAF_OPTS="--strict-env-handling"\n',
'mode': '0644',
'triggers': {
'svc_systemd:telegraf.service:restart',
},
}, },
'/usr/local/share/telegraf/procio': { '/usr/local/share/telegraf/procio': {
'content_type': 'download', 'content_type': 'download',
@ -61,26 +27,9 @@ files = {
}, },
} }
actions = { svc_systemd['telegraf'] = {
'telegraf-test-config': {
'command': "sudo -u telegraf bash -c 'telegraf config check --config /etc/telegraf/telegraf.conf --strict-env-handling'",
'triggered': True,
'needs': [ 'needs': [
'bundle:sudo',
'file:/etc/telegraf/telegraf.conf', 'file:/etc/telegraf/telegraf.conf',
'pkg_apt:telegraf', 'pkg_apt:telegraf',
], ],
},
}
svc_systemd = {
'telegraf.service': {
'needs': ['pkg_apt:telegraf'],
'preceded_by': {
'action:telegraf-test-config',
},
'needs': {
'action:telegraf-test-config',
},
},
} }

View file

@ -7,11 +7,6 @@ defaults = {
# needed by crystal plugins: # needed by crystal plugins:
'libgc-dev': {}, 'libgc-dev': {},
'libevent-dev': {}, 'libevent-dev': {},
(
'libpcre2-8-0'
if node.os == 'debian' and node.os_version >= (13,)
else 'libpcre3'
): {},
}, },
'sources': { 'sources': {
'influxdata': { 'influxdata': {
@ -28,29 +23,26 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'agent': { 'agent': {
'hostname': node.name, 'hostname': node.name,
'collection_jitter': '20s', 'collection_jitter': '0s',
'flush_interval': '20s', 'flush_interval': '15s',
'flush_jitter': '5s', 'flush_jitter': '0s',
'interval': '2m', 'interval': '15s',
'metric_batch_size': 1000, 'metric_batch_size': 1000,
'metric_buffer_limit': 10000, 'metric_buffer_limit': 10000,
'omit_hostname': False, 'omit_hostname': False,
'round_interval': True, 'round_interval': True,
'skip_processors_after_aggregators': True,
}, },
'inputs': { 'inputs': {
'cpu': { 'cpu': {h({
'default': {
'collect_cpu_time': False, 'collect_cpu_time': False,
'percpu': True, 'percpu': True,
'report_active': False, 'report_active': False,
'totalcpu': True, 'totalcpu': True,
}, })},
}, 'disk': {h({
'disk': {
'default': {
'ignore_fs': [ 'ignore_fs': [
'tmpfs', 'tmpfs',
'devtmpfs', 'devtmpfs',
@ -60,60 +52,42 @@ defaults = {
'aufs', 'aufs',
'squashfs', 'squashfs',
], ],
} })},
}, 'procstat': {h({
'procstat': {
'default': {
'interval': '60s', 'interval': '60s',
'pattern': '.', 'pattern': '.',
'fieldinclude': [ 'fieldinclude': [
'cpu_usage', 'cpu_usage',
'memory_rss', 'memory_rss',
], ],
}, })},
}, 'diskio': {h({
'diskio': {
'default': {
'device_tags': ["ID_PART_ENTRY_NUMBER"], 'device_tags': ["ID_PART_ENTRY_NUMBER"],
} })},
}, 'kernel': {h({})},
'kernel': { 'mem': {h({})},
'default': {}, 'processes': {h({})},
}, 'swap': {h({})},
'mem': { 'system': {h({})},
'default': {}, 'net': {h({})},
},
'processes': {
'default': {},
},
'swap': {
'default': {},
},
'system': {
'default': {},
},
'net': {
'default': {},
},
'exec': { 'exec': {
# h({ h({
# 'commands': [ 'commands': [
# f'sudo /usr/local/share/telegraf/procio', f'sudo /usr/local/share/telegraf/procio',
# ], ],
# 'data_format': 'influx', 'data_format': 'influx',
# 'interval': '20s', 'interval': '20s',
# }), }),
'pressure_stall': { h({
'commands': [ 'commands': [
f'/usr/local/share/telegraf/pressure_stall', f'/usr/local/share/telegraf/pressure_stall',
], ],
'data_format': 'influx', 'data_format': 'influx',
'interval': '10s', 'interval': '10s',
}),
}, },
}, },
}, },
'processors': {},
'outputs': {},
}, },
'grafana_rows': { 'grafana_rows': {
'cpu', 'cpu',
@ -131,21 +105,21 @@ defaults = {
@metadata_reactor.provides( @metadata_reactor.provides(
'telegraf/outputs/influxdb_v2/default', 'telegraf/config/outputs/influxdb_v2',
) )
def influxdb(metadata): def influxdb(metadata):
influxdb_metadata = repo.get_node(metadata.get('telegraf/influxdb_node')).metadata.get('influxdb') influxdb_metadata = repo.get_node(metadata.get('telegraf/influxdb_node')).metadata.get('influxdb')
return { return {
'telegraf': { 'telegraf': {
'config': {
'outputs': { 'outputs': {
'influxdb_v2': { 'influxdb_v2': [{
'default': {
'urls': [f"http://{influxdb_metadata['hostname']}:{influxdb_metadata['port']}"], 'urls': [f"http://{influxdb_metadata['hostname']}:{influxdb_metadata['port']}"],
'token': str(influxdb_metadata['writeonly_token']), 'token': str(influxdb_metadata['writeonly_token']),
'organization': influxdb_metadata['org'], 'organization': influxdb_metadata['org'],
'bucket': influxdb_metadata['bucket'], 'bucket': influxdb_metadata['bucket'],
}, }]
}, },
}, },
}, },
@ -153,3 +127,20 @@ def influxdb(metadata):
# crystal based (procio, pressure_stall): # crystal based (procio, pressure_stall):
@metadata_reactor.provides(
'apt/packages/libpcre2-8-0',
'apt/packages/libpcre3',
)
def libpcre(metadata):
if node.os == 'debian' and node.os_version >= (13,):
libpcre_package = 'libpcre2-8-0'
else:
libpcre_package = 'libpcre3'
return {
'apt': {
'packages': {
libpcre_package: {},
},
},
}

View file

@ -20,15 +20,11 @@ def authorized_users(metadata):
users[name] = { users[name] = {
'authorized_keys': set(), 'authorized_keys': set(),
} }
for authorized_user, options in config.get('authorized_users', {}).items(): for authorized_user in config.get('authorized_users', set()):
authorized_user_name, authorized_user_node = authorized_user.split('@') authorized_user_name, authorized_user_node = authorized_user.split('@')
authorized_user_public_key = repo.get_node(authorized_user_node).metadata.get(f'users/{authorized_user_name}/pubkey') users[name]['authorized_keys'].add(
repo.get_node(authorized_user_node).metadata.get(f'users/{authorized_user_name}/pubkey')
for command in options.get('commands', []): )
users[name]['authorized_keys'].add(f'command="{command}" ' + authorized_user_public_key)
else:
users[name]['authorized_keys'].add(authorized_user_public_key)
return { return {
'users': users, 'users': users,
} }

View file

@ -112,18 +112,20 @@ def systemd_networkd_netdevs(metadata):
}, },
} }
for kind in ('s2s', 'clients'): for peer, config in {
for peer in metadata.get(f'wireguard/{kind}'): **metadata.get('wireguard/s2s'),
peer_id = metadata.get(f'wireguard/{kind}/{peer}/peer_id') **metadata.get('wireguard/clients'),
netdev[f'WireGuardPeer#{peer}'] = { }.items():
'PublicKey': repo.libs.wireguard.pubkey(peer_id), netdev.update({
'PresharedKey': repo.libs.wireguard.psk(peer_id, metadata.get('id')), f'WireGuardPeer#{peer}': {
'AllowedIPs': ', '.join(metadata.get(f'wireguard/{kind}/{peer}/allowed_ips', [])), 'PublicKey': repo.libs.wireguard.pubkey(config['peer_id']),
'PresharedKey': repo.libs.wireguard.psk(config['peer_id'], metadata.get('id')),
'AllowedIPs': ', '.join(config.get('allowed_ips', [])),
'PersistentKeepalive': 30, 'PersistentKeepalive': 30,
} }
endpoint = metadata.get(f'wireguard/{kind}/{peer}/endpoint', None) })
if endpoint: if config.get('endpoint'):
netdev[f'WireGuardPeer#{peer}']['Endpoint'] = endpoint netdev[f'WireGuardPeer#{peer}']['Endpoint'] = config['endpoint']
return { return {
'systemd': { 'systemd': {

View file

@ -44,21 +44,16 @@ defaults = {
@metadata_reactor.provides( @metadata_reactor.provides(
'wol-sleeper/mac',
'wol-sleeper/waker_command',
'wol-sleeper/wake_command', 'wol-sleeper/wake_command',
) )
def wake_command(metadata): def wake_command(metadata):
waker_hostname = repo.get_node(metadata.get('wol-sleeper/waker')).hostname waker_hostname = repo.get_node(metadata.get('wol-sleeper/waker')).hostname
mac = metadata.get(f"network/{metadata.get('wol-sleeper/network')}/mac") mac = metadata.get(f"network/{metadata.get('wol-sleeper/network')}/mac")
network = ip_interface(metadata.get(f"network/{metadata.get('wol-sleeper/network')}/ipv4")) ip = ip_interface(metadata.get(f"network/{metadata.get('wol-sleeper/network')}/ipv4")).ip
waker_command = f"/usr/bin/wakeonlan -i {network.network.broadcast_address} {mac}"
return { return {
'wol-sleeper': { 'wol-sleeper': {
'mac': mac, 'wake_command': f"ssh -o StrictHostKeyChecking=no wol@{waker_hostname} 'wakeonlan {mac} && while ! ping {ip} -c1 -W3; do true; done'",
'waker_command': waker_command,
'wake_command': f"ssh -o StrictHostKeyChecking=no wol@{waker_hostname} '{waker_command}' && while ! ping {network.ip} -c1 -W3; do true; done",
}, },
} }

View file

@ -6,25 +6,17 @@ defaults = {
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'users/wol/authorized_users', 'users/wol',
) )
def user(metadata): def user(metadata):
return { return {
'users': { 'users': {
'wol': { 'wol': {
'authorized_users': { 'authorized_users': {
f'root@{ssh_client.name}': { f'root@{node.name}'
'commands': { for node in repo.nodes
sleeper.metadata.get('wol-sleeper/waker_command') if node.dummy == False and node.has_bundle('ssh')
for sleeper in repo.nodes
if sleeper.has_bundle('wol-sleeper')
and sleeper.metadata.get('wol-sleeper/waker') == node.name
}
}
for ssh_client in repo.nodes
if ssh_client.dummy == False and ssh_client.has_bundle('ssh')
}, },
}, },
}, },

View file

@ -59,9 +59,9 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': {
'inputs': { 'inputs': {
'zfs': { 'zfs': [{}],
'default': {},
}, },
}, },
}, },

View file

@ -0,0 +1,51 @@
function zsh_spwd {
paths=(${(s:/:)PWD})
cur_path='/'
cur_short_path='/'
for directory in ${paths[@]}
do
cur_dir=''
for (( i=0; i<${#directory}; i++ )); do
cur_dir+="${directory:$i:1}"
matching=("$cur_path"/"$cur_dir"*/)
if [[ ${#matching[@]} -eq 1 ]]; then
break
fi
done
cur_short_path+="$cur_dir/"
cur_path+="$directory/"
done
printf %q "${cur_short_path: : -1}"
echo
}
function zsh_root_color {
if test "$EUID" -eq 0
then
echo "%{$fg_bold[green]%}"
else
echo "%{$fg_bold[yellow]%}"
fi
}
function zsh_exitcode_color {
echo "%(?:%{$fg_bold[green]%}:%{$fg_bold[red]%})"
}
function zsh_hostname {
if [ -z "$ZSH_HOSTNAME" ]
then
hostname -s
else
echo "$ZSH_HOSTNAME"
fi
}
PROMPT='$(zsh_root_color)$(whoami)%{$reset_color%}@$(zsh_exitcode_color)$(zsh_hostname) %{$fg[cyan]%}$(zsh_spwd)%{$reset_color%} $(git_prompt_info)'
ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[blue]%}git:(%{$fg[red]%}"
ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} "
ZSH_THEME_GIT_PROMPT_DIRTY="%{$fg[blue]%}) %{$fg[yellow]%}✗"
ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg[blue]%})"

View file

@ -1,36 +1,22 @@
# /etc/zsh/zprofile — managed by ckn-bw bundles/zsh # /etc/zsh/zprofile: system-wide .zprofile file for zsh(1).
# Sourced for login shells only. SSH gives login shells; that's all we need. #
# This file is sourced only for login shells (i.e. shells
# invoked with "-" as the first character of argv[0], and
# shells invoked with the -l flag.)
#
# Global Order: zshenv, zprofile, zshrc, zlogin
setopt prompt_subst
autoload -Uz colors && colors
autoload -Uz compinit && compinit
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
# Mirrors the old bw.zsh-theme colors (sans git segment) plus a closing arrow:
# user — bold, yellow normally, green as root
# host — bold, green when last cmd succeeded, red otherwise
# path — cyan (full path, ~ for $HOME)
# — bold, same green/red as host
PROMPT='%B%(!.%F{green}.%F{yellow})%n%f%b@%B%(?.%F{green}.%F{red})%m%f%b %F{cyan}%~%f %(?.%F{green}.%F{red})%B%b%f '
# Prefix-based history search on arrow keys
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search
bindkey '^[[B' down-line-or-beginning-search
bindkey '^[OA' up-line-or-beginning-search
bindkey '^[OB' down-line-or-beginning-search
# Autosuggestions (apt-installed; guarded for minimal images)
[[ -r /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]] \
&& source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh
# Server-only conveniences, carried over from the old zprofile
alias s='sudo su - root -s /usr/bin/zsh' alias s='sudo su - root -s /usr/bin/zsh'
hhtop() {
function hhtop {
mkdir -p ~/.config/htop mkdir -p ~/.config/htop
cp /etc/htoprc.global ~/.config/htop/htoprc cp /etc/htoprc.global ~/.config/htop/htoprc
htop htop
} }
ZSH_THEME=bw
DISABLE_AUTO_UPDATE=true
plugins=(
zsh-autosuggestions
)
source /etc/zsh/oh-my-zsh/oh-my-zsh.sh

View file

@ -1,19 +1,61 @@
from os.path import join from os.path import join
files = { directories = {
'/etc/zsh/zprofile': { '/etc/zsh/oh-my-zsh': {},
'mode': '0644', '/etc/zsh/oh-my-zsh/custom/plugins': {
'mode': '0755',
'needs': [
f"git_deploy:/etc/zsh/oh-my-zsh",
]
},
'/etc/zsh/oh-my-zsh/custom/plugins/zsh-autosuggestions': {
'mode': '0755',
'needs': [
f"git_deploy:/etc/zsh/oh-my-zsh",
]
},
}
git_deploy = {
'/etc/zsh/oh-my-zsh': {
'repo': 'https://github.com/ohmyzsh/ohmyzsh.git',
'rev': 'master',
},
'/etc/zsh/oh-my-zsh/custom/plugins/zsh-autosuggestions': {
'repo': 'https://github.com/zsh-users/zsh-autosuggestions.git',
'rev': 'master',
},
}
files = {
'/etc/zsh/zprofile': {
'mode': '0755',
},
'/etc/zsh/oh-my-zsh/themes/bw.zsh-theme': {
'mode': '0755',
'needs': [
f"git_deploy:/etc/zsh/oh-my-zsh",
]
},
}
actions = {
'chown_oh_my_zsh': {
'command': 'chmod -R 755 /etc/zsh/oh-my-zsh',
'triggered': True,
'triggered_by': [
"git_deploy:/etc/zsh/oh-my-zsh",
"git_deploy:/etc/zsh/oh-my-zsh/custom/plugins/zsh-autosuggestions",
"file:/etc/zsh/zprofile",
"file:/etc/zsh/oh-my-zsh/themes/bw.zsh-theme",
],
}, },
} }
# Empty stub in each zsh user's home. zsh-newuser-install fires on login
# when none of ~/.{zshrc,zshenv,zprofile,zlogin} exists, and the real
# config lives in /etc/zsh/zprofile — so we keep a one-line marker file
# here to suppress the new-user wizard.
for name, user_config in node.metadata.get('users').items(): for name, user_config in node.metadata.get('users').items():
if user_config.get('shell', None) == '/usr/bin/zsh': if user_config.get('shell', None) == '/usr/bin/zsh':
files[join(user_config['home'], '.zshrc')] = { files[join(user_config['home'], '.zshrc')] = {
'owner': name, 'owner': name,
'group': name, 'group': name,
'content': '# bw managed; real config in /etc/zsh/zprofile\n', 'content': '# bw managed',
} }

View file

@ -2,7 +2,6 @@ defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'zsh': {}, 'zsh': {},
'zsh-autosuggestions': {},
} }
} }
} }

View file

@ -1,63 +0,0 @@
# data/
## What's here
Out-of-bundle data assets consumed by one or more bundles. Each subdir
maps to a consumer:
```
data/
├── apt/keys/ # binary GPG keys for apt sources
├── grafana/rows/ # Mako-templated dashboard panels (Python)
├── nginx/ # nginx snippets shared across vhosts
├── homeassistant/, mailman/, nextcloud/, ...
└── network.py # repo-wide network metadata (one-off file)
```
## Two distinct content models
Same directory shape, different content kinds. When you add a new
`data/<x>/` subdir, declare which model it follows:
| Model | Example | Consumer |
|---|---|---|
| **Binary / static** | `data/apt/keys/*.{asc,gpg}` | `bundles/apt` reads files at apply time |
| **Python module / template** | `data/grafana/rows/*.py`, `data/routeros-monitoring/*.py` | bundle `import`s and renders |
If a data asset is read by **exactly one bundle**, prefer
`bundles/<x>/files/` instead of `data/<x>/`. `data/` is for
shared / multi-consumer artifacts. Single-instance evidence: commit
`78a8abc` moved `mikrotik.mib` *out* of `data/` *into* the bundle for
this reason.
## Conventions
- **One subdir per consumer.** Subdir name = consumer bundle name
(`data/apt/`, `data/nextcloud/`).
- **`network.py` exception.** A single file at `data/network.py` holds
repo-wide network metadata; it doesn't belong to one bundle. Treat
it as cross-cutting infrastructure metadata.
## How to add data
1. Decide the content model (binary or Python).
2. `mkdir data/<consumer>/`.
3. Drop assets in.
4. The consumer bundle (`bundles/<consumer>/items.py` or
`metadata.py`) reads them via `repo.path` + `os.path.join` or
similar.
## Pitfalls
- **Apt keys** trigger an offline-verify rule before they're committed.
See [`commands.md#apt-key-changes-need-offline-verification`](../docs/agents/commands.md#apt-key-changes-need-offline-verification).
- **Mako-templated Python data** evaluates at bundle render time. Side
effects in those modules slow the whole repo (same caveat as
[`libs/`](../libs/AGENTS.md)).
## See also
- [`bundles/AGENTS.md`](../bundles/AGENTS.md) — when bundle `files/`
beats `data/`.
- [`docs/agents/commands.md`](../docs/agents/commands.md) — apt-key
verification rule.

View file

@ -8,14 +8,14 @@ KHyP5XgRU/pIOyOo3g6+qIkhgynHVYIBuPbFQGEbOuUg7noAwTC9B9pYXSRFq9wk
T/q8rqOBiyO9SWB9gMiem8HNAzUo5TbVp9xPv2pl3mNXwe5te92pjlWdktOsBZuy T/q8rqOBiyO9SWB9gMiem8HNAzUo5TbVp9xPv2pl3mNXwe5te92pjlWdktOsBZuy
TfTgoj3y0HUY48He/z85aJ5j7gX5PU/6arxdABEBAAG0UGRldmVsOmxhbmd1YWdl TfTgoj3y0HUY48He/z85aJ5j7gX5PU/6arxdABEBAAG0UGRldmVsOmxhbmd1YWdl
czpjcnlzdGFsIE9CUyBQcm9qZWN0IDxkZXZlbDpsYW5ndWFnZXM6Y3J5c3RhbEBi czpjcnlzdGFsIE9CUyBQcm9qZWN0IDxkZXZlbDpsYW5ndWFnZXM6Y3J5c3RhbEBi
dWlsZC5vcGVuc3VzZS5vcmc+iQE+BBMBCAAoBQJodLPOAhsDBQkMCLQ6BgsJCAcD dWlsZC5vcGVuc3VzZS5vcmc+iQE+BBMBCAAoBQJkq9RAAhsDBQkIP9SsBgsJCAcD
AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDkVq5yhW0Udi/iB/9pzVWeChRvk7+bC2p3 AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDkVq5yhW0UdsH4CACAMuwfsUTlUVmdMBw5
QXjc+KRmkev7yC3QglBX/17qDG+nW/z1SptFpIUKMllH/xu0GXIWOW/rxshRdKRK wktrrdwfwN6TiG5tPDjzTcMQNL+RSCh1gNRvaJjNHAy9sAsruGwTyX76K1p942EG
422wnT7KA2AqxArsHfvu0/nGBXAI1DnHuwP0j6xNmmw+uob2nWiUZZNgKydxcGSF F99DrYd/PMBK4oOWe7HHouYIMrLqZFT38shv/tbyJvUfxqfMHSPQJSFPVGtInn3h
fgRfIJcsHBKweasy9G/Fpdur/BFSBNQ8BP6CnB9qx0Z1LgQ6bQQNY1LKH4EzmiNA iKtDeIc88Hl+dsmBhWxDdaoHTGKgIcQTLN1OaX6SsT6WuMo7B4kPxHerwFp/n5bO
rBowUcuVjUzXUW8rc0Old/ffymH3TBM9xQXnsGVZb5+E6NKpcdt0lnWkrtHQK3RX hqyLLkTY0oxJpZlzCj2tYDytHhjkPnYtcPpQ8LnQpGKogUxYDYZ+o4zYvIcT/J5+
ohNmaLwMQe/wMzWN3u/5XshQD8mMQjxEg4QSt2gAEXJdIzI+VgLrGqcfbrk/qhVM cLx1xpf4fI7ZoE+dpIpAGKzN8MoQQ+fjgSheXar35p+8lOKrvrk7MmbQJlBQO+rM
D+c+iEYEExECAAYFAmCKr5QACgkQOzARt2udZSNdFQCgtpRzGoKr9VWnhv+/k4pk IHdJiEYEExECAAYFAmCKr5QACgkQOzARt2udZSNdFQCgtpRzGoKr9VWnhv+/k4pk
Cmp9fycAn0pdJ2xIEsqxOjPBFVDh7Sahecuq Cmp9fycAn0pdJ2xIEsqxOjPBFVDh7Sahecuq
=v2my =yIwD
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,186 +1,186 @@
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
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
ugNdPbTx5TQVD48tsKiSIOPP1g== bn5U7DwupeFEycZrqQEKsjwFjLxa
=UqDn =QzR4
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----

Some files were not shown because too many files have changed in this diff Show more