Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 0004 Python Checklist

## Baseline

- [x] Plugin work lives under `extended/plugins/send-message-python/`.
- [x] The subtree stands alone.
- [x] No committed Slack credential appears anywhere.
- [x] The plugin owns Kubernetes reads and Slack calls.

## Phase 0: pin the contract

- [x] Re-read `proposal.md`.
- [x] Re-read `spec.md`.
- [x] Re-read the `send-message` slice in proposal `0002`.
- [x] Keep scope to Slack only.

## Phase 1: create the tiny repo

- [x] Create `extended/plugins/send-message-python/`.
- [x] Add runtime source.
- [x] Add image build.
- [x] Add `plugin.yaml`.
- [x] Add CRD manifests.
- [x] Add RBAC manifests.
- [x] Add smoke assets.

## Phase 2: implement the runtime

- [x] Implement `POST /api/v1/step.execute`.
- [x] Enforce bearer auth from `/var/run/kargo/token`.
- [x] Read `MessageChannel`.
- [x] Read `ClusterMessageChannel`.
- [x] Read referenced `Secret`.
- [x] Send plaintext Slack payloads.
- [x] Send encoded Slack payloads.
- [x] Return `slack.threadTS`.

## Phase 3: test the contract

- [x] Add auth tests.
- [x] Add channel lookup tests.
- [x] Add Secret lookup tests.
- [x] Add plaintext payload tests.
- [x] Add encoded payload tests.
- [x] Add XML decode tests.
- [x] Add Slack failure tests.

## Phase 4: smoke

- [x] Add plugin-owned `smoke/smoke_test.py`.
- [x] Keep smoke orchestration in Python, not shell.
- [x] Build the image.
- [x] Load it into kind.
- [x] Install CRDs and RBAC.
- [x] Install StepPlugin `ConfigMap`.
- [x] Create local-only test Secret.
- [x] Create test `MessageChannel`.
- [x] Run a `Stage` with `uses: send-message`.
- [x] Assert `Succeeded`.
- [x] Assert non-empty `slack.threadTS`.

## Phase 5: mandatory radical simplification pass 1

- [x] Ask "can I make this look easier by deleting a dependency?"
- [x] Ask "can I merge files without making the contract harder to read?"
- [x] Ask "am I using a framework just because it is familiar?"
- [x] Delete anything that fails those checks.

## Phase 6: mandatory radical simplification pass 2

- [x] Re-run tests and smoke from a green tree.
- [x] Ask "can I make this radically simpler?"
- [x] Remove abstractions that only serve style.
- [x] Remove helpers that only save a few lines.
- [x] Stop only when the repo still reads like a small third-party plugin.

Refactor pass notes:
- Split the runtime into small package files for:
- app flow
- HTTP entrypoint
- Kubernetes and Slack clients
- payload decoding and shaping
- Split smoke support out of the entrypoint into `smoke/lib.py`.
- Re-ran unit tests after refactor.
- Re-ran `py_compile` across all Python files after refactor.
- Re-ran isolated kind smoke through `extended/tests/e2e_stepplugins.sh`.
119 changes: 119 additions & 0 deletions extended/docs/proposals/0004-send-message-step-plugin/plan.python.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 0004 Python Plan

## Goal

- Show the same `send-message` plugin contract in a form that looks easy to
write and easy to own.
- Keep all plugin-owned code under `extended/plugins/send-message-python/`.
- Keep the subtree standalone enough to behave like its own Git repo.
- Match the same Slack-only contract as `spec.md`.

## Design Rule

- Prefer directness over framework taste.
- Prefer a tiny dependency set over "proper" stacks.
- Prefer raw Kubernetes API reads over a large client library when that keeps
the repo smaller and clearer.
- Prefer the first-party Slack Python SDK only if it reduces code materially.
- If a dependency does not make the plugin look simpler to a third party,
delete it.

## Runtime Shape

- Runtime:
- Python
- Suggested layout:
- `extended/plugins/send-message-python/`
- `server.py`
- `smoke/smoke_test.py`
- `requirements.txt`
- `Dockerfile`
- `plugin.yaml`
- `manifests/`
- `smoke/`
- Suggested server shape:
- one small HTTP server
- one request parser
- one Kubernetes reader
- one Slack sender

## Minimal Dependency Target

- Acceptable:
- `slack_sdk`
- `PyYAML`
- Strong preference:
- stdlib HTTP server
- stdlib `json`
- stdlib `xml.etree.ElementTree`
- direct HTTPS to Kubernetes
- Avoid:
- large web frameworks
- large Kubernetes client stacks
- background workers
- async machinery unless it removes code

## Behavior

- Implement `POST /api/v1/step.execute`.
- Enforce bearer auth from `/var/run/kargo/token`.
- Read `MessageChannel` and `ClusterMessageChannel` directly from Kubernetes.
- Read referenced `Secret` directly from Kubernetes.
- Send Slack messages from the plugin.
- Support:
- plaintext
- `json`
- `yaml`
- `xml`
- Match the response contract in `spec.md`.

## Tests

- Keep tests inside the subtree.
- Prefer a small unit-test suite over a heavy harness.
- Cover:
- auth
- channel lookup
- Secret lookup
- plaintext payload shaping
- encoded payload shaping
- XML decode shape
- Slack error handling

## Smoke

- Own `smoke/smoke_test.py` inside the subtree.
- Assume Kargo already exists.
- Use Python for the smoke orchestration too.
- Build image, install manifests, create Secret and channel, run a Stage,
assert `Succeeded`, assert non-empty `slack.threadTS`.

## Mandatory Simplify Passes

- Simplify pass 1, before full smoke:
- ask "can I delete a dependency and still keep this clearer?"
- ask "can I collapse this into fewer files without hiding the contract?"
- Simplify pass 2, after green:
- ask "can I make this radically simpler?"
- remove any helper, abstraction, or library that does not make the plugin
look easier to write

## Current Implementation Notes

- Runtime now lives in a small package:
- `extended/plugins/send-message-python/send_message_plugin/`
- Smoke orchestration lives in:
- `extended/plugins/send-message-python/smoke/smoke_test.py`
- `extended/plugins/send-message-python/smoke/lib.py`
- Keep the dependency set to:
- stdlib
- `PyYAML`
- Do not add:
- Slack SDK
- Kubernetes Python client
- web framework
- Local validation currently proves:
- unit tests pass
- Python sources compile
- Docker image builds
- isolated kind smoke passes through `extended/tests/e2e_stepplugins.sh`
10 changes: 10 additions & 0 deletions extended/plugins/send-message-python/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM python:3.12-alpine

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENTRYPOINT ["python", "-m", "send_message_plugin"]
81 changes: 81 additions & 0 deletions extended/plugins/send-message-python/manifests/crds.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: messagechannels.ee.kargo.akuity.io
spec:
group: ee.kargo.akuity.io
names:
kind: MessageChannel
plural: messagechannels
singular: messagechannel
listKind: MessageChannelList
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
secretRef:
type: object
properties:
name:
type: string
required:
- name
slack:
type: object
properties:
channelID:
type: string
required:
- channelID
required:
- secretRef
- slack
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: clustermessagechannels.ee.kargo.akuity.io
spec:
group: ee.kargo.akuity.io
names:
kind: ClusterMessageChannel
plural: clustermessagechannels
singular: clustermessagechannel
listKind: ClusterMessageChannelList
scope: Cluster
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
secretRef:
type: object
properties:
name:
type: string
required:
- name
slack:
type: object
properties:
channelID:
type: string
required:
- channelID
required:
- secretRef
- slack
22 changes: 22 additions & 0 deletions extended/plugins/send-message-python/manifests/rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: send-message-step-plugin-reader
rules:
- apiGroups:
- ee.kargo.akuity.io
resources:
- messagechannels
- clustermessagechannels
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- secrets
verbs:
- get
- list
- watch
34 changes: 34 additions & 0 deletions extended/plugins/send-message-python/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
apiVersion: kargo-extended.code.org/v1alpha1
kind: StepPlugin
metadata:
name: send-message
namespace: kargo-system-resources
spec:
sidecar:
automountServiceAccountToken: true
container:
name: send-message-step-plugin
image: send-message-step-plugin-python:dev
imagePullPolicy: IfNotPresent
env:
- name: SYSTEM_RESOURCES_NAMESPACE
value: kargo-system-resources
ports:
- containerPort: 9765
securityContext:
runAsNonRoot: true
runAsUser: 65532
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
steps:
- kind: send-message
1 change: 1 addition & 0 deletions extended/plugins/send-message-python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PyYAML==6.0.3
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from .service import PluginServer
from .http import serve
from .models import (
AUTH_HEADER,
AUTH_TOKEN_PATH,
BEARER_PREFIX,
ChannelResource,
KubernetesClient,
RequestError,
SlackClient,
STEP_EXECUTE_PATH,
)
from .payloads import build_slack_payload, decode_xml_slack_payload

__all__ = [
"AUTH_HEADER",
"AUTH_TOKEN_PATH",
"BEARER_PREFIX",
"ChannelResource",
"KubernetesClient",
"PluginServer",
"RequestError",
"STEP_EXECUTE_PATH",
"SlackClient",
"build_slack_payload",
"decode_xml_slack_payload",
"serve",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .http import serve


def main() -> None:
serve()


if __name__ == "__main__":
main()
Loading
Loading