systemd-search 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ """systemd-search — Query systemd units by custom section labels.
2
+
3
+ Relies on `systemctl cat` for effective unit config (handles .d/ drop-ins).
4
+ """
5
+
6
+ import argparse
7
+ import configparser
8
+ import json
9
+ import subprocess
10
+ import sys
11
+
12
+
13
+ def run(cmd):
14
+ return subprocess.run(cmd, capture_output=True, text=True)
15
+
16
+
17
+ def list_unit_files(types):
18
+ """Return [(unit_name, enabled_state)] for the given unit types."""
19
+ args = ["systemctl", "list-unit-files", "--no-legend", "--no-pager"]
20
+ if types:
21
+ args.append("--type=" + ",".join(types))
22
+ result = run(args)
23
+ units = []
24
+ for line in result.stdout.splitlines():
25
+ parts = line.split()
26
+ if len(parts) >= 2:
27
+ units.append((parts[0], parts[1]))
28
+ return units
29
+
30
+
31
+ def parse_cat_output(output):
32
+ """Parse systemctl cat output into {section: {key: value}}.
33
+
34
+ Uses configparser so the full INI grammar is handled correctly.
35
+ strict=False lets duplicate sections (base file + drop-ins) merge and
36
+ duplicate keys resolve to the last definition, matching systemd semantics.
37
+ delimiters=('=',) avoids treating ':' as a separator (systemd never does).
38
+ optionxform=str preserves key casing.
39
+ """
40
+ parser = configparser.RawConfigParser(
41
+ strict=False,
42
+ delimiters=("=",),
43
+ )
44
+ parser.optionxform = str # preserve key case (e.g. ExecStart, not execstart)
45
+ parser.read_string(output)
46
+ return {section: dict(parser.items(section)) for section in parser.sections()}
47
+
48
+
49
+ def get_unit_config(unit):
50
+ result = run(["systemctl", "cat", "--", unit])
51
+ if result.returncode != 0:
52
+ return {}
53
+ return parse_cat_output(result.stdout)
54
+
55
+
56
+ def is_active(unit):
57
+ result = run(["systemctl", "is-active", "--", unit])
58
+ return result.stdout.strip() == "active"
59
+
60
+
61
+ def build_parser():
62
+ p = argparse.ArgumentParser(
63
+ prog="systemd-search",
64
+ description="Query systemd units by custom section labels",
65
+ formatter_class=argparse.RawDescriptionHelpFormatter,
66
+ epilog="""
67
+ examples:
68
+ systemd-search --label Project
69
+ systemd-search --verbose --label Project
70
+ systemd-search --label Project=myapp
71
+ systemd-search --label Project=myapp --type service --type timer
72
+ systemd-search --label Project=myapp --type service --enabled
73
+ systemd-search --label Project=myapp --type service --enabled --active
74
+ systemd-search --label Project=myapp --type service --disabled --dead
75
+ systemd-search --label Project=myapp --exclude Domain
76
+ systemd-search --label Project=myapp --exclude Env=staging --type service --enabled
77
+ """,
78
+ )
79
+ p.add_argument(
80
+ "--section",
81
+ default="X-Labels",
82
+ metavar="SECTION",
83
+ help="Unit file section to inspect (default: X-Labels)",
84
+ )
85
+ p.add_argument(
86
+ "--label",
87
+ action="append",
88
+ default=[],
89
+ metavar="KEY[=VALUE]",
90
+ help="Filter by label key, or key=value. Repeatable (all must match).",
91
+ )
92
+ p.add_argument(
93
+ "--exclude",
94
+ action="append",
95
+ default=[],
96
+ metavar="KEY[=VALUE]",
97
+ help=(
98
+ "Exclude units where KEY exists, or where KEY=VALUE matches. "
99
+ "Repeatable. Units that lack the section entirely are always excluded."
100
+ ),
101
+ )
102
+ p.add_argument(
103
+ "--type",
104
+ action="append",
105
+ default=[],
106
+ dest="unit_types",
107
+ metavar="TYPE",
108
+ help="Unit type to include (default: service). Repeatable.",
109
+ )
110
+
111
+ state = p.add_argument_group("state filters")
112
+ enabled_grp = state.add_mutually_exclusive_group()
113
+ enabled_grp.add_argument(
114
+ "--enabled", action="store_true", default=False, help="Only enabled units"
115
+ )
116
+ enabled_grp.add_argument(
117
+ "--disabled", action="store_true", default=False, help="Only disabled units"
118
+ )
119
+
120
+ active_grp = state.add_mutually_exclusive_group()
121
+ active_grp.add_argument(
122
+ "--active", action="store_true", default=False, help="Only active (running) units"
123
+ )
124
+ active_grp.add_argument(
125
+ "--dead", action="store_true", default=False, help="Only inactive/dead units"
126
+ )
127
+
128
+ output_grp = p.add_mutually_exclusive_group()
129
+ output_grp.add_argument(
130
+ "--verbose",
131
+ "-v",
132
+ action="store_true",
133
+ default=False,
134
+ help="Print matched label key=value pairs alongside unit name",
135
+ )
136
+ output_grp.add_argument(
137
+ "--json",
138
+ action="store_true",
139
+ default=False,
140
+ help="Emit results as a JSON array with name, enabled, is-active, and labels",
141
+ )
142
+ return p
143
+
144
+
145
+ def parse_label_filters(raw_labels):
146
+ """Return [(key, value_or_None)] from raw --label arguments."""
147
+ filters = []
148
+ for item in raw_labels:
149
+ if "=" in item:
150
+ k, _, v = item.partition("=")
151
+ filters.append((k.strip(), v.strip()))
152
+ else:
153
+ filters.append((item.strip(), None))
154
+ return filters
155
+
156
+
157
+ def section_matches(section_data, label_filters):
158
+ """Return True if section_data satisfies all label_filters."""
159
+ for key, value in label_filters:
160
+ if key not in section_data:
161
+ return False
162
+ if value is not None and section_data[key] != value:
163
+ return False
164
+ return True
165
+
166
+
167
+ def section_excluded(section_data, exclude_filters):
168
+ """Return True if section_data triggers any exclude_filter.
169
+
170
+ --exclude KEY → exclude when KEY is present in the section.
171
+ --exclude KEY=VALUE → exclude when KEY is present and equals VALUE.
172
+ """
173
+ for key, value in exclude_filters:
174
+ if value is None:
175
+ if key in section_data:
176
+ return True
177
+ else:
178
+ if section_data.get(key) == value:
179
+ return True
180
+ return False
181
+
182
+
183
+ def format_json_entry(unit_name, enabled_state, active, section_data):
184
+ """Build one JSON result object."""
185
+ return {
186
+ "name": unit_name,
187
+ "enabled": enabled_state in ENABLED_STATES,
188
+ "is-active": active,
189
+ "labels": dict(section_data),
190
+ }
191
+
192
+
193
+ def format_verbose(unit_name, section_data, label_filters):
194
+ """Build the output line when --verbose is set."""
195
+ if label_filters:
196
+ keys_to_show = [k for k, _ in label_filters]
197
+ else:
198
+ keys_to_show = list(section_data.keys())
199
+
200
+ pairs = " ".join(
201
+ f"{k}={section_data[k]}" for k in keys_to_show if k in section_data
202
+ )
203
+ return f"{unit_name}\t{pairs}" if pairs else unit_name
204
+
205
+
206
+ # States that count as "enabled" per systemctl semantics
207
+ ENABLED_STATES = {"enabled", "enabled-runtime", "static", "alias", "generated", "transient"}
208
+ DISABLED_STATES = {"disabled", "masked", "masked-runtime", "indirect"}
209
+
210
+
211
+ def main():
212
+ parser = build_parser()
213
+ args = parser.parse_args()
214
+
215
+ unit_types = args.unit_types if args.unit_types else ["service"]
216
+ label_filters = parse_label_filters(args.label)
217
+ exclude_filters = parse_label_filters(args.exclude)
218
+
219
+ units = list_unit_files(unit_types)
220
+ json_results = []
221
+ found = False
222
+
223
+ for unit_name, enabled_state in units:
224
+ # --- enabled/disabled filter ---
225
+ if args.enabled and enabled_state not in ENABLED_STATES:
226
+ continue
227
+ if args.disabled and enabled_state not in DISABLED_STATES:
228
+ continue
229
+
230
+ # --- section gate (always enforced) ---
231
+ # Units without the section are outside the scope of this tool entirely.
232
+ config = get_unit_config(unit_name)
233
+ if args.section not in config:
234
+ continue
235
+ section_data = config[args.section]
236
+
237
+ # --- label / exclude filters ---
238
+ if label_filters and not section_matches(section_data, label_filters):
239
+ continue
240
+
241
+ if exclude_filters and section_excluded(section_data, exclude_filters):
242
+ continue
243
+
244
+ # --- active/dead filter + active state resolution ---
245
+ # JSON always needs the active state; other modes only fetch it when filtering.
246
+ if args.json or args.active or args.dead:
247
+ active = is_active(unit_name)
248
+ if args.active and not active:
249
+ continue
250
+ if args.dead and active:
251
+ continue
252
+ else:
253
+ active = None
254
+
255
+ found = True
256
+
257
+ # --- output ---
258
+ if args.json:
259
+ json_results.append(
260
+ format_json_entry(unit_name, enabled_state, active, section_data)
261
+ )
262
+ elif args.verbose:
263
+ print(format_verbose(unit_name, section_data, label_filters))
264
+ else:
265
+ print(unit_name)
266
+
267
+ if args.json:
268
+ print(json.dumps(json_results, indent=2))
269
+
270
+ if not found:
271
+ sys.exit(1)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()
@@ -0,0 +1,550 @@
1
+ Metadata-Version: 2.4
2
+ Name: systemd-search
3
+ Version: 1.1.0
4
+ Summary: Query systemd units by custom section labels
5
+ License: Apache-2.0
6
+ Project-URL: Repository, https://github.com/leventyalcin/systemd-search
7
+ Keywords: systemd,systemctl,units,labels,search
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Topic :: System :: Systems Administration
13
+ Classifier: Environment :: Console
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # systemd-search
20
+
21
+ A CLI tool for finding systemd units by custom labels embedded directly in the unit files.
22
+
23
+ ## The problem
24
+
25
+ Tracking which units belong to which project or domain on a busy system has no good native solution. The usual approach — browsing `/etc/systemd/system/`, grepping file contents, running `systemctl cat` on anything suspicious — is slow and error-prone. There is no built-in way to tag a unit and query by that tag.
26
+
27
+ `systemd-search` fills that gap. Labels are embedded in a dedicated section inside the unit file itself. The tool reads those labels and filters units by them, covering type, enabled state, and active state in a single command.
28
+
29
+ ## How it works — the `X-` section trick
30
+
31
+ The `systemd.unit(5)` man page explicitly states:
32
+
33
+ > *Sections whose name is prefixed with `X-` are ignored by systemd.*
34
+ > *Such sections can be used by applications to store additional information in the unit files.*
35
+
36
+ — [systemd.unit(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#%5BUnit%5D%20Section%20Options)
37
+
38
+ An `[X-Labels]` section (or any `[X-*]` section) can be added to any unit file and systemd will load and run the unit exactly as if that section were not there. `systemd-search` reads those sections and uses them as a lightweight tagging system on top of systemd.
39
+
40
+ The tool resolves the final merged configuration through `systemctl cat` before reading any labels, so drop-in override files (`.d/*.conf`) are always taken into account — the search never operates on stale or partial file content.
41
+
42
+ ## Example unit file
43
+
44
+ ```ini
45
+ # /etc/systemd/system/myapp-worker.service
46
+ [Unit]
47
+ Description=My Application Worker
48
+ After=network.target
49
+
50
+ [Service]
51
+ User=myapp
52
+ ExecStart=/opt/myapp/bin/worker
53
+ Restart=on-failure
54
+
55
+ [X-Labels]
56
+ Project=myapp
57
+ Domain=backend
58
+ Component=worker
59
+ Environment=production
60
+ ManagedBy=ansible
61
+
62
+ [Install]
63
+ WantedBy=multi-user.target
64
+ ```
65
+
66
+ Any section name starting with `X-` is valid. The default section `systemd-search` reads is `X-Labels`. A different section can be specified with `--section`.
67
+
68
+ ## Installation
69
+
70
+ ### pip
71
+
72
+ The simplest installation on any system with Python 3.9+:
73
+
74
+ ```bash
75
+ pip install systemd-search
76
+ ```
77
+
78
+ For a user-local install without root:
79
+
80
+ ```bash
81
+ pip install --user systemd-search
82
+ ```
83
+
84
+ ### From GitHub Releases
85
+
86
+ Each release ships a self-contained zipapp executable, native packages, and checksums:
87
+
88
+ ```bash
89
+ # Self-contained zipapp — runs on any host with Python 3.9+, no pip needed
90
+ curl -LO https://github.com/leventyalcin/systemd-search/releases/latest/download/systemd-search-1.0.0
91
+ chmod +x systemd-search-1.0.0
92
+ sudo mv systemd-search-1.0.0 /usr/local/bin/systemd-search
93
+
94
+ # RPM (Rocky Linux 9)
95
+ sudo rpm -i systemd-search-1.0.0-rocky9.noarch.rpm
96
+
97
+ # DEB (Debian 12)
98
+ sudo dpkg -i systemd-search-1.0.0-debian12.all.deb
99
+
100
+ # Verify checksum before installing
101
+ sha256sum -c systemd-search-1.0.0-rocky9.noarch.rpm.sha256
102
+ ```
103
+
104
+ ### Manual
105
+
106
+ Copy the script to any directory on the system `PATH`:
107
+
108
+ ```bash
109
+ sudo cp systemd-search /usr/local/bin/systemd-search
110
+ ```
111
+
112
+ **Requirements:** Python 3.9+ with no third-party packages. On Rocky Linux 9 this is the system default Python and requires no additional installation.
113
+
114
+ ## Usage
115
+
116
+ ```text
117
+ systemd-search [--section SECTION] [--label KEY[=VALUE]] [--type TYPE]
118
+ [--enabled | --disabled] [--active | --dead] [--verbose]
119
+ ```
120
+
121
+ | Flag | Default | Description |
122
+ |-----------------------|------------|-----------------------------------------------------------------------------|
123
+ | `--label KEY` | — | Matches units that have this key in the section |
124
+ | `--label KEY=VALUE` | — | Matches units where the key equals the value |
125
+ | `--exclude KEY` | — | Skips units that have this key in the section. Repeatable. |
126
+ | `--exclude KEY=VALUE` | — | Skips units where the key equals the value. Repeatable. |
127
+ | `--section NAME` | `X-Labels` | Section to read labels from |
128
+ | `--type TYPE` | `service` | Unit type to include (`service`, `timer`, `path`, `socket`, …). Repeatable. |
129
+ | `--enabled` | — | Limits results to enabled units |
130
+ | `--disabled` | — | Limits results to disabled units |
131
+ | `--active` | — | Limits results to active (running) units |
132
+ | `--dead` | — | Limits results to inactive or failed units |
133
+ | `--verbose` / `-v` | — | Prints matched label key=value pairs alongside each unit name |
134
+
135
+ `--enabled` and `--disabled` are mutually exclusive. So are `--active` and `--dead`. Omitting either pair includes all units regardless of that state.
136
+
137
+ When `--exclude` is active, units that lack the section entirely are silently dropped — the filter only operates on units that carry the section in their configuration.
138
+
139
+ ## Examples
140
+
141
+ ### Find all services that belong to a project
142
+
143
+ ```bash
144
+ systemd-search --label Project=myapp
145
+ ```
146
+
147
+ ```text
148
+ myapp-worker.service
149
+ myapp-scheduler.service
150
+ myapp-cleanup.service
151
+ ```
152
+
153
+ ### Print the label values alongside each unit name
154
+
155
+ ```bash
156
+ systemd-search --verbose --label Project=myapp
157
+ ```
158
+
159
+ ```text
160
+ myapp-worker.service Project=myapp
161
+ myapp-scheduler.service Project=myapp
162
+ myapp-cleanup.service Project=myapp
163
+ ```
164
+
165
+ ### Search across multiple unit types
166
+
167
+ ```bash
168
+ systemd-search --label Project=myapp --type service --type timer --type path
169
+ ```
170
+
171
+ ```text
172
+ myapp-worker.service
173
+ myapp-cleanup.service
174
+ myapp-refresh.timer
175
+ myapp-trigger.path
176
+ ```
177
+
178
+ ### Narrow by a specific label and type
179
+
180
+ ```bash
181
+ systemd-search --label Component=worker --type service
182
+ ```
183
+
184
+ ### Find only the running services for a project
185
+
186
+ ```bash
187
+ systemd-search --label Project=myapp --type service --enabled --active
188
+ ```
189
+
190
+ ### Find services that are enabled but not running
191
+
192
+ Useful for spotting crashed or failed units:
193
+
194
+ ```bash
195
+ systemd-search --label Project=myapp --type service --enabled --dead
196
+ ```
197
+
198
+ ### Find services that are installed but not enabled
199
+
200
+ ```bash
201
+ systemd-search --label Project=myapp --disabled
202
+ ```
203
+
204
+ ### Use a custom section name
205
+
206
+ Labels do not have to live in `[X-Labels]`. Any `[X-*]` section works:
207
+
208
+ ```ini
209
+ [X-Meta]
210
+ Project=myapp
211
+ Team=platform
212
+ ```
213
+
214
+ ```bash
215
+ systemd-search --section X-Meta --label Team=platform
216
+ ```
217
+
218
+ ### Match on multiple labels simultaneously
219
+
220
+ All supplied `--label` filters must match for a unit to appear in the results:
221
+
222
+ ```bash
223
+ systemd-search --label Project=myapp --label Environment=production --type service
224
+ ```
225
+
226
+ ### Exclude units that have a specific key
227
+
228
+ `--exclude KEY` skips any unit in the section that carries that key, regardless of its value:
229
+
230
+ ```bash
231
+ systemd-search --label Project=myapp --exclude Domain
232
+ ```
233
+
234
+ Only units labelled with `Project=myapp` that have no `Domain` key are returned.
235
+
236
+ ### Exclude units where a key matches a specific value
237
+
238
+ `--exclude KEY=VALUE` skips units only when the key exists and holds that exact value. Units where the key is absent or holds a different value still appear:
239
+
240
+ ```bash
241
+ systemd-search --label Project=myapp --exclude Env=staging
242
+ ```
243
+
244
+ Returns all `myapp` services except those explicitly labelled `Env=staging`.
245
+
246
+ ### Combine positive and negative filters
247
+
248
+ `--label` and `--exclude` compose freely. All `--label` conditions must hold and no `--exclude` condition must trigger for a unit to appear:
249
+
250
+ ```bash
251
+ systemd-search \
252
+ --label Project=myapp \
253
+ --label Domain=backend \
254
+ --exclude Component=worker \
255
+ --exclude Env=staging \
256
+ --type service \
257
+ --enabled
258
+ ```
259
+
260
+ Reads as: *services for the myapp backend, enabled, excluding workers and staging instances.*
261
+
262
+ ### Verbose output with multiple label filters
263
+
264
+ ```bash
265
+ systemd-search --verbose --label Project=myapp --label Domain=backend
266
+ ```
267
+
268
+ ```text
269
+ myapp-worker.service Project=myapp Domain=backend
270
+ ```
271
+
272
+ ## Monitoring integration
273
+
274
+ `systemd-search --json` produces machine-readable output that can be piped directly into monitoring agents. Any combination of filters can precede it — label filters, state filters, unit types — and the result carries enough context for downstream tools to slice and count however the use case demands.
275
+
276
+ A few possibilities:
277
+
278
+ ```bash
279
+ # All dead services for a project, as JSON
280
+ systemd-search --json --label Project=myapp --dead
281
+
282
+ # Count of enabled-but-dead units across all labelled services
283
+ systemd-search --json | jq '[.[] | select(.enabled and (.["is-active"] | not))] | length'
284
+
285
+ # Feed into a monitoring agent as a metric
286
+ systemd-search --json --label Project=myapp | jq '.[] | select(.["is-active"] | not) | .name'
287
+ ```
288
+
289
+ The examples below show one way to wire this into three common monitoring agents. They are starting points, not prescriptions.
290
+
291
+ ---
292
+
293
+ ### Telegraf
294
+
295
+ The `exec` input plugin runs an arbitrary command on a schedule and parses its output as metrics. A small wrapper script calls `systemd-search --json` once per project and uses `jq` to derive all counters from the single result, avoiding repeated invocations. The output is [InfluxDB line protocol](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/).
296
+
297
+ **`/usr/local/bin/systemd-search-metrics.sh`**
298
+
299
+ ```bash
300
+ #!/bin/bash
301
+ # Emits one influx line per project with unit state counts.
302
+ # Add or remove projects to match the labels used on this host.
303
+
304
+ set -euo pipefail
305
+
306
+ PROJECTS=(myapp payments auth)
307
+
308
+ for project in "${PROJECTS[@]}"; do
309
+ units=$(systemd-search --json \
310
+ --label Project="$project" \
311
+ --type service --type timer --type path)
312
+
313
+ dead=$( echo "$units" | jq '[.[] | select(.enabled and (.["is-active"] | not))] | length')
314
+ active=$( echo "$units" | jq '[.[] | select(.enabled and .["is-active"] )] | length')
315
+ disabled=$(echo "$units" | jq '[.[] | select(.enabled | not) ] | length')
316
+
317
+ echo "systemd_units,project=${project} dead=${dead}i,active=${active}i,disabled=${disabled}i"
318
+ done
319
+ ```
320
+
321
+ **`/etc/telegraf/telegraf.d/systemd-search.conf`**
322
+
323
+ ```toml
324
+ [[inputs.exec]]
325
+ ## Script must be executable: chmod +x /usr/local/bin/systemd-search-metrics.sh
326
+ commands = ["/usr/local/bin/systemd-search-metrics.sh"]
327
+ timeout = "15s"
328
+ interval = "60s"
329
+ data_format = "influx"
330
+ ```
331
+
332
+ The resulting measurement `systemd_units` carries a `project` tag and `dead`/`active`/`disabled` fields. An alert fires when `dead > 0` for any project.
333
+
334
+ ---
335
+
336
+ ### Datadog
337
+
338
+ The Datadog Agent supports [custom Python checks](https://docs.datadoghq.com/developers/custom_checks/write_agent_check/) that emit arbitrary metrics. The check below calls `systemd-search --json` once per configured project and derives all counters from the single JSON result.
339
+
340
+ **`/etc/datadog-agent/checks.d/systemd_labels.py`**
341
+
342
+ ```python
343
+ import json
344
+ import subprocess
345
+ from datadog_checks.base import AgentCheck
346
+
347
+
348
+ class SystemdLabelsCheck(AgentCheck):
349
+ __NAMESPACE__ = "systemd"
350
+
351
+ def check(self, instance):
352
+ project = instance["project"]
353
+ section = instance.get("section", "X-Labels")
354
+ label_key = instance.get("label_key", "Project")
355
+ types = instance.get("types", ["service"])
356
+
357
+ type_args = []
358
+ for t in types:
359
+ type_args += ["--type", t]
360
+
361
+ cmd = [
362
+ "systemd-search", "--json",
363
+ "--section", section,
364
+ "--label", f"{label_key}={project}",
365
+ ] + type_args
366
+
367
+ result = subprocess.run(cmd, capture_output=True, text=True)
368
+ units = json.loads(result.stdout) if result.returncode == 0 else []
369
+
370
+ dead = sum(1 for u in units if u["enabled"] and not u["is-active"])
371
+ active = sum(1 for u in units if u["enabled"] and u["is-active"])
372
+ disabled = sum(1 for u in units if not u["enabled"])
373
+
374
+ tags = [f"project:{project}"]
375
+ self.gauge("units.dead", dead, tags=tags)
376
+ self.gauge("units.active", active, tags=tags)
377
+ self.gauge("units.disabled", disabled, tags=tags)
378
+ ```
379
+
380
+ **`/etc/datadog-agent/conf.d/systemd_labels.d/conf.yaml`**
381
+
382
+ ```yaml
383
+ instances:
384
+ - project: myapp
385
+ types: [service, timer, path]
386
+
387
+ - project: payments
388
+ types: [service]
389
+
390
+ - project: auth
391
+ section: X-Meta # override if a different section name is used
392
+ label_key: Application
393
+ types: [service, timer]
394
+ ```
395
+
396
+ The check emits `systemd.units.dead`, `systemd.units.active`, and `systemd.units.disabled` with a `project` tag. A monitor on `systemd.units.dead > 0` grouped by `project` covers all labelled projects in a single alert rule.
397
+
398
+ ---
399
+
400
+ ### Dynatrace
401
+
402
+ Dynatrace ingests custom metrics through its [Metrics Ingest v2 API](https://docs.dynatrace.com/docs/dynatrace-api/environment-api/metric-v2/metric-ingest). A script pushed by a systemd timer calls `systemd-search --json` once per project and uses `jq` to compute all counters before pushing a single batch payload.
403
+
404
+ **`/usr/local/bin/systemd-search-dynatrace.sh`**
405
+
406
+ ```bash
407
+ #!/bin/bash
408
+ # Push unit state metrics for labelled projects to Dynatrace Metrics Ingest v2.
409
+
410
+ set -euo pipefail
411
+
412
+ DT_URL="${DYNATRACE_URL}" # e.g. https://abc12345.live.dynatrace.com
413
+ DT_TOKEN="${DYNATRACE_API_TOKEN}" # Ingest Metrics (metrics.ingest) scope required
414
+ PROJECTS=(myapp payments auth)
415
+
416
+ payload=""
417
+
418
+ for project in "${PROJECTS[@]}"; do
419
+ units=$(systemd-search --json \
420
+ --label Project="$project" \
421
+ --type service --type timer --type path)
422
+
423
+ dead=$( echo "$units" | jq '[.[] | select(.enabled and (.["is-active"] | not))] | length')
424
+ active=$( echo "$units" | jq '[.[] | select(.enabled and .["is-active"] )] | length')
425
+ disabled=$(echo "$units" | jq '[.[] | select(.enabled | not) ] | length')
426
+
427
+ # Dynatrace line protocol: metric.key,dimensions gauge,value
428
+ payload+="systemd.units.dead,project=${project} gauge,${dead}"$'\n'
429
+ payload+="systemd.units.active,project=${project} gauge,${active}"$'\n'
430
+ payload+="systemd.units.disabled,project=${project} gauge,${disabled}"$'\n'
431
+ done
432
+
433
+ curl -sf -X POST "${DT_URL}/api/v2/metrics/ingest" \
434
+ -H "Authorization: Api-Token ${DT_TOKEN}" \
435
+ -H "Content-Type: text/plain; charset=utf-8" \
436
+ --data-raw "${payload}"
437
+ ```
438
+
439
+ **`/etc/systemd/system/systemd-search-dynatrace.service`**
440
+
441
+ ```ini
442
+ [Unit]
443
+ Description=Push systemd label metrics to Dynatrace
444
+ After=network-online.target
445
+ Wants=network-online.target
446
+
447
+ [Service]
448
+ Type=oneshot
449
+ EnvironmentFile=/etc/systemd-search/dynatrace.env
450
+ ExecStart=/usr/local/bin/systemd-search-dynatrace.sh
451
+ ```
452
+
453
+ **`/etc/systemd/system/systemd-search-dynatrace.timer`**
454
+
455
+ ```ini
456
+ [Unit]
457
+ Description=Run Dynatrace metric push every 60 seconds
458
+
459
+ [Timer]
460
+ OnBootSec=30s
461
+ OnUnitActiveSec=60s
462
+ AccuracySec=5s
463
+
464
+ [Install]
465
+ WantedBy=timers.target
466
+ ```
467
+
468
+ **`/etc/systemd-search/dynatrace.env`**
469
+
470
+ ```bash
471
+ DYNATRACE_URL=https://abc12345.live.dynatrace.com
472
+ DYNATRACE_API_TOKEN=dt0c01.XXXXXXXXXXXX...
473
+ ```
474
+
475
+ Enable the timer:
476
+
477
+ ```bash
478
+ systemctl enable --now systemd-search-dynatrace.timer
479
+ ```
480
+
481
+ The metric `systemd.units.dead` is then available in Dynatrace with a `project` dimension. An anomaly detection rule or a fixed threshold alert on that metric covers all labelled projects without any per-service configuration in Dynatrace itself.
482
+
483
+ ---
484
+
485
+ ## Development
486
+
487
+ All development dependencies are managed with [Pipenv](https://pipenv.pypa.io). The `Pipfile` pins Python 3.9 to match the system Python on Rocky Linux 9 — the primary deployment target.
488
+
489
+ ### First-time setup
490
+
491
+ Install Pipenv if not already present:
492
+
493
+ ```bash
494
+ pip install --user pipenv
495
+ ```
496
+
497
+ Then create the virtual environment and install all dev dependencies:
498
+
499
+ ```bash
500
+ pipenv install --dev
501
+ ```
502
+
503
+ This creates a Python 3.9 virtual environment under `.venv/` (or the Pipenv default location) and installs pytest, Molecule, Ansible, and the Docker driver.
504
+
505
+ ### Entering the environment
506
+
507
+ ```bash
508
+ pipenv shell
509
+ ```
510
+
511
+ All subsequent commands in this section assume the environment is active. Alternatively, prefix any single command with `pipenv run`:
512
+
513
+ ```bash
514
+ pipenv run pytest tests/ -v
515
+ ```
516
+
517
+ ### Unit tests
518
+
519
+ ```bash
520
+ pytest tests/ -v
521
+ ```
522
+
523
+ The unit tests target **Python 3.9** — the system Python on Rocky Linux 9. That version ships as the default on Rocky Linux 9 and will not change for the lifetime of the distribution. Running tests against 3.9 ensures the tool works on that platform without any additional Python installation and catches accidental use of language or stdlib features introduced in later versions.
524
+
525
+ ### Integration tests
526
+
527
+ Molecule tests install the tool inside real systemd containers and exercise every search combination against live units. Docker must be running.
528
+
529
+ ```bash
530
+ molecule test -s rocky # tests Rocky Linux 9 and 10 in parallel
531
+ molecule test -s debian # tests Debian 12 and 13 in parallel
532
+ ```
533
+
534
+ The scenarios use the `geerlingguy/docker-*-ansible` images, which are systemd-capable images built for this kind of testing.
535
+
536
+ ### Updating dependencies
537
+
538
+ ```bash
539
+ # Add or upgrade a dev dependency
540
+ pipenv install --dev some-package
541
+ ```
542
+
543
+ ### CI/CD
544
+
545
+ Pull requests must pass unit tests and both molecule scenarios before merging. Pushing a semver tag triggers the packaging and release jobs, which build RPM and DEB packages, publish the wheel to PyPI, and create a GitHub Release. The tag is the version — there is no separate version file.
546
+
547
+ ```bash
548
+ git tag 1.2.0
549
+ git push origin 1.2.0
550
+ ```
@@ -0,0 +1,9 @@
1
+ systemd_search/__init__.py,sha256=zYts7usaEEr5URAly7B6xO1CZlajOLrBW61oGSk0Byo,8669
2
+ systemd_search-1.1.0.dist-info/licenses/LICENSE,sha256=SItcyiWGgtkaNWqFjRplx4-DHIJpG0CkC3hhST0U4gQ,10284
3
+ systemd_search-1.1.0.dist-info/METADATA,sha256=XtdGySID9RUvx--kzASJCFzVvJe0ujtcOL3nhxGMh5A,18285
4
+ systemd_search-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ systemd_search-1.1.0.dist-info/entry_points.txt,sha256=6Nwyh2llLgkxANHGmzZD4_Bh9MEmsCaBhBAFFlInnjY,55
6
+ systemd_search-1.1.0.dist-info/scm_file_list.json,sha256=nEgmRnxad1o4o8PA_h36ATGAjYWA7l4pEiyRhHPHlWg,973
7
+ systemd_search-1.1.0.dist-info/scm_version.json,sha256=pm8QrB-GPU67W-3le1ff9XoV62k4gcY8B6jwgUxQHBg,160
8
+ systemd_search-1.1.0.dist-info/top_level.txt,sha256=xi-TLvNd02JOhaN4XChkpRwXHsqD3pV6QexfU-dDcS0,15
9
+ systemd_search-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ systemd-search = systemd_search:main
@@ -0,0 +1,184 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" shall mean an individual or Legal Entity exercising
24
+ permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner. For the purposes
50
+ of this definition, "submitted" means any form of electronic, verbal,
51
+ or written communication sent to the Licensor or its representatives,
52
+ including but not limited to communication on electronic mailing lists,
53
+ source code control systems, and issue tracking systems that are managed
54
+ by, or on behalf of, the Licensor for the purpose of discussing and
55
+ improving the Work, but excluding communication that is conspicuously
56
+ marked or designated in writing by the copyright owner as
57
+ "Not a Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
+ whom a Contribution has been received by the Licensor and incorporated
61
+ within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ copyright license to reproduce, prepare Derivative Works of,
67
+ publicly display, publicly perform, sublicense, and distribute the
68
+ Work and such Derivative Works in Source or Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of
71
+ this License, each Contributor hereby grants to You a perpetual,
72
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
73
+ (except as stated in this section) patent license to make, have made,
74
+ use, offer to sell, sell, import, and otherwise transfer the Work,
75
+ where such license applies only to those patent claims licensable
76
+ by such Contributor that are necessarily infringed by their
77
+ Contribution(s) alone or by the combination of their Contribution(s)
78
+ with the Work to which such Contribution(s) was submitted. If You
79
+ institute patent litigation against any entity (including a cross-claim
80
+ or counterclaim in a lawsuit) alleging that the Work or any Contributor
81
+ Contribution constitutes patent infringement, then any patent licenses
82
+ granted to You under this License for that Work shall terminate as of
83
+ the date such litigation is filed.
84
+
85
+ 4. Redistribution. You may reproduce and distribute copies of the
86
+ Work or Derivative Works thereof in any medium, with or without
87
+ modifications, and in Source or Object form, provided that You
88
+ meet the following conditions:
89
+
90
+ (a) You must give any other recipients of the Work or Derivative
91
+ Works a copy of this License; and
92
+
93
+ (b) You must cause any modified files to carry prominent notices
94
+ stating that You changed the files; and
95
+
96
+ (c) You must retain, in the Source form of any Derivative Works
97
+ that You distribute, all copyright, patent, trademark, and
98
+ attribution notices from the Source form of the Work,
99
+ excluding those notices that do not pertain to any part of
100
+ the Derivative Works; and
101
+
102
+ (d) If the Work includes a "NOTICE" text file as part of its
103
+ distribution, You must include a readable copy of the
104
+ attribution notices contained within such NOTICE file, in
105
+ at least one of the following places: within a NOTICE text
106
+ file distributed as part of the Derivative Works; within
107
+ the Source form or documentation, if provided along with the
108
+ Derivative Works; or, within a display generated by the
109
+ Derivative Works, if and wherever such third-party notices
110
+ normally appear. The contents of the NOTICE file are for
111
+ informational purposes only and do not modify the License.
112
+ You may add Your own attribution notices within Derivative
113
+ Works that You distribute, alongside or as an addendum to
114
+ the NOTICE text from the Work, provided that such additional
115
+ attribution notices cannot be construed as modifying the License.
116
+
117
+ You may add Your own license statement for Your modifications and
118
+ may provide additional grant of rights to use, copy, modify, merge,
119
+ publish, distribute, sublicense, and/or sell copies of the Work,
120
+ and to permit persons to whom the Work is furnished to do so, subject
121
+ to the following conditions, provided that such modifications are
122
+ clearly labeled as such.
123
+
124
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
125
+ any Contribution intentionally submitted for inclusion in the Work
126
+ by You to the Licensor shall be under the terms and conditions of
127
+ this License, without any additional terms or conditions.
128
+ Notwithstanding the above, nothing herein shall supersede or modify
129
+ the terms of any separate license agreement you may have executed
130
+ with Licensor regarding such Contributions.
131
+
132
+ 6. Trademarks. This License does not grant permission to use the trade
133
+ names, trademarks, service marks, or product names of the Licensor,
134
+ except as required for reasonable and customary use in describing the
135
+ origin of the Work and reproducing the content of the NOTICE file.
136
+
137
+ 7. Disclaimer of Warranty. Unless required by applicable law or
138
+ agreed to in writing, Licensor provides the Work (and each
139
+ Contributor provides its Contributions) on an "AS IS" BASIS,
140
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
141
+ implied, including, without limitation, any conditions of TITLE,
142
+ MERCHANTABLITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
143
+ solely responsible for determining the appropriateness of using or
144
+ redistributing the Work and assume any risks associated with Your
145
+ exercise of permissions under this License.
146
+
147
+ 8. Limitation of Liability. In no event and under no legal theory,
148
+ whether in tort (including negligence), contract, or otherwise,
149
+ unless required by applicable law (such as deliberate and grossly
150
+ negligent acts) or agreed to in writing, shall any Contributor be
151
+ liable to You for damages, including any direct, indirect, special,
152
+ incidental, or exemplary damages of any character arising as a
153
+ result of this License or out of the use or inability to use the
154
+ Work (including but not limited to damages for loss of goodwill,
155
+ work stoppage, computer failure or malfunction, or all other
156
+ commercial damages or losses), even if such Contributor has been
157
+ advised of the possibility of such damages.
158
+
159
+ 9. Accepting Warranty or Additional Liability. While redistributing
160
+ the Work or Derivative Works thereof, You may choose to offer,
161
+ and charge a fee for, acceptance of support, warranty, indemnity,
162
+ or other liability obligations and/or rights consistent with this
163
+ License. However, in accepting such obligations, You may act only
164
+ on Your own behalf and on Your sole responsibility, not on behalf
165
+ of any other Contributor, and only if You agree to indemnify,
166
+ defend, and hold each Contributor harmless for any liability
167
+ incurred by, or claims asserted against, such Contributor by reason
168
+ of your accepting any such warranty or additional liability.
169
+
170
+ END OF TERMS AND CONDITIONS
171
+
172
+ Copyright 2026 systemd-search contributors
173
+
174
+ Licensed under the Apache License, Version 2.0 (the "License");
175
+ you may not use this file except in compliance with the License.
176
+ You may obtain a copy of the License at
177
+
178
+ http://www.apache.org/licenses/LICENSE-2.0
179
+
180
+ Unless required by applicable law or agreed to in writing, software
181
+ distributed under the License is distributed on an "AS IS" BASIS,
182
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
183
+ See the License for the specific language governing permissions and
184
+ limitations under the License.
@@ -0,0 +1,33 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "LICENSE",
5
+ "systemd-search",
6
+ "ARCHITECTURE.md",
7
+ "pyproject.toml",
8
+ "Pipfile.lock",
9
+ "Pipfile",
10
+ ".gitignore",
11
+ "scripts/build-standalone.sh",
12
+ "scripts/build-rpm.sh",
13
+ "scripts/build-deb.sh",
14
+ "scripts/release-notes.md",
15
+ "src/systemd_search/__init__.py",
16
+ "molecule/rocky/molecule.yml",
17
+ "molecule/debian/molecule.yml",
18
+ "molecule/common/verify.yml",
19
+ "molecule/common/prepare.yml",
20
+ "molecule/common/converge.yml",
21
+ "molecule/common/files/fixture-single.service",
22
+ "molecule/common/files/fixture-multi.timer",
23
+ "molecule/common/files/fixture-multi-a.service",
24
+ "molecule/common/files/fixture-multi.path",
25
+ "molecule/common/files/fixture-disabled.service",
26
+ "molecule/common/files/fixture-failing.service",
27
+ "tests/test_unit.py",
28
+ "tests/conftest.py",
29
+ ".github/workflows/release.yml",
30
+ ".github/workflows/tests.yml",
31
+ ".github/workflows/pr.yml"
32
+ ]
33
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.1.0",
3
+ "distance": 0,
4
+ "node": "g1a40283a850c0dfe2ac3f553d1cf3ca6289d2ca8",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-29"
8
+ }
@@ -0,0 +1 @@
1
+ systemd_search