ducted 2.0__tar.gz → 2.2__tar.gz
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.
- {ducted-2.0 → ducted-2.2}/PKG-INFO +15 -11
- ducted-2.2/README.md +180 -0
- {ducted-2.0 → ducted-2.2}/docs/conf.py +2 -2
- ducted-2.2/docs/update_modules.py +37 -0
- {ducted-2.0 → ducted-2.2}/duct/__init__.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/__main__.py +6 -2
- {ducted-2.0 → ducted-2.2}/duct/aggregators.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/configuration.py +44 -48
- {ducted-2.0 → ducted-2.2}/duct/interfaces.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/logs/follower.py +6 -1
- {ducted-2.0 → ducted-2.2}/duct/logs/parsers.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/objects.py +3 -3
- {ducted-2.0 → ducted-2.2}/duct/outputs/bosun.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/outputs/elasticsearch.py +1 -3
- ducted-2.2/duct/outputs/graphite.py +69 -0
- ducted-2.2/duct/outputs/influxdb3.py +128 -0
- {ducted-2.0 → ducted-2.2}/duct/outputs/logger.py +4 -5
- ducted-2.2/duct/outputs/nats.py +187 -0
- {ducted-2.0 → ducted-2.2}/duct/outputs/null.py +2 -2
- {ducted-2.0 → ducted-2.2}/duct/outputs/opentsdb.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/outputs/prometheus.py +3 -5
- {ducted-2.0 → ducted-2.2}/duct/outputs/riemann.py +2 -6
- {ducted-2.0 → ducted-2.2}/duct/protocol/elasticsearch.py +2 -1
- ducted-2.2/duct/protocol/i2c.py +110 -0
- {ducted-2.0 → ducted-2.2}/duct/protocol/icmp.py +1 -1
- ducted-2.2/duct/protocol/influxdb3.py +80 -0
- {ducted-2.0 → ducted-2.2}/duct/protocol/opentsdb.py +3 -2
- {ducted-2.0 → ducted-2.2}/duct/protocol/riemann.py +4 -3
- ducted-2.2/duct/protocol/senml.py +134 -0
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/__init__.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/counters.py +11 -9
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/flows.py +44 -43
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/protocol.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/utils.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/protocol/xdr.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/server.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/protocol/ssh.py +5 -5
- {ducted-2.0 → ducted-2.2}/duct/service.py +66 -44
- {ducted-2.0 → ducted-2.2}/duct/sources/__init__.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/sources/apache.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/database/elasticsearch.py +2 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/database/memcache.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/database/postgresql.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/docker.py +0 -2
- {ducted-2.0 → ducted-2.2}/duct/sources/generator.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/haproxy.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/linux/basic.py +1 -7
- {ducted-2.0 → ducted-2.2}/duct/sources/linux/ipsec.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/sources/linux/process.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/sources/linux/sensors.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/sources/mail.py +1 -1
- {ducted-2.0 → ducted-2.2}/duct/sources/media/libav.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/munin.py +1 -3
- ducted-2.2/duct/sources/nats.py +160 -0
- {ducted-2.0 → ducted-2.2}/duct/sources/network.py +1 -5
- {ducted-2.0 → ducted-2.2}/duct/sources/nginx.py +1 -7
- {ducted-2.0 → ducted-2.2}/duct/sources/postfix.py +1 -3
- ducted-2.2/duct/sources/power/nut.py +146 -0
- ducted-2.2/duct/sources/power/raritan_px.py +116 -0
- {ducted-2.0 → ducted-2.2}/duct/sources/python/uwsgi.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/rabbitmq.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/redis.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/riak.py +0 -2
- {ducted-2.0 → ducted-2.2}/duct/sources/riemann.py +2 -3
- ducted-2.2/duct/sources/sensors/environment/__init__.py +10 -0
- ducted-2.2/duct/sources/sensors/environment/ds18b20.py +86 -0
- ducted-2.0/duct/sources/sensors/environment.py → ducted-2.2/duct/sources/sensors/environment/mpl115.py +28 -34
- {ducted-2.0 → ducted-2.2}/duct/sources/sflow.py +1 -3
- {ducted-2.0 → ducted-2.2}/duct/sources/snmp.py +21 -9
- {ducted-2.0 → ducted-2.2}/duct/sources/unbound.py +1 -3
- ducted-2.2/duct/tests/__init__.py +0 -0
- ducted-2.2/duct/tests/helpers.py +85 -0
- {ducted-2.0 → ducted-2.2}/duct/tests/test_duct.py +7 -7
- ducted-2.2/duct/tests/test_logs.py +86 -0
- ducted-2.2/duct/tests/test_outputs.py +415 -0
- ducted-2.2/duct/tests/test_protocol.py +233 -0
- {ducted-2.0 → ducted-2.2}/duct/tests/test_service.py +46 -8
- {ducted-2.0 → ducted-2.2}/duct/tests/test_sources.py +245 -2
- {ducted-2.0 → ducted-2.2}/duct/tests/test_ssh.py +3 -3
- {ducted-2.0 → ducted-2.2}/duct/tests/test_utils.py +4 -3
- {ducted-2.0 → ducted-2.2}/duct/utils.py +2 -2
- {ducted-2.0 → ducted-2.2}/ducted.egg-info/PKG-INFO +15 -11
- {ducted-2.0 → ducted-2.2}/ducted.egg-info/SOURCES.txt +16 -2
- ducted-2.2/ducted.egg-info/requires.txt +21 -0
- {ducted-2.0 → ducted-2.2}/ducted.egg-info/top_level.txt +1 -0
- {ducted-2.0 → ducted-2.2}/pyproject.toml +15 -11
- ducted-2.0/README.md +0 -54
- ducted-2.0/duct/protocol/imap4.py +0 -11
- ducted-2.0/duct/tests/test_logs.py +0 -86
- ducted-2.0/duct/tests/test_outputs.py +0 -59
- ducted-2.0/ducted.egg-info/requires.txt +0 -17
- {ducted-2.0 → ducted-2.2}/LICENSE +0 -0
- {ducted-2.0 → ducted-2.2}/duct/ihateprotobuf/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/ihateprotobuf/proto_pb2.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/logs/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/outputs/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/protocol/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/protocol/sflow/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/sources/database/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/sources/linux/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/sources/media/__init__.py +0 -0
- {ducted-2.0/duct/sources/python → ducted-2.2/duct/sources/power}/__init__.py +0 -0
- {ducted-2.0/duct/sources/sensors → ducted-2.2/duct/sources/python}/__init__.py +0 -0
- {ducted-2.0/duct/tests → ducted-2.2/duct/sources/sensors}/__init__.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/tests/globs.py +0 -0
- {ducted-2.0 → ducted-2.2}/duct/tests/test_sflow.py +0 -0
- {ducted-2.0 → ducted-2.2}/ducted.egg-info/dependency_links.txt +0 -0
- {ducted-2.0 → ducted-2.2}/ducted.egg-info/entry_points.txt +0 -0
- {ducted-2.0 → ducted-2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ducted
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2
|
|
4
4
|
Summary: A monitoring agent and event processor
|
|
5
5
|
Author-email: Colin Alston <colin@tamvera.com>
|
|
6
6
|
License: MIT
|
|
@@ -14,18 +14,22 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Topic :: System :: Monitoring
|
|
15
15
|
Requires-Python: >=3.11
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: zope.interface
|
|
18
|
-
Requires-Dist: PyYaml
|
|
19
|
-
Requires-Dist: protobuf
|
|
20
|
-
Requires-Dist: aiohttp
|
|
21
|
-
Requires-Dist: asyncssh
|
|
22
|
-
Requires-Dist: aiodns
|
|
23
|
-
Requires-Dist: pysnmp
|
|
24
|
-
Requires-Dist: cryptography
|
|
25
|
-
Requires-Dist: service_identity
|
|
17
|
+
Requires-Dist: zope.interface>=8.5
|
|
18
|
+
Requires-Dist: PyYaml~=6.0
|
|
19
|
+
Requires-Dist: protobuf~=7.35
|
|
20
|
+
Requires-Dist: aiohttp~=3.14
|
|
21
|
+
Requires-Dist: asyncssh~=2.23
|
|
22
|
+
Requires-Dist: aiodns~=4.0
|
|
23
|
+
Requires-Dist: pysnmp~=7.1
|
|
24
|
+
Requires-Dist: cryptography~=48.0
|
|
25
|
+
Requires-Dist: service_identity~=26.1
|
|
26
|
+
Requires-Dist: pydantic~=2.13
|
|
27
|
+
Requires-Dist: construct~=2.10
|
|
28
|
+
Requires-Dist: nats-py~=2.15
|
|
29
|
+
Requires-Dist: cbor2~=6.1
|
|
30
|
+
Requires-Dist: smbus2~=0.6
|
|
26
31
|
Provides-Extra: dev
|
|
27
32
|
Requires-Dist: asyncpg; extra == "dev"
|
|
28
|
-
Requires-Dist: construct<2.6; extra == "dev"
|
|
29
33
|
Requires-Dist: pytest; extra == "dev"
|
|
30
34
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
31
35
|
Requires-Dist: pytest-cov; extra == "dev"
|
ducted-2.2/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Duct
|
|
2
|
+
|
|
3
|
+
I want to talk to you about ducts. Do your ducts seem old-fashioned? Out of date?
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Tamvera/ducted/actions/workflows/ci.yml)
|
|
6
|
+
[](https://tamvera.github.io/ducted/)
|
|
7
|
+
|
|
8
|
+
**A pluggable Python telemetry pipeline.** Collect metrics from anywhere, route them to any backend, with nothing but Python and a YAML file.
|
|
9
|
+
|
|
10
|
+
Duct is an asyncio daemon that polls *sources* on configurable intervals and routes the resulting events to *outputs*. Both are plain Python classes: anything Python can reach - a socket, a file, an HTTP API, a subprocess, an SSH session, a hardware sensor - can be a source or an output. No DSL, no code generation.
|
|
11
|
+
|
|
12
|
+
## What makes it different
|
|
13
|
+
|
|
14
|
+
- **Sources and outputs can run network servers.** The Prometheus output hosts an HTTP scrape endpoint inside the daemon; the sFlow source runs a UDP collector; the Riemann source acts as a full Riemann TCP server. Duct can receive telemetry from other systems, not only emit it.
|
|
15
|
+
- **SSH remote checks, no remote agent.** Mark any source `use_ssh: true` and it runs transparently on a remote host over a pooled SSH connection.
|
|
16
|
+
- **Modern, backend-agnostic.** InfluxDB 3, Prometheus, NATS (JetStream + SenML/CBOR), Elasticsearch, Graphite, and more. Use one output or several simultaneously.
|
|
17
|
+
- **Fine-grained routing.** Route individual sources to specific outputs or sets of outputs.
|
|
18
|
+
- **Threshold state evaluation.** Declare `warning`/`critical` expressions per-metric; Duct overrides event state automatically.
|
|
19
|
+
- **Blueprint macros.** DRY config: define a toolbox of checks once, expand it across a list of hosts with a single block.
|
|
20
|
+
|
|
21
|
+
## Built-in sources
|
|
22
|
+
|
|
23
|
+
| Category | Sources |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Linux system | CPU, memory, load average, disk I/O, disk free, network, process stats |
|
|
26
|
+
| Sensors & hardware | lm-sensors, SMART, DS18B20 temperature, MPL115 pressure/temperature, UPS (NUT) |
|
|
27
|
+
| Web & proxy | Apache, Nginx (status + log metrics), HAProxy |
|
|
28
|
+
| Databases | PostgreSQL, Elasticsearch, Redis, RabbitMQ, Memcache, Riak |
|
|
29
|
+
| Network | Ping, HTTP, sFlow UDP collector, SNMP |
|
|
30
|
+
| Infrastructure | Docker containers, Postfix, Unbound, StrongSwan/IPsec |
|
|
31
|
+
| Messaging | NATS subscriber (JetStream + SenML) |
|
|
32
|
+
| Ingestion | Riemann TCP listener, Munin node client, uWSGI Emperor |
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
### System wide on Linux, with a systemd script
|
|
37
|
+
```bash
|
|
38
|
+
curl -o - https://raw.githubusercontent.com/Tamvera/ducted/refs/heads/master/scripts/install.sh | sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### With docker
|
|
42
|
+
```bash
|
|
43
|
+
docker run -i -v ./duct.yml:/duct/duct.yml ghcr.io/tamvera/ducted:latest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### With pip
|
|
47
|
+
```bash
|
|
48
|
+
pip install ducted
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.11+.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Create `duct.yml`:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
interval: 1.0
|
|
59
|
+
ttl: 60.0
|
|
60
|
+
|
|
61
|
+
outputs:
|
|
62
|
+
# Prometheus scrape endpoint at http://0.0.0.0:9100/metrics
|
|
63
|
+
- output: duct.outputs.prometheus.Prometheus
|
|
64
|
+
port: 9100
|
|
65
|
+
|
|
66
|
+
sources:
|
|
67
|
+
- service: cpu
|
|
68
|
+
source: duct.sources.linux.basic.CPU
|
|
69
|
+
interval: 1.0
|
|
70
|
+
warning: { cpu: "> 0.5" }
|
|
71
|
+
critical: { cpu: "> 0.8" }
|
|
72
|
+
|
|
73
|
+
- service: memory
|
|
74
|
+
source: duct.sources.linux.basic.Memory
|
|
75
|
+
interval: 10.0
|
|
76
|
+
|
|
77
|
+
- service: disk
|
|
78
|
+
source: duct.sources.linux.basic.DiskFree
|
|
79
|
+
interval: 60.0
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ductd -c duct.yml
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Outputs
|
|
87
|
+
|
|
88
|
+
Fan out to multiple backends simultaneously by naming them:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
outputs:
|
|
92
|
+
- output: duct.outputs.prometheus.Prometheus
|
|
93
|
+
name: prom
|
|
94
|
+
port: 9100
|
|
95
|
+
|
|
96
|
+
- output: duct.outputs.influxdb3.InfluxDB3
|
|
97
|
+
name: influx
|
|
98
|
+
url: http://localhost:8086
|
|
99
|
+
database: mymetrics
|
|
100
|
+
token: my-token
|
|
101
|
+
|
|
102
|
+
- output: duct.outputs.nats.Nats
|
|
103
|
+
name: bus
|
|
104
|
+
servers: ["nats://localhost:4222"]
|
|
105
|
+
format: senml-cbor
|
|
106
|
+
jetstream: true
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Receiving telemetry
|
|
110
|
+
|
|
111
|
+
Sources can listen as well as poll. Funnel events from other systems into the same pipeline and route them to any output:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
sources:
|
|
115
|
+
# Act as a Riemann TCP server - ingest events from other agents
|
|
116
|
+
- service: riemann-in
|
|
117
|
+
source: duct.sources.riemann.RiemannTCP
|
|
118
|
+
port: 5555
|
|
119
|
+
route: influx
|
|
120
|
+
|
|
121
|
+
# Collect sFlow from network switches (UDP server)
|
|
122
|
+
- service: sflow
|
|
123
|
+
source: duct.sources.sflow.sFlow
|
|
124
|
+
port: 6343
|
|
125
|
+
|
|
126
|
+
# Subscribe to a NATS subject
|
|
127
|
+
- service: nats-in
|
|
128
|
+
source: duct.sources.nats.Nats
|
|
129
|
+
servers: ["nats://localhost:4222"]
|
|
130
|
+
topics: ["metrics.>"]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## SSH remote checks
|
|
134
|
+
|
|
135
|
+
Run checks on remote hosts without installing an agent. SSH connections are pooled per host:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
ssh_username: monitor
|
|
139
|
+
ssh_keyfile: /etc/duct/id_ed25519
|
|
140
|
+
|
|
141
|
+
sources:
|
|
142
|
+
- service: load
|
|
143
|
+
source: duct.sources.linux.basic.LoadAverage
|
|
144
|
+
use_ssh: true
|
|
145
|
+
ssh_host: web01.example.com
|
|
146
|
+
interval: 30.0
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use **blueprint macros** to expand a toolbox of checks across many hosts with a single config block - see the [documentation](https://tamvera.github.io/ducted/) for details.
|
|
150
|
+
|
|
151
|
+
## Writing a plugin
|
|
152
|
+
|
|
153
|
+
A source is a Python class. It can use any library, open any connection, or start any server:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from zope.interface import implementer
|
|
157
|
+
from duct.interfaces import IDuctSource
|
|
158
|
+
from duct.objects import Source
|
|
159
|
+
|
|
160
|
+
@implementer(IDuctSource)
|
|
161
|
+
class MySource(Source):
|
|
162
|
+
async def get(self):
|
|
163
|
+
value = await fetch_something()
|
|
164
|
+
return self.createEvent('ok', 'My metric', value, prefix='mymetric')
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Point to it in config by dotted import path:
|
|
168
|
+
|
|
169
|
+
```yaml
|
|
170
|
+
sources:
|
|
171
|
+
- service: mything
|
|
172
|
+
source: mypackage.mysource.MySource
|
|
173
|
+
interval: 10.0
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Outputs follow the same pattern: subclass `Output`, implement `createClient`, and drain the event queue however you like - including running an embedded server.
|
|
177
|
+
|
|
178
|
+
## Documentation
|
|
179
|
+
|
|
180
|
+
Full documentation at <https://tamvera.github.io/ducted/>
|
|
@@ -101,7 +101,7 @@ pygments_style = 'sphinx'
|
|
|
101
101
|
|
|
102
102
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
|
103
103
|
# a list of builtin themes.
|
|
104
|
-
html_theme = '
|
|
104
|
+
html_theme = 'blue_nebula'
|
|
105
105
|
|
|
106
106
|
# Theme options are theme-specific and customize the look and feel of a theme
|
|
107
107
|
# further. For a list of options available for each theme, see the
|
|
@@ -130,7 +130,7 @@ html_theme = 'alabaster'
|
|
|
130
130
|
# Add any paths that contain custom static files (such as style sheets) here,
|
|
131
131
|
# relative to this directory. They are copied after the builtin static files,
|
|
132
132
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
|
133
|
-
html_static_path = ['_static']
|
|
133
|
+
#html_static_path = ['_static']
|
|
134
134
|
|
|
135
135
|
# Add any extra paths that contain custom files (such as robots.txt or
|
|
136
136
|
# .htaccess) here, relative to this directory. These files are copied
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
CWD = Path(__file__).parent
|
|
4
|
+
BASE_DIR = (CWD / "..").resolve()
|
|
5
|
+
|
|
6
|
+
RST_TEMPLATE = """%s
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def scan_modules(name):
|
|
11
|
+
doc_path = CWD / "api" / f"{name}.rst"
|
|
12
|
+
mod_path = BASE_DIR / "duct" / name
|
|
13
|
+
|
|
14
|
+
modules = []
|
|
15
|
+
for node in mod_path.rglob("**/*.py"):
|
|
16
|
+
if node.is_file() and node.name != "__init__.py":
|
|
17
|
+
filepath = node.relative_to(BASE_DIR)
|
|
18
|
+
module = str(filepath.parents[0]/filepath.stem).replace('/', '.')
|
|
19
|
+
modules.append(module)
|
|
20
|
+
|
|
21
|
+
modules.sort()
|
|
22
|
+
print(modules)
|
|
23
|
+
with open(doc_path, "w+t") as f:
|
|
24
|
+
f.write(f"duct.{name}\n")
|
|
25
|
+
f.write("**************\n\n")
|
|
26
|
+
|
|
27
|
+
for module in modules:
|
|
28
|
+
f.write(module + '\n')
|
|
29
|
+
f.write("===================================\n\n")
|
|
30
|
+
f.write(f".. automodule:: {module}\n")
|
|
31
|
+
f.write(" :members:\n")
|
|
32
|
+
f.write(" :show-inheritance:\n\n")
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
scan_modules("sources")
|
|
36
|
+
scan_modules("outputs")
|
|
37
|
+
scan_modules("protocol")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Duct
|
|
2
|
+
Duct - asyncio entry point
|
|
3
3
|
|
|
4
4
|
Usage:
|
|
5
5
|
ductd -c duct.yml
|
|
@@ -18,7 +18,7 @@ from duct.service import DuctService
|
|
|
18
18
|
def main():
|
|
19
19
|
"""Entry point for the duct daemon."""
|
|
20
20
|
parser = argparse.ArgumentParser(
|
|
21
|
-
description='Duct
|
|
21
|
+
description='Duct - A monitoring agent and event processor'
|
|
22
22
|
)
|
|
23
23
|
parser.add_argument(
|
|
24
24
|
'-c', '--config',
|
|
@@ -42,7 +42,10 @@ def main():
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
async def _run(config_path):
|
|
45
|
+
log = logging.getLogger(__name__)
|
|
46
|
+
log.info("Starting Ductd service.")
|
|
45
47
|
config = ConfigFile(config_path)
|
|
48
|
+
|
|
46
49
|
svc = DuctService(config)
|
|
47
50
|
|
|
48
51
|
loop = asyncio.get_running_loop()
|
|
@@ -56,6 +59,7 @@ async def _run(config_path):
|
|
|
56
59
|
loop.add_signal_handler(sig, _handle_signal)
|
|
57
60
|
|
|
58
61
|
await svc.startService()
|
|
62
|
+
log.info("Service running.")
|
|
59
63
|
|
|
60
64
|
await stop_event.wait()
|
|
61
65
|
|
|
@@ -2,22 +2,56 @@
|
|
|
2
2
|
.. module:: configuration
|
|
3
3
|
:synopsis: Configuration file parser
|
|
4
4
|
|
|
5
|
-
.. moduleauthor:: Colin Alston <colin@
|
|
5
|
+
.. moduleauthor:: Colin Alston <colin@tamvera.com>
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
9
|
import itertools
|
|
10
10
|
import copy
|
|
11
11
|
import logging
|
|
12
|
+
from typing import Optional
|
|
12
13
|
|
|
13
14
|
import yaml
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
14
16
|
|
|
15
17
|
log = logging.getLogger(__name__)
|
|
16
18
|
|
|
19
|
+
|
|
17
20
|
class ConfigurationError(Exception):
|
|
18
21
|
"""General exception class for Duct configuration issues
|
|
19
22
|
"""
|
|
20
23
|
|
|
24
|
+
|
|
25
|
+
class DuctConfig(BaseModel):
|
|
26
|
+
"""Top-level configuration schema with centralised defaults."""
|
|
27
|
+
model_config = ConfigDict(extra='allow')
|
|
28
|
+
|
|
29
|
+
# Timing
|
|
30
|
+
interval: float = 60.0
|
|
31
|
+
ttl: float = 60.0
|
|
32
|
+
stagger: float = 0.2
|
|
33
|
+
|
|
34
|
+
# Debugging
|
|
35
|
+
debug: int = 0
|
|
36
|
+
|
|
37
|
+
default_route: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
# Sources and outputs
|
|
40
|
+
sources: list = []
|
|
41
|
+
outputs: Optional[list] = []
|
|
42
|
+
|
|
43
|
+
# Global SSH defaults (can be overridden per-source)
|
|
44
|
+
ssh_knownhosts_file: Optional[str] = '/var/lib/duct/known_hosts'
|
|
45
|
+
ssh_keyfile: Optional[str] = None
|
|
46
|
+
ssh_key: Optional[str] = None
|
|
47
|
+
ssh_keypass: Optional[str] = None
|
|
48
|
+
ssh_username: Optional[str] = None
|
|
49
|
+
ssh_password: Optional[str] = None
|
|
50
|
+
ssh_port: int = 22
|
|
51
|
+
|
|
52
|
+
# Daemon config
|
|
53
|
+
base_path: str = "/var/lib/duct"
|
|
54
|
+
|
|
21
55
|
class ConfigFile(object):
|
|
22
56
|
"""Duct configuration file parser and accessor
|
|
23
57
|
"""
|
|
@@ -32,45 +66,18 @@ class ConfigFile(object):
|
|
|
32
66
|
else:
|
|
33
67
|
raise ConfigurationError(f"Configuration file '{path}' not found")
|
|
34
68
|
|
|
35
|
-
self.known_items = {
|
|
36
|
-
'sources': list,
|
|
37
|
-
'outputs': list,
|
|
38
|
-
'interval': (float, int,),
|
|
39
|
-
'ttl': (float, int,),
|
|
40
|
-
'blueprint': list,
|
|
41
|
-
'ssh_key': str,
|
|
42
|
-
'ssh_user': str,
|
|
43
|
-
'toolbox': dict
|
|
44
|
-
}
|
|
45
|
-
|
|
46
69
|
self._parse_config()
|
|
47
70
|
|
|
48
|
-
def _validate_type(self, item, vtype):
|
|
49
|
-
if not isinstance(vtype, tuple):
|
|
50
|
-
vtype = (vtype, )
|
|
51
|
-
|
|
52
|
-
parse = False
|
|
53
|
-
for vt in vtype:
|
|
54
|
-
try:
|
|
55
|
-
assert isinstance(self.raw_config.get(item, vt()), vtype)
|
|
56
|
-
parse = True
|
|
57
|
-
except AssertionError:
|
|
58
|
-
pass
|
|
59
|
-
|
|
60
|
-
if not parse:
|
|
61
|
-
raise ConfigurationError(
|
|
62
|
-
f"{item} must be one of type {repr(vtype)}")
|
|
63
|
-
|
|
64
|
-
def _validate_config(self):
|
|
65
|
-
for key, val in self.known_items.items():
|
|
66
|
-
self._validate_type(key, val)
|
|
67
|
-
|
|
68
71
|
def _parse_config(self):
|
|
69
72
|
self._merge_includes()
|
|
73
|
+
self._build_blueprints()
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
try:
|
|
76
|
+
self.duct_config = DuctConfig(**self.raw_config)
|
|
77
|
+
except ValidationError as e:
|
|
78
|
+
raise ConfigurationError(str(e)) from e
|
|
72
79
|
|
|
73
|
-
self.
|
|
80
|
+
self.raw_config = self.duct_config.model_dump()
|
|
74
81
|
|
|
75
82
|
def _merge_includes(self):
|
|
76
83
|
def both(i1, i2, t):
|
|
@@ -86,7 +93,6 @@ class ConfigFile(object):
|
|
|
86
93
|
|
|
87
94
|
paths.extend(paths2)
|
|
88
95
|
|
|
89
|
-
|
|
90
96
|
for ipath in paths:
|
|
91
97
|
if os.path.exists(ipath):
|
|
92
98
|
files = [os.path.join(ipath, fi) for fi in os.listdir(ipath)
|
|
@@ -98,15 +104,12 @@ class ConfigFile(object):
|
|
|
98
104
|
for key, val in conf.items():
|
|
99
105
|
if key in self.raw_config:
|
|
100
106
|
if both(val, self.raw_config[key], dict):
|
|
101
|
-
# Merge dicts
|
|
102
107
|
for k2, v2 in val.items():
|
|
103
108
|
self.raw_config[key][k2] = v2
|
|
104
109
|
|
|
105
110
|
elif both(val, self.raw_config[key], list):
|
|
106
|
-
# Extend lists
|
|
107
111
|
self.raw_config[key].extend(val)
|
|
108
112
|
else:
|
|
109
|
-
# Overwrite
|
|
110
113
|
self.raw_config[key] = val
|
|
111
114
|
else:
|
|
112
115
|
self.raw_config[key] = val
|
|
@@ -117,24 +120,20 @@ class ConfigFile(object):
|
|
|
117
120
|
ipath)
|
|
118
121
|
|
|
119
122
|
def _build_blueprints(self):
|
|
120
|
-
# Turn toolboxes into a dict
|
|
121
123
|
toolboxes = self.raw_config.get('toolbox', {})
|
|
122
124
|
blueprints = self.raw_config.get('blueprint', [])
|
|
123
125
|
|
|
124
126
|
if blueprints:
|
|
125
|
-
# Make sure raw config stubs exist if we have any blueprints
|
|
126
127
|
if 'sources' not in self.raw_config:
|
|
127
128
|
self.raw_config['sources'] = []
|
|
128
129
|
|
|
129
130
|
for blueprint in blueprints:
|
|
130
131
|
tbs = blueprint['toolbox']
|
|
131
|
-
# Listify it so we can inherit multiple toolboxes
|
|
132
132
|
if not isinstance(toolboxes, list):
|
|
133
133
|
tbs = [tbs]
|
|
134
134
|
|
|
135
135
|
tbs = [toolboxes[tool] for tool in tbs]
|
|
136
136
|
|
|
137
|
-
# Compute a dot product of all the config settings vectors
|
|
138
137
|
inversions = []
|
|
139
138
|
for key, val in blueprint.get('sets', {}).items():
|
|
140
139
|
inversions.append([(key, jay) for jay in val])
|
|
@@ -142,25 +141,19 @@ class ConfigFile(object):
|
|
|
142
141
|
for options in itertools.product(*inversions):
|
|
143
142
|
for toolbox in tbs:
|
|
144
143
|
for source in toolbox.get('sources', []):
|
|
145
|
-
# Make a copy of the source dict
|
|
146
144
|
mysource = copy.copy(source)
|
|
147
145
|
|
|
148
|
-
# Merge toolbox defaults
|
|
149
146
|
for key, val in toolbox.get('defaults', {}).items():
|
|
150
147
|
mysource[key] = val
|
|
151
148
|
|
|
152
|
-
# Merge blueprint defaults
|
|
153
149
|
for key, val in blueprint.get('defaults', {}).items():
|
|
154
150
|
mysource[key] = val
|
|
155
151
|
|
|
156
|
-
# Merge set permutation
|
|
157
152
|
for key, val in options:
|
|
158
153
|
mysource[key] = val
|
|
159
154
|
|
|
160
|
-
# Bolt it into our real config file
|
|
161
155
|
self.raw_config['sources'].append(mysource)
|
|
162
156
|
|
|
163
|
-
# Free a tiny bit of memory
|
|
164
157
|
if 'toolbox' in self.raw_config:
|
|
165
158
|
del self.raw_config['toolbox']
|
|
166
159
|
|
|
@@ -175,3 +168,6 @@ class ConfigFile(object):
|
|
|
175
168
|
|
|
176
169
|
def __getitem__(self, item):
|
|
177
170
|
return self.raw_config[item]
|
|
171
|
+
|
|
172
|
+
def __getattr__(self, name):
|
|
173
|
+
return self.duct_config.__getattribute__(name)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.. module:: follower
|
|
3
3
|
:synopsis: Log tailing classes
|
|
4
4
|
|
|
5
|
-
.. moduleauthor:: Colin Alston <colin@
|
|
5
|
+
.. moduleauthor:: Colin Alston <colin@tamvera.com>
|
|
6
6
|
"""
|
|
7
7
|
import os
|
|
8
8
|
|
|
@@ -14,6 +14,11 @@ class LogFollower(object):
|
|
|
14
14
|
:type logfile: str
|
|
15
15
|
:param parser: Optional parser method for log lines
|
|
16
16
|
:type parser: str
|
|
17
|
+
:param tmp_path: Temporary path used to store position markers
|
|
18
|
+
:type tmp_path: str
|
|
19
|
+
:param history: If True the log is read from the start instead of tailed
|
|
20
|
+
unless its already been read
|
|
21
|
+
:type history: str
|
|
17
22
|
"""
|
|
18
23
|
|
|
19
24
|
def __init__(self, logfile, parser=None, tmp_path="/var/lib/duct/",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.. module:: objects
|
|
3
3
|
:synopsis: Base classes for sources, outputs, and event objects
|
|
4
4
|
|
|
5
|
-
.. moduleauthor:: Colin Alston <colin@
|
|
5
|
+
.. moduleauthor:: Colin Alston <colin@tamvera.com>
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import hashlib
|
|
@@ -108,7 +108,7 @@ class Output(object):
|
|
|
108
108
|
async def createClient(self):
|
|
109
109
|
"""Coroutine that sets up the output connection"""
|
|
110
110
|
|
|
111
|
-
def eventsReceived(self, events):
|
|
111
|
+
async def eventsReceived(self, events):
|
|
112
112
|
"""Receives a list of events and queues them"""
|
|
113
113
|
if self.maxsize > 0:
|
|
114
114
|
if len(self.events) < self.maxsize:
|
|
@@ -242,7 +242,7 @@ class Source(object):
|
|
|
242
242
|
self._loop_task = asyncio.create_task(self._run_loop())
|
|
243
243
|
|
|
244
244
|
async def _run_loop(self):
|
|
245
|
-
"""Async polling loop
|
|
245
|
+
"""Async polling loop - fires tick() then repeats every inter secs."""
|
|
246
246
|
try:
|
|
247
247
|
while True:
|
|
248
248
|
await self.tick()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.. module:: bosun
|
|
3
3
|
:synopsis: Bosun output
|
|
4
4
|
|
|
5
|
-
.. moduleauthor:: Colin Alston <colin@
|
|
5
|
+
.. moduleauthor:: Colin Alston <colin@tamvera.com>
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
8
|
import base64
|
|
@@ -14,8 +14,6 @@ from duct.utils import HTTPRequest
|
|
|
14
14
|
class Bosun(opentsdb.OpenTSDB):
|
|
15
15
|
"""Bosun HTTP API output
|
|
16
16
|
|
|
17
|
-
**Configuration arguments:**
|
|
18
|
-
|
|
19
17
|
:param url: URL (default: http://localhost:4242)
|
|
20
18
|
:type url: str.
|
|
21
19
|
:param maxsize: Maximum queue backlog size (default: 250000, 0 disables)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.. module:: elasticsearch
|
|
3
3
|
:synopsis: Elasticsearch event output
|
|
4
4
|
|
|
5
|
-
.. moduleauthor:: Colin Alston <colin@
|
|
5
|
+
.. moduleauthor:: Colin Alston <colin@tamvera.com>
|
|
6
6
|
"""
|
|
7
7
|
import asyncio
|
|
8
8
|
import datetime
|
|
@@ -17,8 +17,6 @@ log = logging.getLogger(__name__)
|
|
|
17
17
|
class ElasticSearch(Output):
|
|
18
18
|
"""ElasticSearch HTTP API output
|
|
19
19
|
|
|
20
|
-
**Configuration arguments:**
|
|
21
|
-
|
|
22
20
|
:param url: Elasticsearch URL (default: http://localhost:9200)
|
|
23
21
|
:type url: str
|
|
24
22
|
:param maxsize: Maximum queue backlog size (default: 250000, 0 disables)
|