1 | # py312 |
2 | # requirements: |
3 | # confluent-kafka==2.16.1 |
4 |
|
5 | import json |
6 | import time |
7 | from typing import Optional, TypedDict |
8 |
|
9 | from confluent_kafka import KafkaError, KafkaException, Producer |
10 |
|
11 |
|
12 | class kafka(TypedDict): |
13 | brokers: list |
14 | security: dict |
15 |
|
16 |
|
17 | PROTOCOLS = { |
18 | "PLAINTEXT": "plaintext", |
19 | "SASL_PLAINTEXT": "sasl_plaintext", |
20 | "SSL": "ssl", |
21 | "SASL_SSL": "sasl_ssl", |
22 | "SASL_SSL_OAUTHBEARER": "sasl_ssl", |
23 | } |
24 |
|
25 |
|
26 | def producer_config(auth: kafka) -> dict: |
27 | # Same librdkafka settings as Windmill's Kafka trigger reads from this resource. |
28 | security = auth.get("security") or {} |
29 | label = security.get("label") or "PLAINTEXT" |
30 | if "GSSAPI" in label: |
31 | raise Exception( |
32 | f"{label} is not supported: the Kafka client this script uses is built without Kerberos (GSSAPI)" |
33 | ) |
34 | if label not in PROTOCOLS: |
35 | raise Exception(f"Unknown security label {label!r}: expected one of {', '.join(PROTOCOLS)}") |
36 | conf = { |
37 | "bootstrap.servers": ",".join(auth["brokers"]), |
38 | "security.protocol": PROTOCOLS[label], |
39 | "message.timeout.ms": 30000, |
40 | } |
41 | if label in ("SASL_PLAINTEXT", "SASL_SSL"): |
42 | conf["sasl.mechanism"] = security["mechanism"] |
43 | conf["sasl.username"] = security["username"] |
44 | conf["sasl.password"] = security["password"] |
45 | if label == "SASL_SSL_OAUTHBEARER": |
46 | conf["sasl.mechanism"] = "OAUTHBEARER" |
47 | conf["sasl.oauthbearer.method"] = "oidc" |
48 | conf["sasl.oauthbearer.client.id"] = security["client_id"] |
49 | conf["sasl.oauthbearer.client.secret"] = security["client_secret"] |
50 | conf["sasl.oauthbearer.token.endpoint.url"] = security["token_endpoint_url"] |
51 | conf["https.ca.location"] = "probe" |
52 | if security.get("scope"): |
53 | conf["sasl.oauthbearer.scope"] = security["scope"] |
54 | if security.get("extensions"): |
55 | conf["sasl.oauthbearer.extensions"] = security["extensions"] |
56 | for field, key in ( |
57 | ("ca", "ssl.ca.pem"), |
58 | ("certificate", "ssl.certificate.pem"), |
59 | ("key", "ssl.key.pem"), |
60 | ("key_password", "ssl.key.password"), |
61 | ): |
62 | if security.get(field): |
63 | conf[key] = security[field] |
64 | return conf |
65 |
|
66 |
|
67 | def main( |
68 | auth: kafka, |
69 | topic: str, |
70 | payload, |
71 | key: Optional[str] = None, |
72 | headers: Optional[dict] = None, |
73 | ): |
74 | # None is a tombstone (a null value, which deletes the key on a compacted topic). |
75 | if payload is None: |
76 | value = None |
77 | else: |
78 | value = (payload if isinstance(payload, str) else json.dumps(payload)).encode() |
79 | errors = [] # client-level errors (bad credentials, TLS, unreachable brokers) |
80 | producer = Producer({**producer_config(auth), "error_cb": errors.append}) |
81 | reports = [] |
82 | producer.produce( |
83 | topic, |
84 | value=value, |
85 | key=str(key).encode() if key not in (None, "") else None, |
86 | headers=[ |
87 | (k, (v if isinstance(v, str) else json.dumps(v)).encode()) |
88 | for k, v in (headers or {}).items() |
89 | ] |
90 | or None, |
91 | on_delivery=lambda err, msg: reports.append((err, msg)), |
92 | ) |
93 | # librdkafka retries refused connections until message.timeout.ms. Stop early only |
94 | # on a fatal error, or when every broker is down and one refused the credentials: |
95 | # a broker that is briefly unreachable, or one bad broker among several, may still |
96 | # let the message through. |
97 | deadline = time.monotonic() + 40 |
98 | while not reports and time.monotonic() < deadline: |
99 | producer.poll(0.2) |
100 | for e in errors: |
101 | if e.fatal(): |
102 | raise KafkaException(e) |
103 | auth_errors = [e for e in errors if e.code() == KafkaError._AUTHENTICATION] |
104 | if auth_errors and any(e.code() == KafkaError._ALL_BROKERS_DOWN for e in errors): |
105 | raise KafkaException(auth_errors[0]) |
106 | if not reports: |
107 | raise Exception("Timed out waiting for the broker to acknowledge the message") |
108 | err, msg = reports[0] |
109 | if err is not None: |
110 | # A timed-out delivery says nothing of why: add the last connection error. |
111 | cause = [e for e in errors if e.code() != KafkaError._ALL_BROKERS_DOWN] |
112 | raise Exception(err.str() + (f" (last error: {cause[-1].str()})" if cause else "")) |
113 | return {"topic": msg.topic(), "partition": msg.partition(), "offset": msg.offset()} |
114 | |