Skip to content

Commit ecae86a

Browse files
committed
feat: add send-message python plugin
Signed-off-by: Seth Nickell <snickell@gmail.com>
1 parent 6722637 commit ecae86a

21 files changed

Lines changed: 2115 additions & 3 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# 0004 Python Checklist
2+
3+
## Baseline
4+
5+
- [x] Plugin work lives under `extended/plugins/send-message-python/`.
6+
- [x] The subtree stands alone.
7+
- [x] No committed Slack credential appears anywhere.
8+
- [x] The plugin owns Kubernetes reads and Slack calls.
9+
10+
## Phase 0: pin the contract
11+
12+
- [x] Re-read `proposal.md`.
13+
- [x] Re-read `spec.md`.
14+
- [x] Re-read the `send-message` slice in proposal `0002`.
15+
- [x] Keep scope to Slack only.
16+
17+
## Phase 1: create the tiny repo
18+
19+
- [x] Create `extended/plugins/send-message-python/`.
20+
- [x] Add runtime source.
21+
- [x] Add image build.
22+
- [x] Add `plugin.yaml`.
23+
- [x] Add CRD manifests.
24+
- [x] Add RBAC manifests.
25+
- [x] Add smoke assets.
26+
27+
## Phase 2: implement the runtime
28+
29+
- [x] Implement `POST /api/v1/step.execute`.
30+
- [x] Enforce bearer auth from `/var/run/kargo/token`.
31+
- [x] Read `MessageChannel`.
32+
- [x] Read `ClusterMessageChannel`.
33+
- [x] Read referenced `Secret`.
34+
- [x] Send plaintext Slack payloads.
35+
- [x] Send encoded Slack payloads.
36+
- [x] Return `slack.threadTS`.
37+
38+
## Phase 3: test the contract
39+
40+
- [x] Add auth tests.
41+
- [x] Add channel lookup tests.
42+
- [x] Add Secret lookup tests.
43+
- [x] Add plaintext payload tests.
44+
- [x] Add encoded payload tests.
45+
- [x] Add XML decode tests.
46+
- [x] Add Slack failure tests.
47+
48+
## Phase 4: smoke
49+
50+
- [x] Add plugin-owned `smoke/smoke_test.py`.
51+
- [x] Keep smoke orchestration in Python, not shell.
52+
- [x] Build the image.
53+
- [x] Load it into kind.
54+
- [x] Install CRDs and RBAC.
55+
- [x] Install StepPlugin `ConfigMap`.
56+
- [x] Create local-only test Secret.
57+
- [x] Create test `MessageChannel`.
58+
- [x] Run a `Stage` with `uses: send-message`.
59+
- [x] Assert `Succeeded`.
60+
- [x] Assert non-empty `slack.threadTS`.
61+
62+
## Phase 5: mandatory radical simplification pass 1
63+
64+
- [x] Ask "can I make this look easier by deleting a dependency?"
65+
- [x] Ask "can I merge files without making the contract harder to read?"
66+
- [x] Ask "am I using a framework just because it is familiar?"
67+
- [x] Delete anything that fails those checks.
68+
69+
## Phase 6: mandatory radical simplification pass 2
70+
71+
- [x] Re-run tests and smoke from a green tree.
72+
- [x] Ask "can I make this radically simpler?"
73+
- [x] Remove abstractions that only serve style.
74+
- [x] Remove helpers that only save a few lines.
75+
- [x] Stop only when the repo still reads like a small third-party plugin.
76+
77+
Refactor pass notes:
78+
- Split the runtime into small package files for:
79+
- app flow
80+
- HTTP entrypoint
81+
- Kubernetes and Slack clients
82+
- payload decoding and shaping
83+
- Split smoke support out of the entrypoint into `smoke/lib.py`.
84+
- Re-ran unit tests after refactor.
85+
- Re-ran `py_compile` across all Python files after refactor.
86+
- Re-ran isolated kind smoke through `extended/tests/e2e_stepplugins.sh`.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# 0004 Python Plan
2+
3+
## Goal
4+
5+
- Show the same `send-message` plugin contract in a form that looks easy to
6+
write and easy to own.
7+
- Keep all plugin-owned code under `extended/plugins/send-message-python/`.
8+
- Keep the subtree standalone enough to behave like its own Git repo.
9+
- Match the same Slack-only contract as `spec.md`.
10+
11+
## Design Rule
12+
13+
- Prefer directness over framework taste.
14+
- Prefer a tiny dependency set over "proper" stacks.
15+
- Prefer raw Kubernetes API reads over a large client library when that keeps
16+
the repo smaller and clearer.
17+
- Prefer the first-party Slack Python SDK only if it reduces code materially.
18+
- If a dependency does not make the plugin look simpler to a third party,
19+
delete it.
20+
21+
## Runtime Shape
22+
23+
- Runtime:
24+
- Python
25+
- Suggested layout:
26+
- `extended/plugins/send-message-python/`
27+
- `server.py`
28+
- `smoke/smoke_test.py`
29+
- `requirements.txt`
30+
- `Dockerfile`
31+
- `plugin.yaml`
32+
- `manifests/`
33+
- `smoke/`
34+
- Suggested server shape:
35+
- one small HTTP server
36+
- one request parser
37+
- one Kubernetes reader
38+
- one Slack sender
39+
40+
## Minimal Dependency Target
41+
42+
- Acceptable:
43+
- `slack_sdk`
44+
- `PyYAML`
45+
- Strong preference:
46+
- stdlib HTTP server
47+
- stdlib `json`
48+
- stdlib `xml.etree.ElementTree`
49+
- direct HTTPS to Kubernetes
50+
- Avoid:
51+
- large web frameworks
52+
- large Kubernetes client stacks
53+
- background workers
54+
- async machinery unless it removes code
55+
56+
## Behavior
57+
58+
- Implement `POST /api/v1/step.execute`.
59+
- Enforce bearer auth from `/var/run/kargo/token`.
60+
- Read `MessageChannel` and `ClusterMessageChannel` directly from Kubernetes.
61+
- Read referenced `Secret` directly from Kubernetes.
62+
- Send Slack messages from the plugin.
63+
- Support:
64+
- plaintext
65+
- `json`
66+
- `yaml`
67+
- `xml`
68+
- Match the response contract in `spec.md`.
69+
70+
## Tests
71+
72+
- Keep tests inside the subtree.
73+
- Prefer a small unit-test suite over a heavy harness.
74+
- Cover:
75+
- auth
76+
- channel lookup
77+
- Secret lookup
78+
- plaintext payload shaping
79+
- encoded payload shaping
80+
- XML decode shape
81+
- Slack error handling
82+
83+
## Smoke
84+
85+
- Own `smoke/smoke_test.py` inside the subtree.
86+
- Assume Kargo already exists.
87+
- Use Python for the smoke orchestration too.
88+
- Build image, install manifests, create Secret and channel, run a Stage,
89+
assert `Succeeded`, assert non-empty `slack.threadTS`.
90+
91+
## Mandatory Simplify Passes
92+
93+
- Simplify pass 1, before full smoke:
94+
- ask "can I delete a dependency and still keep this clearer?"
95+
- ask "can I collapse this into fewer files without hiding the contract?"
96+
- Simplify pass 2, after green:
97+
- ask "can I make this radically simpler?"
98+
- remove any helper, abstraction, or library that does not make the plugin
99+
look easier to write
100+
101+
## Current Implementation Notes
102+
103+
- Runtime now lives in a small package:
104+
- `extended/plugins/send-message-python/send_message_plugin/`
105+
- Smoke orchestration lives in:
106+
- `extended/plugins/send-message-python/smoke/smoke_test.py`
107+
- `extended/plugins/send-message-python/smoke/lib.py`
108+
- Keep the dependency set to:
109+
- stdlib
110+
- `PyYAML`
111+
- Do not add:
112+
- Slack SDK
113+
- Kubernetes Python client
114+
- web framework
115+
- Local validation currently proves:
116+
- unit tests pass
117+
- Python sources compile
118+
- Docker image builds
119+
- isolated kind smoke passes through `extended/tests/e2e_stepplugins.sh`
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.12-alpine
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt ./
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY . .
9+
10+
ENTRYPOINT ["python", "-m", "send_message_plugin"]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
apiVersion: apiextensions.k8s.io/v1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
name: messagechannels.ee.kargo.akuity.io
5+
spec:
6+
group: ee.kargo.akuity.io
7+
names:
8+
kind: MessageChannel
9+
plural: messagechannels
10+
singular: messagechannel
11+
listKind: MessageChannelList
12+
scope: Namespaced
13+
versions:
14+
- name: v1alpha1
15+
served: true
16+
storage: true
17+
schema:
18+
openAPIV3Schema:
19+
type: object
20+
properties:
21+
spec:
22+
type: object
23+
properties:
24+
secretRef:
25+
type: object
26+
properties:
27+
name:
28+
type: string
29+
required:
30+
- name
31+
slack:
32+
type: object
33+
properties:
34+
channelID:
35+
type: string
36+
required:
37+
- channelID
38+
required:
39+
- secretRef
40+
- slack
41+
---
42+
apiVersion: apiextensions.k8s.io/v1
43+
kind: CustomResourceDefinition
44+
metadata:
45+
name: clustermessagechannels.ee.kargo.akuity.io
46+
spec:
47+
group: ee.kargo.akuity.io
48+
names:
49+
kind: ClusterMessageChannel
50+
plural: clustermessagechannels
51+
singular: clustermessagechannel
52+
listKind: ClusterMessageChannelList
53+
scope: Cluster
54+
versions:
55+
- name: v1alpha1
56+
served: true
57+
storage: true
58+
schema:
59+
openAPIV3Schema:
60+
type: object
61+
properties:
62+
spec:
63+
type: object
64+
properties:
65+
secretRef:
66+
type: object
67+
properties:
68+
name:
69+
type: string
70+
required:
71+
- name
72+
slack:
73+
type: object
74+
properties:
75+
channelID:
76+
type: string
77+
required:
78+
- channelID
79+
required:
80+
- secretRef
81+
- slack
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
apiVersion: rbac.authorization.k8s.io/v1
2+
kind: ClusterRole
3+
metadata:
4+
name: send-message-step-plugin-reader
5+
rules:
6+
- apiGroups:
7+
- ee.kargo.akuity.io
8+
resources:
9+
- messagechannels
10+
- clustermessagechannels
11+
verbs:
12+
- get
13+
- list
14+
- watch
15+
- apiGroups:
16+
- ""
17+
resources:
18+
- secrets
19+
verbs:
20+
- get
21+
- list
22+
- watch
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
apiVersion: kargo-extended.code.org/v1alpha1
2+
kind: StepPlugin
3+
metadata:
4+
name: send-message
5+
namespace: kargo-system-resources
6+
spec:
7+
sidecar:
8+
automountServiceAccountToken: true
9+
container:
10+
name: send-message-step-plugin
11+
image: send-message-step-plugin-python:dev
12+
imagePullPolicy: IfNotPresent
13+
env:
14+
- name: SYSTEM_RESOURCES_NAMESPACE
15+
value: kargo-system-resources
16+
ports:
17+
- containerPort: 9765
18+
securityContext:
19+
runAsNonRoot: true
20+
runAsUser: 65532
21+
allowPrivilegeEscalation: false
22+
readOnlyRootFilesystem: true
23+
capabilities:
24+
drop:
25+
- ALL
26+
resources:
27+
requests:
28+
cpu: 50m
29+
memory: 64Mi
30+
limits:
31+
cpu: 250m
32+
memory: 128Mi
33+
steps:
34+
- kind: send-message
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PyYAML==6.0.3
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from .service import PluginServer
2+
from .http import serve
3+
from .models import (
4+
AUTH_HEADER,
5+
AUTH_TOKEN_PATH,
6+
BEARER_PREFIX,
7+
ChannelResource,
8+
KubernetesClient,
9+
RequestError,
10+
SlackClient,
11+
STEP_EXECUTE_PATH,
12+
)
13+
from .payloads import build_slack_payload, decode_xml_slack_payload
14+
15+
__all__ = [
16+
"AUTH_HEADER",
17+
"AUTH_TOKEN_PATH",
18+
"BEARER_PREFIX",
19+
"ChannelResource",
20+
"KubernetesClient",
21+
"PluginServer",
22+
"RequestError",
23+
"STEP_EXECUTE_PATH",
24+
"SlackClient",
25+
"build_slack_payload",
26+
"decode_xml_slack_payload",
27+
"serve",
28+
]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .http import serve
2+
3+
4+
def main() -> None:
5+
serve()
6+
7+
8+
if __name__ == "__main__":
9+
main()

0 commit comments

Comments
 (0)