From a28c71c85f1dc94f3fdd2b31a405f4312291dc9c Mon Sep 17 00:00:00 2001 From: Seth Nickell Date: Thu, 9 Apr 2026 19:57:39 -1000 Subject: [PATCH 1/4] feat: implement send-message step plugin Signed-off-by: Seth Nickell --- AGENTS.md | 2 + extended/docs/AGENTS.md | 2 +- .../implementation_checklist.md | 94 +++ .../implementation_notes.md | 71 ++ .../implementation_plan.md | 175 +++++ .../0004-send-message-step-plugin/proposal.md | 2 - .../0004-send-message-step-plugin/spec.md | 341 +++++++++ .../pkg/stepplugin/agent/command_bridge.go | 12 +- .../stepplugin/agent/command_bridge_test.go | 85 +++ extended/plugins/send-message/Dockerfile | 19 + .../cmd/send-message-step-plugin/main.go | 22 + extended/plugins/send-message/go.mod | 13 + extended/plugins/send-message/go.sum | 10 + .../send-message/internal/plugin/plugin.go | 700 ++++++++++++++++++ .../internal/plugin/plugin_test.go | 490 ++++++++++++ .../plugins/send-message/manifests/crds.yaml | 81 ++ .../plugins/send-message/manifests/rbac.yaml | 22 + extended/plugins/send-message/plugin.yaml | 34 + extended/plugins/send-message/smoke/README.md | 17 + .../send-message/smoke/render-plugin-dir.sh | 33 + .../plugins/send-message/smoke/smoke-test.sh | 285 +++++++ extended/tests/e2e_stepplugins.sh | 33 + 22 files changed, 2537 insertions(+), 6 deletions(-) create mode 100644 extended/docs/proposals/0004-send-message-step-plugin/implementation_checklist.md create mode 100644 extended/docs/proposals/0004-send-message-step-plugin/implementation_notes.md create mode 100644 extended/docs/proposals/0004-send-message-step-plugin/implementation_plan.md create mode 100644 extended/docs/proposals/0004-send-message-step-plugin/spec.md create mode 100644 extended/pkg/stepplugin/agent/command_bridge_test.go create mode 100644 extended/plugins/send-message/Dockerfile create mode 100644 extended/plugins/send-message/cmd/send-message-step-plugin/main.go create mode 100644 extended/plugins/send-message/go.mod create mode 100644 extended/plugins/send-message/go.sum create mode 100644 extended/plugins/send-message/internal/plugin/plugin.go create mode 100644 extended/plugins/send-message/internal/plugin/plugin_test.go create mode 100644 extended/plugins/send-message/manifests/crds.yaml create mode 100644 extended/plugins/send-message/manifests/rbac.yaml create mode 100644 extended/plugins/send-message/plugin.yaml create mode 100644 extended/plugins/send-message/smoke/README.md create mode 100755 extended/plugins/send-message/smoke/render-plugin-dir.sh create mode 100755 extended/plugins/send-message/smoke/smoke-test.sh diff --git a/AGENTS.md b/AGENTS.md index 0990a1911b..91483e7e79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ to be added to Kargo. `extended/` whenever Go package boundaries allow it. - Load code from `extended/` helper libraries instead of defining new logic in files outside `extended/`. +- Do not fix unrelated upstream bugs outside `extended/` just because you trip over them; only touch upstream-owned files when the fork feature actually needs it. - Expect each edited file outside `extended/` to have a corresponding helper or adapter in `extended/` when that reduces merge-conflict risk. - For every feature seam that forces an edit outside `extended/`, add tests @@ -141,6 +142,7 @@ to be added to Kargo. proposal. 2. Update the proposal as we discuss tradeoffs conversationally and I make decisions. +2.5. When implementation is requested for a proposal with a non-trivial contract, write `spec.md` from `proposal.md` before `implementation_plan.md`. 3. When I tell you to implement, write `implementation_plan.md` first. Follow proposal `0000` guidelines in `extended/docs/proposals/0000-proposal-directory-structure/proposal.md`. diff --git a/extended/docs/AGENTS.md b/extended/docs/AGENTS.md index d8d496e5ba..e539882645 100644 --- a/extended/docs/AGENTS.md +++ b/extended/docs/AGENTS.md @@ -25,7 +25,7 @@ Decisions: - Current direction for `kargo-extended` is runtime-loaded StepPlugins modeled on Argo Workflows executor plugins, not compile-time plugin imports. - 2026-03-23 - Plugins are discovered at runtime from the cluster, not imported into the controller binary. - 2026-03-23 - `kargo-extension-opentofu` implements `tf-plan`, `tf-output`, and `tf-apply`. - 2026-03-23 -- `kargo-extension-send-message` uses the same interface as the pro feature as closely as public docs allow. - 2026-03-23 +- `kargo-extension-send-message` does not keep the "as closely as public docs allow" hedge. - 2026-04-10 - `kargo-extension-send-message` implements `MessageChannel` and `ClusterMessageChannel`. - 2026-03-23 - `kargo-extension-send-message` includes Slack only. - 2026-03-23 - `kargo-extension-send-message` does not include SMTP. - 2026-03-23 diff --git a/extended/docs/proposals/0004-send-message-step-plugin/implementation_checklist.md b/extended/docs/proposals/0004-send-message-step-plugin/implementation_checklist.md new file mode 100644 index 0000000000..07027a24b7 --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/implementation_checklist.md @@ -0,0 +1,94 @@ +# 0004 Implementation Checklist + +Update this as implementation teaches us things. + +## Baseline + +- [x] Plugin work lives under `extended/plugins/send-message`. +- [x] The plugin subtree does not rely on repo resources outside itself. +- [x] Slack credentials stay local-only and out of git. +- [x] The host does not own channel lookup, Secret reads, or Slack API calls. + +## Phase 0: read and pin the seams + +- [x] Read proposal 0004 again before starting code. +- [x] Re-read the `send-message` section in proposal 0002 spec. +- [x] Read the current StepPlugin host runtime and e2e harness seams. +- [x] Record any host-side gap found during that read. + +## Phase 1: create the standalone subtree + +- [x] Create `extended/plugins/send-message/`. +- [x] Add plugin-owned runtime source there. +- [x] Add plugin-owned image build there. +- [x] Add plugin-owned tests there. +- [x] Add plugin-owned CRD manifests there. +- [x] Add plugin-owned RBAC manifests there. +- [x] Add plugin-owned smoke assets or smoke helper there. + +## Phase 2: implement the runtime + +- [x] Implement `POST /api/v1/step.execute`. +- [x] Enforce bearer-token auth from `/var/run/kargo/token`. +- [x] Return `403` on bad auth. +- [x] Reject unsupported methods cleanly. +- [x] Parse the v1 step-config subset. +- [x] Resolve `MessageChannel` from the Project namespace. +- [x] Resolve `ClusterMessageChannel` from the system-resources namespace. +- [x] Resolve referenced Secrets from the correct namespace. +- [x] Read Slack token from Secret key `apiKey`. +- [x] Send the Slack message from the plugin. +- [x] Return `slack.threadTS` output. + +## Phase 3: own the OSS CRDs and RBAC + +- [x] Add Slack-only `MessageChannel` CRD manifest. +- [x] Add Slack-only `ClusterMessageChannel` CRD manifest. +- [x] Keep the API group `ee.kargo.akuity.io/v1alpha1`. +- [x] Add plugin-owned RBAC manifests for channel and Secret reads. +- [x] Keep RBAC setup out of host code unless a real host gap is proven. + +## Phase 4: tests inside the subtree + +- [x] Add runtime tests for auth. +- [x] Add runtime tests for namespaced channel lookup. +- [x] Add runtime tests for cluster-scoped channel lookup. +- [x] Add runtime tests for referenced Secret lookup. +- [x] Add runtime tests for Slack request shaping. +- [x] Add runtime tests for `slack.channelID` override. +- [x] Add runtime tests for `slack.threadTS` override and output. +- [x] Add runtime tests for missing channel or Secret failures. +- [x] Add runtime tests for Slack API failure handling. + +## Phase 5: local smoke path + +- [x] Build the plugin image from `extended/plugins/send-message`. +- [x] Load the image into the local kind cluster. +- [x] Keep kube access on an isolated `KUBECONFIG`. +- [x] Keep the user's existing kube context untouched. +- [x] Install plugin CRDs and RBAC. +- [x] Generate and install the StepPlugin `ConfigMap`. +- [x] Inject a local-only Slack token into a cluster `Secret`. +- [x] Create a test `MessageChannel` or `ClusterMessageChannel`. +- [x] Run a `Stage` with `uses: send-message`. +- [x] Prove promotion success in-cluster. +- [x] Prove `slack.threadTS` output is populated. +- [x] Verify the message appeared in the target Slack channel. + +## Phase 6: repo harness integration + +- [x] Extend `extended/tests/e2e_stepplugins.sh` to support the real + `send-message` smoke path. +- [x] Keep the committed harness credential-free. +- [x] Gate Slack smoke on a local env var for the token. +- [x] Keep any non-`extended/` edit to a tiny hook only, if one is needed. + +## Phase Post-Green: Minimize Diff Of Files Outside ./extended Against Kargo Upstream + +- [x] Fetch `upstream`. +- [x] Review every edited file outside `extended/`, if any, against + `upstream/main`. +- [x] Move more logic behind `extended/` helpers if that shrinks the outside + diff safely. +- [x] Re-run matching tests after each cleanup pass. +- [x] Stop only when no obvious outside-`extended/` shrink remains. diff --git a/extended/docs/proposals/0004-send-message-step-plugin/implementation_notes.md b/extended/docs/proposals/0004-send-message-step-plugin/implementation_notes.md new file mode 100644 index 0000000000..9c2f11bf0c --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/implementation_notes.md @@ -0,0 +1,71 @@ +# 0004 Implementation Notes + +- Plugin subtree: + - `extended/plugins/send-message/` + - standalone `go.mod` + - standalone `Dockerfile` + - standalone `plugin.yaml` + - standalone CRDs and RBAC under `manifests/` + - standalone smoke helper under `smoke/` + +- Runtime contract implemented in the plugin: + - `POST /api/v1/step.execute` + - bearer auth from `/var/run/kargo/token` + - direct Kubernetes API reads from projected service-account credentials + - direct Slack API call from the plugin + - encoded `json`, `yaml`, and `xml` payload support + +- V1 scope shipped: + - `send-message` + - `MessageChannel` + - `ClusterMessageChannel` + - Slack only + - no SMTP + +- Secret and channel rules implemented: + - `secretRef.name` resolves in the Project namespace for `MessageChannel` + - `secretRef.name` resolves in the system-resources namespace for + `ClusterMessageChannel` + - Slack token key is `apiKey` + - `spec.slack.channelID` is required in CRD schema + +- Smoke harness knobs: + - `STEPPLUGIN_SEND_MESSAGE_SMOKE=true` + - `STEPPLUGIN_SEND_MESSAGE_CHANNEL_ID=` + - `STEPPLUGIN_SEND_MESSAGE_SLACK_API_KEY=` +- Plugin-local smoke entrypoint: + - `extended/plugins/send-message/smoke/smoke-test.sh` + - repo harness calls that script rather than owning the full orchestration + +- Host gap found during smoke: + - the StepPlugin auth-token mount was not readable by non-root sidecars + - fix was in `extended/pkg/stepplugin/agent/command_bridge.go` + - auth directory mode is now `0755` + - auth file mode is now `0444` + +- Smoke-result note: + - plugin response includes `output.slack.threadTS` + - Promotion state observed in-cluster stored the value under + `status.state.step-1.slack.threadTS` + - the smoke harness accepts either that path or + `status.stepExecutionMetadata[0].output.slack.threadTS` + +- Encoded-message note: + - when `encodingType` is set, `config.slack.channelID` and + `config.slack.threadTS` are ignored + - encoded body uses Slack-native field names such as `channel` and + `thread_ts` + - if encoded body omits `channel`, plugin fills it from channel resource + `spec.slack.channelID` + +- XML note: + - no public exact upstream XML example was found + - this implementation treats XML as a Kargo-owned alternate serialization of + the same Slack payload object used for `json` and `yaml` + - root name is ignored + - repeated sibling names become arrays + +- Non-plugin repo fix found during smoke: + - `pkg/cli/cmd/promote/promote.go` assumed wrapped REST payloads + - local smoke showed direct-object and direct-list payloads + - decoder helpers now accept both shapes diff --git a/extended/docs/proposals/0004-send-message-step-plugin/implementation_plan.md b/extended/docs/proposals/0004-send-message-step-plugin/implementation_plan.md new file mode 100644 index 0000000000..e6cdaea447 --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/implementation_plan.md @@ -0,0 +1,175 @@ +# 0004 Implementation Plan + +## Goal + +- Deliver a real Slack-only `send-message` StepPlugin under + `extended/plugins/send-message`. +- Keep that subtree self-contained enough to behave like its own Git repo. +- Do not rely on code, files, build helpers, tests, or other resources outside + `extended/plugins/send-message` for the plugin implementation itself. +- Use the StepPlugin host slice from proposal + [0002-kargo-executor-plugins-from-argo-workflows](../0002-kargo-executor-plugins-from-argo-workflows/proposal.md) + without widening host responsibilities. +- Keep channel lookup and referenced Secret reads in the plugin, not the host. +- Use local-only Slack credentials for smoke testing. Do not commit any real + token or local token source. +- Make the plugin match the documented Slack subset, including encoded-message + behavior, except for SMTP which stays out of scope. + +## Implementation Shape + +- Put all plugin-owned source, manifests, tests, and smoke assets under: + - `extended/plugins/send-message/` +- Treat that subtree as a standalone plugin repo: + - own runtime source + - own image build + - own CRDs + - own RBAC manifests + - own tests + - own smoke-test helpers + - own smoke entrypoint script +- It is acceptable for repo-level smoke harness code outside that subtree to + call into the subtree's smoke entrypoint. +- It is not acceptable for plugin runtime code inside that subtree to import or + shell out to helpers elsewhere in this repo. + +## Plugin Runtime + +- Build a plugin-owned runtime image from `extended/plugins/send-message/`. +- Implement `POST /api/v1/step.execute`. +- Enforce the StepPlugin bearer-token contract: + - read `/var/run/kargo/token` + - require `Authorization: Bearer ...` + - return `403` on bad auth +- Read Kubernetes credentials from the mounted service account projection when + present. +- Use direct Kubernetes API reads from the plugin: + - `MessageChannel` in the Project namespace + - `ClusterMessageChannel` cluster-scoped + - referenced `Secret` in the Project namespace or system-resources namespace +- Call Slack from the plugin runtime, not through the host. + +## V1 Behavior + +- Scope: + - `send-message` + - `MessageChannel` + - `ClusterMessageChannel` + - Slack only +- Do not implement SMTP. +- Support this step-config subset in v1: + - `channel.kind` + - `channel.name` + - `message` + - `encodingType` + - `slack.channelID` + - `slack.threadTS` +- Support this output subset in v1: + - `slack.threadTS` +- Plaintext behavior: + - `message` is sent as Slack `text` + - `slack.channelID` overrides channel resource `spec.slack.channelID` + - `slack.threadTS` is sent as Slack `thread_ts` + - output `slack.threadTS` is `config.slack.threadTS` if set, else Slack + response `ts` +- Encoded-message behavior: + - support `json`, `yaml`, and `xml` + - when `encodingType` is set, treat the body as the Slack payload object + - ignore `config.slack.channelID` and `config.slack.threadTS` + - if encoded body omits `channel`, fill it from channel resource + `spec.slack.channelID` + - if encoded body sets `thread_ts`, return that value in output + - otherwise return Slack response `ts` in output +- XML behavior: + - XML is a Kargo-owned alternate serialization of the same Slack payload + object used for `json` and `yaml` + - root element name is ignored + - attributes become keys + - repeated sibling element names become arrays + - nested elements become nested objects +- `MessageChannel` lookup rules: + - resource kind is `MessageChannel` + - resource namespace is the Project namespace from the step context + - `secretRef.name` resolves in the same namespace + - `spec.slack.channelID` is required + - Slack token key is `apiKey` +- `ClusterMessageChannel` lookup rules: + - resource kind is `ClusterMessageChannel` + - `secretRef.name` resolves in the system-resources namespace configured for + the plugin install + - `spec.slack.channelID` is required + - Slack token key is `apiKey` + +## CRDs And RBAC + +- The plugin subtree owns the OSS CRD manifests for: + - `MessageChannel` + - `ClusterMessageChannel` +- Use the existing Akuity API group: + - `ee.kargo.akuity.io/v1alpha1` +- Keep schemas minimal but structurally valid for the Slack-only slice. +- Ship plugin-owned RBAC manifests under the subtree. +- For the local smoke path: + - bind only the test Project default `ServiceAccount` + - grant channel reads and referenced Secret reads needed by the plugin + - keep that setup in the smoke assets or smoke helper, not in host code + +## Tests + +- Keep plugin tests under `extended/plugins/send-message/`. +- Cover at least: + - bearer-token auth + - `MessageChannel` lookup + - `ClusterMessageChannel` lookup + - referenced Secret lookup + - Slack request shaping for plain text + - `slack.channelID` override + - `slack.threadTS` override + - `slack.threadTS` output + - encoded-body behavior ignores `config.slack.*` + - `xml` decode path + - failure path when channel or Secret is missing + - failure path when Slack returns an error + +## Smoke Test + +- Put the primary smoke script in: + - `extended/plugins/send-message/smoke/smoke-test.sh` +- That script assumes Kargo is already available and takes its inputs from env. +- Keep the committed smoke path credential-free. +- Expect a local env var for the Slack API token during local smoke testing. +- Build the plugin image from `extended/plugins/send-message/`. +- Load that image into the local kind cluster without touching the user's + global kube context. +- Install plugin CRDs, RBAC, and generated StepPlugin `ConfigMap`. +- Create either a `MessageChannel` or `ClusterMessageChannel` test resource and + the referenced Secret in-cluster from the local-only token source. +- Run a `Stage` with `uses: send-message`. +- Prove in-cluster success from `Promotion` status, including non-empty + `slack.threadTS`. +- For interactive local verification, also confirm the message appeared in the + target Slack channel. +- The repo harness may call this script at the right point in e2e flow, but the + smoke orchestration lives in the plugin subtree. + +## Host Changes + +- Prefer zero host changes. +- If the plugin proves a host gap, keep any host fix thin and behind + `extended/`. +- Do not move channel lookup, Secret reads, or Slack API calls into the host. +- Host gap actually found: + - non-root sidecars could not read `/var/run/kargo/token` + - keep the fix thin in `extended/pkg/stepplugin/agent/command_bridge.go` + - auth directory mode must permit traversal by non-root sidecars + - auth file mode must permit read by non-root sidecars + +## Phase Post-Green: Minimize Diff Of Files Outside ./extended Against Kargo Upstream + +1. Get the feature green first. +2. Fetch `upstream/main`. +3. Review every edited file outside `extended/`, if any. +4. Move logic behind `extended/` helpers where that shrinks the conflict + surface. +5. Re-run the matching tests after each cleanup pass. +6. Stop when no obvious helper extraction or edit-block reduction remains. diff --git a/extended/docs/proposals/0004-send-message-step-plugin/proposal.md b/extended/docs/proposals/0004-send-message-step-plugin/proposal.md index be493341d7..bffdfd96e4 100644 --- a/extended/docs/proposals/0004-send-message-step-plugin/proposal.md +++ b/extended/docs/proposals/0004-send-message-step-plugin/proposal.md @@ -20,8 +20,6 @@ Date: 2026-03-24 `extended/plugins/send-message`. - Use that subtree to test whether a third party can implement the plugin without merge permissions in the main repo. -- Match the public Kargo `send-message` step shape for the Slack subset as - closely as public docs allow. - Keep channel lookup and referenced Secret reads in the plugin, not the host. - Do not implement SMTP in this slice. diff --git a/extended/docs/proposals/0004-send-message-step-plugin/spec.md b/extended/docs/proposals/0004-send-message-step-plugin/spec.md new file mode 100644 index 0000000000..4ca9c4beb3 --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/spec.md @@ -0,0 +1,341 @@ +# 0004 Spec + +## Scope + +- plugin dir: + - `extended/plugins/send-message` +- step kind: + - `send-message` +- channel resources: + - `MessageChannel` + - `ClusterMessageChannel` +- transport: + - Slack only +- out of scope: + - SMTP + +## Separate-Repo Rule + +- Treat `extended/plugins/send-message` as if it were its own Git repo. +- Plugin runtime, manifests, tests, image build, and smoke assets live under + that subtree. +- The plugin runtime must not require code, files, build helpers, or tests from + elsewhere in `kargo-extended`. +- Repo-level harness code may call the subtree's smoke entrypoint, but the + plugin must own the smoke procedure. + +## Runtime Contract + +- HTTP endpoint: + - `POST /api/v1/step.execute` +- auth: + - sidecar reads `/var/run/kargo/token` + - request must send `Authorization: Bearer ` + - bad or missing token returns `403` +- Kubernetes access: + - plugin reads projected service-account credentials + - plugin reads the Kubernetes API directly +- host responsibilities: + - mount workdir + - mount auth token + - optionally mount projected service-account credentials + - pass opaque step config +- host does not: + - resolve channels + - resolve referenced Secrets + - call Slack + - reshape encoded message bodies + +## Step Config + +Supported config keys: + +- `channel.kind` +- `channel.name` +- `message` +- `encodingType` +- `slack.channelID` +- `slack.threadTS` + +Channel reference: + +- `channel.kind` must be `MessageChannel` or `ClusterMessageChannel` +- `channel.name` names the referenced channel resource + +## Channel Rules + +`MessageChannel`: + +- namespaced +- resource namespace is the step context Project namespace +- `secretRef.name` resolves in the same namespace +- `spec.slack.channelID` is required +- Secret key is `apiKey` + +`ClusterMessageChannel`: + +- cluster-scoped +- `secretRef.name` resolves in `SYSTEM_RESOURCES_NAMESPACE` +- `spec.slack.channelID` is required +- Secret key is `apiKey` + +## Plaintext Behavior + +- `encodingType` omitted or empty means plaintext +- plaintext mode sends Slack `text = config.message` +- plaintext mode resolves `channel` in this order: + - `config.slack.channelID`, if set + - else `channel.spec.slack.channelID` +- plaintext mode sets `thread_ts = config.slack.threadTS` if set +- plaintext output: + - `slack.threadTS = config.slack.threadTS` if set + - else `slack.threadTS = Slack response ts` + +Plaintext example: + +```yaml +channel: + kind: MessageChannel + name: send-message-smoke +message: hello from plugin +slack: + channelID: C999 + threadTS: "1700000000.000001" +``` + +Produces Slack payload: + +```json +{ + "channel": "C999", + "text": "hello from plugin", + "thread_ts": "1700000000.000001" +} +``` + +## Encoded Message Behavior + +- supported encodings: + - `json` + - `yaml` + - `xml` +- encoded mode means `message` is the Slack body, not plain text +- encoded mode owns the Slack body shape +- encoded body uses Slack field names, not Kargo config names +- when `encodingType` is set: + - ignore `config.slack.channelID` + - ignore `config.slack.threadTS` +- if encoded body omits `channel`, fill it from `channel.spec.slack.channelID` +- encoded output: + - `slack.threadTS = payload.thread_ts` if set + - else `slack.threadTS = Slack response ts` + +Encoded body keys may include, for example: + +- `channel` +- `thread_ts` +- `text` +- `blocks` +- `icon_emoji` +- `icon_url` + +JSON example: + +```yaml +channel: + kind: MessageChannel + name: send-message-smoke +encodingType: json +message: | + { + "text": "rich", + "thread_ts": "1700000000.000002", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*hello*" + } + } + ] + } +slack: + channelID: C999 + threadTS: "1700000000.000001" +``` + +Produces Slack payload: + +```json +{ + "channel": "", + "text": "rich", + "thread_ts": "1700000000.000002", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*hello*" + } + } + ] +} +``` + +The outer `slack.*` keys are ignored in this mode. +The encoded body did not set `channel`, so the plugin filled it from the +channel resource. + +## XML Mapping + +- There is no known Slack-standard XML format here. +- XML is a Kargo-owned alternate serialization of the same logical Slack body + object used for `json` and `yaml`. +- The implementation mapping is: + - root element name is ignored + - root attributes become top-level keys + - child element names become object keys + - repeated sibling element names become arrays + - leaf elements become strings + - nested elements become nested objects + - mixed content stores text under `#text` + +Simple XML example: + +```xml + + C1234567890 + hello + 1700000000.000003 + +``` + +Decodes to: + +```json +{ + "channel": "C1234567890", + "text": "hello", + "thread_ts": "1700000000.000003" +} +``` + +Array/object XML example: + +```xml + + rich + + section + + mrkdwn + *hello* + + + + divider + + +``` + +Decodes to: + +```json +{ + "text": "rich", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*hello*" + } + }, + { + "type": "divider" + } + ] +} +``` + +## Response Contract + +Success: + +- status `Succeeded` +- output key `slack.threadTS` + +Failure classes: + +- status `Failed`: + - Slack API rejects the request + - Kubernetes lookup needed for execution fails after request validation +- status `Errored`: + - request shape is invalid + - auth is bad or missing + - config cannot be decoded + - required inputs are missing + +## CRDs And RBAC + +- API group: + - `ee.kargo.akuity.io/v1alpha1` +- plugin-owned CRDs: + - `MessageChannel` + - `ClusterMessageChannel` +- plugin-owned RBAC grants: + - read `MessageChannel` + - read `ClusterMessageChannel` + - read referenced `Secret` +- local smoke binds the plugin reader role only to the test Project default + `ServiceAccount` + +## Smoke Contract + +- primary smoke entrypoint: + - `extended/plugins/send-message/smoke/smoke-test.sh` +- script assumes Kargo is already available +- script requires: + - `SEND_MESSAGE_SMOKE_PROJECT` + - `SEND_MESSAGE_SMOKE_WAREHOUSE` + - `SEND_MESSAGE_SMOKE_FREIGHT_NAME` + - `SEND_MESSAGE_SMOKE_SLACK_API_KEY` + - `SEND_MESSAGE_SMOKE_CHANNEL_ID` +- script may override: + - `SEND_MESSAGE_SMOKE_SYSTEM_RESOURCES_NAMESPACE` + - `SEND_MESSAGE_SMOKE_SECRET_NAME` + - `SEND_MESSAGE_SMOKE_CHANNEL_NAME` + - `SEND_MESSAGE_SMOKE_CONFIGMAP_NAME` + - `KARGO_BIN` + - `KARGO_FLAGS` + - `KUBECTL_BIN` + - `DOCKER_BIN` + - `KIND_BIN` + - `JQ_BIN` +- script behavior: + - require a `kind-*` kube context + - build the plugin image + - load the image into `kind` + - render the plugin build dir + - build the StepPlugin discovery `ConfigMap` + - install CRDs and RBAC + - create test Secret and `MessageChannel` + - create a test `Stage` + - approve and promote the requested freight + - wait for `Succeeded` + - assert non-empty `slack.threadTS` + - clean up test resources on exit +- repo harness may call this script, but this script is the plugin-owned smoke + path + +## Host Prerequisite Found During 0004 + +- Non-root third-party sidecars must be able to read `/var/run/kargo/token`. +- The base host slice was missing that property. +- The current host bridge for that requirement lives under + `extended/pkg/stepplugin/agent/`. +- Current required auth mount behavior for plugin sidecars: + - auth directory mode `0755` + - auth file mode `0444` diff --git a/extended/pkg/stepplugin/agent/command_bridge.go b/extended/pkg/stepplugin/agent/command_bridge.go index 2bc265354e..c709d6414d 100644 --- a/extended/pkg/stepplugin/agent/command_bridge.go +++ b/extended/pkg/stepplugin/agent/command_bridge.go @@ -26,6 +26,12 @@ import ( "github.com/akuity/kargo/pkg/types" ) +var authDir = common.AuthDir + +const authDirMode = 0o755 + +const authFileMode = 0o444 + func NewCommand() *cobra.Command { cmd := &cobra.Command{ Use: "promotion-agent", @@ -54,11 +60,11 @@ func runAgentInit(context.Context) error { containerNames[target.ContainerName] = struct{}{} } for containerName := range containerNames { - filename := filepath.Join(common.AuthDir, containerName, common.AuthFilename) - if err := os.MkdirAll(filepath.Dir(filename), 0o770); err != nil { + filename := filepath.Join(authDir, containerName, common.AuthFilename) + if err := os.MkdirAll(filepath.Dir(filename), authDirMode); err != nil { return err } - if err := os.WriteFile(filename, []byte(rand.String(32)), 0o400); err != nil { + if err := os.WriteFile(filename, []byte(rand.String(32)), authFileMode); err != nil { return err } } diff --git a/extended/pkg/stepplugin/agent/command_bridge_test.go b/extended/pkg/stepplugin/agent/command_bridge_test.go new file mode 100644 index 0000000000..6c4f8c0786 --- /dev/null +++ b/extended/pkg/stepplugin/agent/command_bridge_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/akuity/kargo/extended/pkg/stepplugin/common" + "github.com/akuity/kargo/extended/pkg/stepplugin/executor" +) + +func TestRunAgentInitWritesReadableTokenPerContainer(t *testing.T) { + originalAuthDir := authDir + originalTargets := os.Getenv(common.EnvVarPluginTargets) + t.Cleanup(func() { + authDir = originalAuthDir + require.NoError(t, os.Setenv(common.EnvVarPluginTargets, originalTargets)) + }) + + tempDir := t.TempDir() + authDir = tempDir + + require.NoError( + t, + os.Setenv( + common.EnvVarPluginTargets, + `{"send-message":{"address":"http://localhost:9765","containerName":"send-message-step-plugin"}}`, + ), + ) + + require.NoError(t, runAgentInit(context.Background())) + + tokenPath := filepath.Join( + tempDir, + "send-message-step-plugin", + common.AuthFilename, + ) + info, err := os.Stat(tokenPath) + require.NoError(t, err) + require.Equal(t, os.FileMode(authFileMode), info.Mode().Perm()) + + dirInfo, err := os.Stat(filepath.Dir(tokenPath)) + require.NoError(t, err) + require.Equal(t, os.FileMode(authDirMode), dirInfo.Mode().Perm()) + + token, err := os.ReadFile(tokenPath) + require.NoError(t, err) + require.NotEmpty(t, token) +} + +func TestRunAgentInitDeduplicatesContainerNames(t *testing.T) { + originalAuthDir := authDir + originalTargets := os.Getenv(common.EnvVarPluginTargets) + t.Cleanup(func() { + authDir = originalAuthDir + require.NoError(t, os.Setenv(common.EnvVarPluginTargets, originalTargets)) + }) + + tempDir := t.TempDir() + authDir = tempDir + + targets := map[string]executor.PluginTarget{ + "send-message": { + Address: "http://localhost:9765", + ContainerName: "shared-plugin", + }, + "send-message-alt": { + Address: "http://localhost:9765", + ContainerName: "shared-plugin", + }, + } + targetsJSON, err := json.Marshal(targets) + require.NoError(t, err) + require.NoError(t, os.Setenv(common.EnvVarPluginTargets, string(targetsJSON))) + + require.NoError(t, runAgentInit(context.Background())) + + matches, err := filepath.Glob(filepath.Join(tempDir, "*", common.AuthFilename)) + require.NoError(t, err) + require.Len(t, matches, 1) +} diff --git a/extended/plugins/send-message/Dockerfile b/extended/plugins/send-message/Dockerfile new file mode 100644 index 0000000000..34c8336648 --- /dev/null +++ b/extended/plugins/send-message/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25.1-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags='-s -w' \ + -o /out/send-message-step-plugin ./cmd/send-message-step-plugin + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates + +COPY --from=build /out/send-message-step-plugin /usr/local/bin/send-message-step-plugin + +ENTRYPOINT ["/usr/local/bin/send-message-step-plugin"] diff --git a/extended/plugins/send-message/cmd/send-message-step-plugin/main.go b/extended/plugins/send-message/cmd/send-message-step-plugin/main.go new file mode 100644 index 0000000000..b25f2c0280 --- /dev/null +++ b/extended/plugins/send-message/cmd/send-message-step-plugin/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/code-dot-org/kargo-send-message-step-plugin/internal/plugin" +) + +func main() { + logger := log.New(os.Stderr, "", log.LstdFlags|log.LUTC) + server := plugin.NewServer(plugin.Options{ + Logger: logger, + }) + + addr := ":9765" + logger.Printf("send-message step plugin listening on %s", addr) + if err := http.ListenAndServe(addr, server.Handler()); err != nil { + logger.Fatalf("send-message step plugin server exited: %v", err) + } +} diff --git a/extended/plugins/send-message/go.mod b/extended/plugins/send-message/go.mod new file mode 100644 index 0000000000..53df35e2cd --- /dev/null +++ b/extended/plugins/send-message/go.mod @@ -0,0 +1,13 @@ +module github.com/code-dot-org/kargo-send-message-step-plugin + +go 1.25.1 + +require ( + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/extended/plugins/send-message/go.sum b/extended/plugins/send-message/go.sum new file mode 100644 index 0000000000..c4c1710c47 --- /dev/null +++ b/extended/plugins/send-message/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/extended/plugins/send-message/internal/plugin/plugin.go b/extended/plugins/send-message/internal/plugin/plugin.go new file mode 100644 index 0000000000..61654d3807 --- /dev/null +++ b/extended/plugins/send-message/internal/plugin/plugin.go @@ -0,0 +1,700 @@ +package plugin + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + stepExecutePath = "/api/v1/step.execute" + authHeader = "Authorization" + bearerPrefix = "Bearer " + + authTokenPath = "/var/run/kargo/token" + serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" + serviceAccountToken = "token" + serviceAccountCA = "ca.crt" + defaultSystemNS = "kargo-system-resources" + defaultSlackAPIBase = "https://slack.com/api" + defaultKubernetesURL = "https://kubernetes.default.svc" +) + +type Options struct { + Logger *log.Logger + ExpectedTokenPath string + SystemResourcesNamespace string + SlackAPIBaseURL string + KubernetesClient KubernetesClient + SlackClient SlackClient +} + +type Server struct { + logger *log.Logger + expectedTokenPath string + systemResourcesNamespace string + slackAPIBaseURL string + kubeClient KubernetesClient + slackClient SlackClient +} + +type KubernetesClient interface { + GetMessageChannel( + ctx context.Context, + namespace string, + name string, + ) (*ChannelResource, error) + GetClusterMessageChannel( + ctx context.Context, + name string, + ) (*ChannelResource, error) + GetSecret( + ctx context.Context, + namespace string, + name string, + ) (map[string]string, error) +} + +type SlackClient interface { + PostMessage( + ctx context.Context, + apiBaseURL string, + token string, + payload map[string]any, + ) (*SlackPostMessageResponse, error) +} + +type StepExecuteRequest struct { + Context StepContext `json:"context"` + Step Step `json:"step"` +} + +type StepContext struct { + Project string `json:"project,omitempty"` +} + +type Step struct { + Kind string `json:"kind"` + Config SendMessageConfig `json:"config"` +} + +type SendMessageConfig struct { + Channel ChannelRef `json:"channel"` + Message string `json:"message"` + EncodingType string `json:"encodingType,omitempty"` + Slack SlackOptions `json:"slack,omitempty"` +} + +type ChannelRef struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +type SlackOptions struct { + ChannelID string `json:"channelID,omitempty"` + ThreadTS string `json:"threadTS,omitempty"` +} + +type StepExecuteResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Output map[string]any `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Terminal bool `json:"terminal,omitempty"` +} + +type ChannelResource struct { + SecretName string + SlackChannelID string + ResourceKind string + ResourceName string + ResourceNS string +} + +type SlackPostMessageResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + TS string `json:"ts,omitempty"` +} + +func NewServer(opts Options) *Server { + logger := opts.Logger + if logger == nil { + logger = log.New(io.Discard, "", 0) + } + + expectedTokenPath := opts.ExpectedTokenPath + if expectedTokenPath == "" { + expectedTokenPath = authTokenPath + } + + systemNS := opts.SystemResourcesNamespace + if systemNS == "" { + systemNS = strings.TrimSpace(os.Getenv("SYSTEM_RESOURCES_NAMESPACE")) + } + if systemNS == "" { + systemNS = defaultSystemNS + } + + slackBaseURL := opts.SlackAPIBaseURL + if slackBaseURL == "" { + slackBaseURL = strings.TrimSpace(os.Getenv("SLACK_API_BASE_URL")) + } + if slackBaseURL == "" { + slackBaseURL = defaultSlackAPIBase + } + + kubeClient := opts.KubernetesClient + if kubeClient == nil { + kubeClient = MustNewInClusterKubernetesClient() + } + slackClient := opts.SlackClient + if slackClient == nil { + slackClient = RealSlackClient{} + } + + return &Server{ + logger: logger, + expectedTokenPath: expectedTokenPath, + systemResourcesNamespace: systemNS, + slackAPIBaseURL: slackBaseURL, + kubeClient: kubeClient, + slackClient: slackClient, + } +} + +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.handle) +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != stepExecutePath { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if err := s.authorize(r.Header.Get(authHeader)); err != nil { + s.writeJSON(w, http.StatusForbidden, StepExecuteResponse{ + Status: "Errored", + Message: err.Error(), + Error: err.Error(), + Terminal: true, + }) + return + } + + var req StepExecuteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.writeJSON(w, http.StatusBadRequest, StepExecuteResponse{ + Status: "Errored", + Message: "invalid request body", + Error: err.Error(), + Terminal: true, + }) + return + } + + resp := s.execute(r.Context(), req) + s.writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) execute( + ctx context.Context, + req StepExecuteRequest, +) StepExecuteResponse { + if req.Step.Kind != "send-message" { + return StepExecuteResponse{ + Status: "Errored", + Message: fmt.Sprintf("unsupported step kind %q", req.Step.Kind), + Error: fmt.Sprintf("unsupported step kind %q", req.Step.Kind), + Terminal: true, + } + } + + channel, secretNS, err := s.lookupChannel(ctx, req) + if err != nil { + return failedResponse(err) + } + + secret, err := s.kubeClient.GetSecret(ctx, secretNS, channel.SecretName) + if err != nil { + return failedResponse(fmt.Errorf("error getting Slack Secret: %w", err)) + } + token := secret["apiKey"] + if token == "" { + return failedResponse(errors.New("Slack Secret is missing key \"apiKey\"")) + } + + payload, outputThreadTS, err := buildSlackPayload(req.Step.Config, channel) + if err != nil { + return failedResponse(err) + } + + slackResp, err := s.slackClient.PostMessage( + ctx, + s.slackAPIBaseURL, + token, + payload, + ) + if err != nil { + return failedResponse(fmt.Errorf("error posting Slack message: %w", err)) + } + if !slackResp.OK { + return failedResponse(fmt.Errorf("Slack API error: %s", slackResp.Error)) + } + if outputThreadTS == "" { + outputThreadTS = slackResp.TS + } + + return StepExecuteResponse{ + Status: "Succeeded", + Output: map[string]any{ + "slack": map[string]any{ + "threadTS": outputThreadTS, + }, + }, + } +} + +func (s *Server) lookupChannel( + ctx context.Context, + req StepExecuteRequest, +) (*ChannelResource, string, error) { + ref := req.Step.Config.Channel + switch ref.Kind { + case "MessageChannel": + if req.Context.Project == "" { + return nil, "", errors.New("step context project is required for MessageChannel") + } + channel, err := s.kubeClient.GetMessageChannel(ctx, req.Context.Project, ref.Name) + if err != nil { + return nil, "", err + } + return channel, req.Context.Project, nil + case "ClusterMessageChannel": + channel, err := s.kubeClient.GetClusterMessageChannel(ctx, ref.Name) + if err != nil { + return nil, "", err + } + return channel, s.systemResourcesNamespace, nil + default: + return nil, "", fmt.Errorf("unsupported channel kind %q", ref.Kind) + } +} + +func buildSlackPayload( + cfg SendMessageConfig, + channel *ChannelResource, +) (map[string]any, string, error) { + var payload map[string]any + switch strings.TrimSpace(cfg.EncodingType) { + case "": + channelID := strings.TrimSpace(cfg.Slack.ChannelID) + if channelID == "" { + channelID = strings.TrimSpace(channel.SlackChannelID) + } + if channelID == "" { + return nil, "", fmt.Errorf( + "%s %q does not define spec.slack.channelID and config.slack.channelID is empty", + channel.ResourceKind, + channel.ResourceName, + ) + } + payload = map[string]any{ + "channel": channelID, + "text": cfg.Message, + } + threadTS := strings.TrimSpace(cfg.Slack.ThreadTS) + if threadTS != "" { + payload["thread_ts"] = threadTS + } + return payload, threadTS, nil + case "json": + if err := json.Unmarshal([]byte(cfg.Message), &payload); err != nil { + return nil, "", fmt.Errorf("error decoding JSON Slack payload: %w", err) + } + case "yaml": + if err := yaml.Unmarshal([]byte(cfg.Message), &payload); err != nil { + return nil, "", fmt.Errorf("error decoding YAML Slack payload: %w", err) + } + case "xml": + var err error + payload, err = decodeXMLSlackPayload(cfg.Message) + if err != nil { + return nil, "", err + } + default: + return nil, "", fmt.Errorf("unsupported encodingType %q", cfg.EncodingType) + } + + if payload == nil { + return nil, "", errors.New("Slack payload must decode to an object") + } + + channelID := strings.TrimSpace(channel.SlackChannelID) + if channelID == "" { + return nil, "", fmt.Errorf( + "%s %q does not define spec.slack.channelID", + channel.ResourceKind, + channel.ResourceName, + ) + } + if _, ok := payload["channel"]; !ok { + payload["channel"] = channelID + } + threadTS := strings.TrimSpace(stringValue(payload["thread_ts"])) + return payload, threadTS, nil +} + +type xmlPayloadNode struct { + XMLName xml.Name + Attrs []xml.Attr `xml:",any,attr"` + Nodes []xmlPayloadNode `xml:",any"` + Text string `xml:",chardata"` +} + +func decodeXMLSlackPayload(message string) (map[string]any, error) { + var root xmlPayloadNode + if err := xml.Unmarshal([]byte(message), &root); err != nil { + return nil, fmt.Errorf("error decoding XML Slack payload: %w", err) + } + + payload := xmlNodeToObject(root) + if payload == nil { + return nil, errors.New("Slack payload must decode to an object") + } + return payload, nil +} + +func xmlNodeToObject(node xmlPayloadNode) map[string]any { + payload := map[string]any{} + for _, attr := range node.Attrs { + payload[attr.Name.Local] = attr.Value + } + for _, child := range node.Nodes { + appendXMLValue(payload, child.XMLName.Local, xmlNodeValue(child)) + } + + text := strings.TrimSpace(node.Text) + if text != "" { + if len(payload) == 0 { + payload["text"] = text + } else { + payload["#text"] = text + } + } + return payload +} + +func xmlNodeValue(node xmlPayloadNode) any { + if len(node.Attrs) == 0 && len(node.Nodes) == 0 { + return strings.TrimSpace(node.Text) + } + return xmlNodeToObject(node) +} + +func appendXMLValue(payload map[string]any, key string, value any) { + if existing, ok := payload[key]; ok { + switch typed := existing.(type) { + case []any: + payload[key] = append(typed, value) + default: + payload[key] = []any{typed, value} + } + return + } + payload[key] = value +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return typed + default: + return "" + } +} + +func failedResponse(err error) StepExecuteResponse { + return StepExecuteResponse{ + Status: "Failed", + Message: err.Error(), + Error: err.Error(), + Terminal: true, + } +} + +func (s *Server) authorize(authHeaderValue string) error { + expected, err := os.ReadFile(s.expectedTokenPath) + if err != nil { + return fmt.Errorf("error reading auth token: %w", err) + } + headerValue := strings.TrimSpace(authHeaderValue) + if !strings.HasPrefix(headerValue, bearerPrefix) { + return errors.New("missing bearer token") + } + received := strings.TrimSpace(strings.TrimPrefix(headerValue, bearerPrefix)) + if received != strings.TrimSpace(string(expected)) { + return errors.New("invalid bearer token") + } + return nil +} + +func (s *Server) writeJSON(w http.ResponseWriter, statusCode int, response StepExecuteResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(response); err != nil { + s.logger.Printf("error writing response: %v", err) + } +} + +type RealSlackClient struct{} + +func (RealSlackClient) PostMessage( + ctx context.Context, + apiBaseURL string, + token string, + payload map[string]any, +) (*SlackPostMessageResponse, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + strings.TrimRight(apiBaseURL, "/")+"/chat.postMessage", + strings.NewReader(string(body)), + ) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", bearerPrefix+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result SlackPostMessageResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + if result.Error == "" { + result.Error = resp.Status + } + result.OK = false + } + return &result, nil +} + +type inClusterKubernetesClient struct { + baseURL string + client *http.Client + token string +} + +func MustNewInClusterKubernetesClient() KubernetesClient { + client, err := NewInClusterKubernetesClient() + if err != nil { + panic(err) + } + return client +} + +func NewInClusterKubernetesClient() (KubernetesClient, error) { + tokenBytes, err := os.ReadFile(filepath.Join(serviceAccountDir, serviceAccountToken)) + if err != nil { + return nil, fmt.Errorf("error reading service account token: %w", err) + } + caBytes, err := os.ReadFile(filepath.Join(serviceAccountDir, serviceAccountCA)) + if err != nil { + return nil, fmt.Errorf("error reading service account ca: %w", err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caBytes) { + return nil, errors.New("error loading service account ca") + } + + baseURL := defaultKubernetesURL + if host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")); host != "" { + port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")) + if port == "" { + port = strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT")) + } + if port == "" { + port = "443" + } + baseURL = "https://" + host + ":" + port + } + + return &inClusterKubernetesClient{ + baseURL: baseURL, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: pool, + }, + }, + }, + token: strings.TrimSpace(string(tokenBytes)), + }, nil +} + +func (c *inClusterKubernetesClient) GetMessageChannel( + ctx context.Context, + namespace string, + name string, +) (*ChannelResource, error) { + var response struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"metadata"` + Spec struct { + SecretRef struct { + Name string `json:"name"` + } `json:"secretRef"` + Slack struct { + ChannelID string `json:"channelID"` + } `json:"slack"` + } `json:"spec"` + } + err := c.getJSON( + ctx, + fmt.Sprintf( + "/apis/ee.kargo.akuity.io/v1alpha1/namespaces/%s/messagechannels/%s", + namespace, + name, + ), + &response, + ) + if err != nil { + return nil, err + } + return &ChannelResource{ + SecretName: response.Spec.SecretRef.Name, + SlackChannelID: response.Spec.Slack.ChannelID, + ResourceKind: "MessageChannel", + ResourceName: response.Metadata.Name, + ResourceNS: response.Metadata.Namespace, + }, nil +} + +func (c *inClusterKubernetesClient) GetClusterMessageChannel( + ctx context.Context, + name string, +) (*ChannelResource, error) { + var response struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + SecretRef struct { + Name string `json:"name"` + } `json:"secretRef"` + Slack struct { + ChannelID string `json:"channelID"` + } `json:"slack"` + } `json:"spec"` + } + err := c.getJSON( + ctx, + fmt.Sprintf( + "/apis/ee.kargo.akuity.io/v1alpha1/clustermessagechannels/%s", + name, + ), + &response, + ) + if err != nil { + return nil, err + } + return &ChannelResource{ + SecretName: response.Spec.SecretRef.Name, + SlackChannelID: response.Spec.Slack.ChannelID, + ResourceKind: "ClusterMessageChannel", + ResourceName: response.Metadata.Name, + }, nil +} + +func (c *inClusterKubernetesClient) GetSecret( + ctx context.Context, + namespace string, + name string, +) (map[string]string, error) { + var response struct { + Data map[string]string `json:"data"` + } + err := c.getJSON( + ctx, + fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", namespace, name), + &response, + ) + if err != nil { + return nil, err + } + + decoded := make(map[string]string, len(response.Data)) + for key, value := range response.Data { + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("error decoding secret key %q: %w", key, err) + } + decoded[key] = string(raw) + } + return decoded, nil +} + +func (c *inClusterKubernetesClient) getJSON( + ctx context.Context, + path string, + out any, +) error { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + strings.TrimRight(c.baseURL, "/")+path, + nil, + ) + if err != nil { + return err + } + req.Header.Set(authHeader, bearerPrefix+c.token) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("resource not found at %s", path) + } + if resp.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Kubernetes API error %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/extended/plugins/send-message/internal/plugin/plugin_test.go b/extended/plugins/send-message/internal/plugin/plugin_test.go new file mode 100644 index 0000000000..751ed545cc --- /dev/null +++ b/extended/plugins/send-message/internal/plugin/plugin_test.go @@ -0,0 +1,490 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHandlerRejectsMissingBearerToken(t *testing.T) { + srv := newTestServer(t, nil, nil) + req := httptest.NewRequest( + http.MethodPost, + stepExecutePath, + bytes.NewReader(mustJSON(t, minimalRequest())), + ) + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + var resp StepExecuteResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "Errored", resp.Status) +} + +func TestExecuteUsesNamespacedMessageChannel(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000100", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.Channel = ChannelRef{ + Kind: "MessageChannel", + Name: "send", + } + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal(t, "1712345678.000100", resp.Output["slack"].(map[string]any)["threadTS"]) + require.Equal( + t, + map[string]any{ + "channel": "C123", + "text": "hello from plugin", + }, + slack.lastPayload, + ) +} + +func TestExecuteUsesClusterMessageChannel(t *testing.T) { + kube := &fakeKubernetesClient{ + clusterMessageChannels: map[string]*ChannelResource{ + "send": { + SecretName: "slack-token", + SlackChannelID: "C777", + ResourceKind: "ClusterMessageChannel", + ResourceName: "send", + }, + }, + secrets: map[string]map[string]string{ + "kargo-system-resources/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000200", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.Channel = ChannelRef{ + Kind: "ClusterMessageChannel", + Name: "send", + } + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C777", + "text": "hello from plugin", + }, + slack.lastPayload, + ) +} + +func TestExecuteHonorsSlackOverrides(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000300", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.Slack.ChannelID = "C999" + req.Step.Config.Slack.ThreadTS = "1700000000.000001" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C999", + "text": "hello from plugin", + "thread_ts": "1700000000.000001", + }, + slack.lastPayload, + ) + require.Equal( + t, + "1700000000.000001", + resp.Output["slack"].(map[string]any)["threadTS"], + ) +} + +func TestExecuteSupportsJSONEncoding(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000400", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.EncodingType = "json" + req.Step.Config.Message = `{"text":"rich","blocks":[{"type":"section"}]}` + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C123", + "text": "rich", + "blocks": []any{ + map[string]any{"type": "section"}, + }, + }, + slack.lastPayload, + ) +} + +func TestExecuteEncodedPayloadIgnoresSlackConfigOverrides(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000450", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.EncodingType = "json" + req.Step.Config.Message = `{"text":"rich","thread_ts":"1700000000.000002"}` + req.Step.Config.Slack.ChannelID = "C999" + req.Step.Config.Slack.ThreadTS = "1700000000.000001" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C123", + "text": "rich", + "thread_ts": "1700000000.000002", + }, + slack.lastPayload, + ) + require.Equal( + t, + "1700000000.000002", + resp.Output["slack"].(map[string]any)["threadTS"], + ) +} + +func TestExecuteFailsWhenSlackErrors(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: false, + Error: "channel_not_found", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Failed", resp.Status) + require.Contains(t, resp.Error, "channel_not_found") +} + +func TestExecuteFailsWhenSecretMissing(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{}, + } + srv := newTestServer(t, kube, &fakeSlackClient{}) + + req := minimalRequest() + req.Context.Project = "demo" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Failed", resp.Status) + require.Contains(t, resp.Error, "error getting Slack Secret") +} + +func TestExecuteSupportsXMLEncoding(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000500", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.EncodingType = "xml" + req.Step.Config.Message = ` + + rich + 1700000000.000003 +` + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C123", + "text": "rich", + "thread_ts": "1700000000.000003", + }, + slack.lastPayload, + ) + require.Equal( + t, + "1700000000.000003", + resp.Output["slack"].(map[string]any)["threadTS"], + ) +} + +type fakeKubernetesClient struct { + messageChannels map[string]*ChannelResource + clusterMessageChannels map[string]*ChannelResource + secrets map[string]map[string]string +} + +func (f *fakeKubernetesClient) GetMessageChannel( + _ context.Context, + namespace string, + name string, +) (*ChannelResource, error) { + if channel, ok := f.messageChannels[namespace+"/"+name]; ok { + return channel, nil + } + return nil, errors.New("message channel not found") +} + +func (f *fakeKubernetesClient) GetClusterMessageChannel( + _ context.Context, + name string, +) (*ChannelResource, error) { + if channel, ok := f.clusterMessageChannels[name]; ok { + return channel, nil + } + return nil, errors.New("cluster message channel not found") +} + +func (f *fakeKubernetesClient) GetSecret( + _ context.Context, + namespace string, + name string, +) (map[string]string, error) { + if secret, ok := f.secrets[namespace+"/"+name]; ok { + return secret, nil + } + return nil, errors.New("secret not found") +} + +type fakeSlackClient struct { + lastPayload map[string]any + response *SlackPostMessageResponse + err error +} + +func (f *fakeSlackClient) PostMessage( + _ context.Context, + _ string, + _ string, + payload map[string]any, +) (*SlackPostMessageResponse, error) { + f.lastPayload = payload + if f.err != nil { + return nil, f.err + } + if f.response == nil { + return &SlackPostMessageResponse{OK: true, TS: "0"}, nil + } + return f.response, nil +} + +func newTestServer( + t *testing.T, + kube KubernetesClient, + slack SlackClient, +) *Server { + t.Helper() + + dir := t.TempDir() + tokenPath := filepath.Join(dir, "token") + require.NoError(t, os.WriteFile(tokenPath, []byte("expected-token"), 0o600)) + + if kube == nil { + kube = &fakeKubernetesClient{} + } + if slack == nil { + slack = &fakeSlackClient{} + } + + return NewServer(Options{ + Logger: log.New(io.Discard, "", 0), + ExpectedTokenPath: tokenPath, + SystemResourcesNamespace: "kargo-system-resources", + KubernetesClient: kube, + SlackClient: slack, + }) +} + +func executeRequest( + t *testing.T, + srv *Server, + req StepExecuteRequest, +) StepExecuteResponse { + t.Helper() + + httpReq := httptest.NewRequest( + http.MethodPost, + stepExecutePath, + bytes.NewReader(mustJSON(t, req)), + ) + httpReq.Header.Set(authHeader, bearerPrefix+"expected-token") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httpReq) + require.Equal(t, http.StatusOK, rec.Code) + + var resp StepExecuteResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp +} + +func minimalRequest() StepExecuteRequest { + return StepExecuteRequest{ + Step: Step{ + Kind: "send-message", + Config: SendMessageConfig{ + Channel: ChannelRef{ + Kind: "MessageChannel", + Name: "send", + }, + Message: "hello from plugin", + }, + }, + } +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return data +} diff --git a/extended/plugins/send-message/manifests/crds.yaml b/extended/plugins/send-message/manifests/crds.yaml new file mode 100644 index 0000000000..008f9931f8 --- /dev/null +++ b/extended/plugins/send-message/manifests/crds.yaml @@ -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 diff --git a/extended/plugins/send-message/manifests/rbac.yaml b/extended/plugins/send-message/manifests/rbac.yaml new file mode 100644 index 0000000000..df10af6b7f --- /dev/null +++ b/extended/plugins/send-message/manifests/rbac.yaml @@ -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 diff --git a/extended/plugins/send-message/plugin.yaml b/extended/plugins/send-message/plugin.yaml new file mode 100644 index 0000000000..76126e0a04 --- /dev/null +++ b/extended/plugins/send-message/plugin.yaml @@ -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: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 diff --git a/extended/plugins/send-message/smoke/README.md b/extended/plugins/send-message/smoke/README.md new file mode 100644 index 0000000000..1bf15b5865 --- /dev/null +++ b/extended/plugins/send-message/smoke/README.md @@ -0,0 +1,17 @@ +# Local Smoke Notes + +- Primary smoke entrypoint: + - `extended/plugins/send-message/smoke/smoke-test.sh` +- The script assumes a working Kargo cluster and CLI are already available. +- Required env: + - `SEND_MESSAGE_SMOKE_PROJECT` + - `SEND_MESSAGE_SMOKE_WAREHOUSE` + - `SEND_MESSAGE_SMOKE_FREIGHT_NAME` + - `SEND_MESSAGE_SMOKE_SLACK_API_KEY` + - `SEND_MESSAGE_SMOKE_CHANNEL_ID` +- Optional env: + - `KARGO_BIN` + - `KARGO_FLAGS` + - `SEND_MESSAGE_SMOKE_SYSTEM_RESOURCES_NAMESPACE` +- No committed file in this subtree contains a real token value or local token + source. diff --git a/extended/plugins/send-message/smoke/render-plugin-dir.sh b/extended/plugins/send-message/smoke/render-plugin-dir.sh new file mode 100755 index 0000000000..eecbfa6d96 --- /dev/null +++ b/extended/plugins/send-message/smoke/render-plugin-dir.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 OUT_DIR IMAGE SYSTEM_RESOURCES_NAMESPACE" >&2 + exit 1 +fi + +OUT_DIR="$1" +IMAGE="$2" +SYSTEM_RESOURCES_NAMESPACE="$3" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +mkdir -p "$OUT_DIR" +cp "$PLUGIN_DIR/plugin.yaml" "$OUT_DIR/plugin.yaml" + +python3 - "$OUT_DIR/plugin.yaml" "$IMAGE" "$SYSTEM_RESOURCES_NAMESPACE" <<'PY' +import pathlib +import sys + +plugin_yaml = pathlib.Path(sys.argv[1]) +image = sys.argv[2] +system_ns = sys.argv[3] + +data = plugin_yaml.read_text() +data = data.replace("namespace: kargo-system-resources", f"namespace: {system_ns}") +data = data.replace("image: send-message-step-plugin:dev", f"image: {image}") +data = data.replace("value: kargo-system-resources", f"value: {system_ns}") +plugin_yaml.write_text(data) +PY diff --git a/extended/plugins/send-message/smoke/smoke-test.sh b/extended/plugins/send-message/smoke/smoke-test.sh new file mode 100755 index 0000000000..772585bc98 --- /dev/null +++ b/extended/plugins/send-message/smoke/smoke-test.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log_info() { + printf '[INFO] %s\n' "$*" +} + +log_pass() { + printf '[PASS] %s\n' "$*" +} + +log_fail() { + printf '[FAIL] %s\n' "$*" >&2 +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + log_fail "$name is required" + exit 1 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +KARGO_BIN="${KARGO_BIN:-kargo}" +KARGO_FLAGS="${KARGO_FLAGS:-}" +KUBECTL_BIN="${KUBECTL_BIN:-kubectl}" +DOCKER_BIN="${DOCKER_BIN:-docker}" +KIND_BIN="${KIND_BIN:-kind}" +JQ_BIN="${JQ_BIN:-jq}" + +SYSTEM_RESOURCES_NS="${SEND_MESSAGE_SMOKE_SYSTEM_RESOURCES_NAMESPACE:-kargo-system-resources}" +SECRET_NAME="${SEND_MESSAGE_SMOKE_SECRET_NAME:-send-message-slack-token}" +CHANNEL_NAME="${SEND_MESSAGE_SMOKE_CHANNEL_NAME:-send-message-smoke}" +CONFIGMAP_NAME="${SEND_MESSAGE_SMOKE_CONFIGMAP_NAME:-send-message-step-plugin}" +CLUSTER_ROLE_NAME="send-message-step-plugin-reader" + +require_env SEND_MESSAGE_SMOKE_PROJECT +require_env SEND_MESSAGE_SMOKE_WAREHOUSE +require_env SEND_MESSAGE_SMOKE_FREIGHT_NAME +require_env SEND_MESSAGE_SMOKE_SLACK_API_KEY +require_env SEND_MESSAGE_SMOKE_CHANNEL_ID + +TEST_PROJECT="$SEND_MESSAGE_SMOKE_PROJECT" +TEST_WAREHOUSE="$SEND_MESSAGE_SMOKE_WAREHOUSE" +SMOKE_FREIGHT_NAME="$SEND_MESSAGE_SMOKE_FREIGHT_NAME" + +PLUGIN_BUILD_DIR="" +STAGE_NAME="" +CLUSTER_ROLE_BINDING_NAME="" + +cleanup() { + if [[ -n "$STAGE_NAME" ]]; then + "$KUBECTL_BIN" delete stage.kargo.akuity.io "$STAGE_NAME" \ + -n "$TEST_PROJECT" \ + --ignore-not-found >/dev/null 2>&1 || true + fi + + "$KUBECTL_BIN" delete messagechannel.ee.kargo.akuity.io "$CHANNEL_NAME" \ + -n "$TEST_PROJECT" \ + --ignore-not-found >/dev/null 2>&1 || true + "$KUBECTL_BIN" delete secret "$SECRET_NAME" \ + -n "$TEST_PROJECT" \ + --ignore-not-found >/dev/null 2>&1 || true + "$KUBECTL_BIN" delete configmap "$CONFIGMAP_NAME" \ + -n "$SYSTEM_RESOURCES_NS" \ + --ignore-not-found >/dev/null 2>&1 || true + + if [[ -n "$CLUSTER_ROLE_BINDING_NAME" ]]; then + "$KUBECTL_BIN" delete clusterrolebinding "$CLUSTER_ROLE_BINDING_NAME" \ + --ignore-not-found >/dev/null 2>&1 || true + fi + + "$KUBECTL_BIN" delete clusterrole "$CLUSTER_ROLE_NAME" \ + --ignore-not-found >/dev/null 2>&1 || true + "$KUBECTL_BIN" delete -f "$PLUGIN_DIR/manifests/crds.yaml" \ + --ignore-not-found >/dev/null 2>&1 || true + + if [[ -n "$PLUGIN_BUILD_DIR" ]]; then + rm -rf "$PLUGIN_BUILD_DIR" + fi +} + +trap cleanup EXIT + +current_context="$("$KUBECTL_BIN" config current-context)" +if [[ "$current_context" != kind-* ]]; then + log_fail "send-message smoke requires a kind context, got: $current_context" + exit 1 +fi + +kind_name="${current_context#kind-}" +image_tag="send-message-step-plugin:e2e-$(date +%s)" +PLUGIN_BUILD_DIR="$(mktemp -d "/tmp/send-message-stepplugin-e2e-XXXXXX")" + +log_info "Build send-message StepPlugin image" +"$DOCKER_BIN" build -t "$image_tag" "$PLUGIN_DIR" +log_pass "Build send-message StepPlugin image" + +log_info "Load send-message StepPlugin image into kind" +"$KIND_BIN" load docker-image --name "$kind_name" "$image_tag" +log_pass "Load send-message StepPlugin image into kind" + +log_info "Render send-message StepPlugin build dir" +"$SCRIPT_DIR/render-plugin-dir.sh" "$PLUGIN_BUILD_DIR" "$image_tag" "$SYSTEM_RESOURCES_NS" +log_pass "Render send-message StepPlugin build dir" + +log_info "Build send-message StepPlugin ConfigMap" +( + cd "$PLUGIN_BUILD_DIR" + "$KARGO_BIN" step-plugin build . +) +log_pass "Build send-message StepPlugin ConfigMap" + +log_info "Install send-message CRDs" +"$KUBECTL_BIN" apply -f "$PLUGIN_DIR/manifests/crds.yaml" +log_pass "Install send-message CRDs" + +log_info "Install send-message ClusterRole" +"$KUBECTL_BIN" apply -f "$PLUGIN_DIR/manifests/rbac.yaml" +log_pass "Install send-message ClusterRole" + +CLUSTER_ROLE_BINDING_NAME="send-message-step-plugin-reader-${TEST_PROJECT}" +cat > "/tmp/${CLUSTER_ROLE_BINDING_NAME}.yaml" < "/tmp/${SECRET_NAME}.yaml" < "/tmp/${CHANNEL_NAME}.yaml" < "/tmp/${STAGE_NAME}.yaml" </dev/null | + "$JQ_BIN" -r --arg stage "$STAGE_NAME" ' + [.items[] | select(.spec.stage == $stage)] + | sort_by(.metadata.creationTimestamp) + | last // {} + ' + )" || true + if [[ -n "$promotion_json" && "$promotion_json" != "null" ]]; then + promotion_name="$(printf '%s\n' "$promotion_json" | "$JQ_BIN" -r '.metadata.name // empty')" + phase="$(printf '%s\n' "$promotion_json" | "$JQ_BIN" -r '.status.phase // empty')" + thread_ts="$( + printf '%s\n' "$promotion_json" | + "$JQ_BIN" -r ' + .status.stepExecutionMetadata[0].output.slack.threadTS // + .status.state["step-1"].slack.threadTS // + empty + ' + )" + fi + case "$phase" in + Succeeded) + break + ;; + Failed|Errored|Aborted) + log_fail "send-message smoke promotion reached terminal phase $phase" + "$KUBECTL_BIN" get promotion.kargo.akuity.io "$promotion_name" \ + -n "$TEST_PROJECT" \ + -o yaml + exit 1 + ;; + esac + sleep 2 +done + +if [[ "$phase" != "Succeeded" ]]; then + log_fail "send-message smoke promotion did not succeed in time" + "$KUBECTL_BIN" get promotion.kargo.akuity.io -n "$TEST_PROJECT" -o yaml + exit 1 +fi + +if [[ -z "$thread_ts" ]]; then + log_fail "send-message smoke did not produce slack.threadTS output" + "$KUBECTL_BIN" get promotion.kargo.akuity.io "$promotion_name" \ + -n "$TEST_PROJECT" \ + -o yaml + exit 1 +fi + +log_pass "send-message StepPlugin smoke promotion finished successfully" diff --git a/extended/tests/e2e_stepplugins.sh b/extended/tests/e2e_stepplugins.sh index 075f9a67f3..cf6052000a 100644 --- a/extended/tests/e2e_stepplugins.sh +++ b/extended/tests/e2e_stepplugins.sh @@ -356,5 +356,38 @@ EOF fi wait_for_stepplugin_promotion "$promotion_name" + run_send_message_stepplugin_e2e_tests "$smoke_freight_name" stepplugin_e2e_end "success" } + +run_send_message_stepplugin_e2e_tests() { + local smoke_freight_name="$1" + + if [[ "${STEPPLUGIN_SEND_MESSAGE_SMOKE:-false}" != "true" ]]; then + log_info "Skipping send-message StepPlugin smoke path; set STEPPLUGIN_SEND_MESSAGE_SMOKE=true to enable it" + return 0 + fi + + if [[ -z "${STEPPLUGIN_SEND_MESSAGE_SLACK_API_KEY:-}" ]]; then + log_error "STEPPLUGIN_SEND_MESSAGE_SLACK_API_KEY is required for send-message StepPlugin smoke" + stepplugin_e2e_fail + fi + + if [[ -z "${STEPPLUGIN_SEND_MESSAGE_CHANNEL_ID:-}" ]]; then + log_error "STEPPLUGIN_SEND_MESSAGE_CHANNEL_ID is required for send-message StepPlugin smoke" + stepplugin_e2e_fail + fi + + log_test "Run send-message StepPlugin smoke script" + if ! SEND_MESSAGE_SMOKE_PROJECT="$TEST_PROJECT" \ + SEND_MESSAGE_SMOKE_WAREHOUSE="$TEST_WAREHOUSE" \ + SEND_MESSAGE_SMOKE_FREIGHT_NAME="$smoke_freight_name" \ + SEND_MESSAGE_SMOKE_SLACK_API_KEY="$STEPPLUGIN_SEND_MESSAGE_SLACK_API_KEY" \ + SEND_MESSAGE_SMOKE_CHANNEL_ID="$STEPPLUGIN_SEND_MESSAGE_CHANNEL_ID" \ + SEND_MESSAGE_SMOKE_SYSTEM_RESOURCES_NAMESPACE="$SYSTEM_RESOURCES_NS" \ + KARGO_BIN="$KARGO_BIN" \ + KARGO_FLAGS="$KARGO_FLAGS" \ + "$REPO_ROOT/extended/plugins/send-message/smoke/smoke-test.sh"; then + stepplugin_e2e_fail + fi +} From ba34b9893647e28149b3fe5cf25cdabec3037ddd Mon Sep 17 00:00:00 2001 From: Seth Nickell Date: Thu, 9 Apr 2026 23:28:27 -1000 Subject: [PATCH 2/4] refactor: polish send-message go plugin Signed-off-by: Seth Nickell --- .../Dockerfile | 6 +- .../cmd/send-message-plugin}/main.go | 0 .../{send-message => send-message-go}/go.mod | 0 .../{send-message => send-message-go}/go.sum | 0 .../internal/plugin/plugin.go | 80 ++++++++++--- .../internal/plugin/plugin_test.go | 108 ++++++++++++++++++ .../manifests/crds.yaml | 0 .../manifests/rbac.yaml | 0 .../plugin.yaml | 0 .../smoke/README.md | 2 +- .../smoke/render-plugin-dir.sh | 0 .../smoke/smoke-test.sh | 0 12 files changed, 174 insertions(+), 22 deletions(-) rename extended/plugins/{send-message => send-message-go}/Dockerfile (54%) rename extended/plugins/{send-message/cmd/send-message-step-plugin => send-message-go/cmd/send-message-plugin}/main.go (100%) rename extended/plugins/{send-message => send-message-go}/go.mod (100%) rename extended/plugins/{send-message => send-message-go}/go.sum (100%) rename extended/plugins/{send-message => send-message-go}/internal/plugin/plugin.go (90%) rename extended/plugins/{send-message => send-message-go}/internal/plugin/plugin_test.go (79%) rename extended/plugins/{send-message => send-message-go}/manifests/crds.yaml (100%) rename extended/plugins/{send-message => send-message-go}/manifests/rbac.yaml (100%) rename extended/plugins/{send-message => send-message-go}/plugin.yaml (100%) rename extended/plugins/{send-message => send-message-go}/smoke/README.md (89%) rename extended/plugins/{send-message => send-message-go}/smoke/render-plugin-dir.sh (100%) rename extended/plugins/{send-message => send-message-go}/smoke/smoke-test.sh (100%) diff --git a/extended/plugins/send-message/Dockerfile b/extended/plugins/send-message-go/Dockerfile similarity index 54% rename from extended/plugins/send-message/Dockerfile rename to extended/plugins/send-message-go/Dockerfile index 34c8336648..a5fa74294c 100644 --- a/extended/plugins/send-message/Dockerfile +++ b/extended/plugins/send-message-go/Dockerfile @@ -8,12 +8,12 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -trimpath -ldflags='-s -w' \ - -o /out/send-message-step-plugin ./cmd/send-message-step-plugin + -o /out/send-message-plugin ./cmd/send-message-plugin FROM alpine:3.22 RUN apk add --no-cache ca-certificates -COPY --from=build /out/send-message-step-plugin /usr/local/bin/send-message-step-plugin +COPY --from=build /out/send-message-plugin /usr/local/bin/send-message-plugin -ENTRYPOINT ["/usr/local/bin/send-message-step-plugin"] +ENTRYPOINT ["/usr/local/bin/send-message-plugin"] diff --git a/extended/plugins/send-message/cmd/send-message-step-plugin/main.go b/extended/plugins/send-message-go/cmd/send-message-plugin/main.go similarity index 100% rename from extended/plugins/send-message/cmd/send-message-step-plugin/main.go rename to extended/plugins/send-message-go/cmd/send-message-plugin/main.go diff --git a/extended/plugins/send-message/go.mod b/extended/plugins/send-message-go/go.mod similarity index 100% rename from extended/plugins/send-message/go.mod rename to extended/plugins/send-message-go/go.mod diff --git a/extended/plugins/send-message/go.sum b/extended/plugins/send-message-go/go.sum similarity index 100% rename from extended/plugins/send-message/go.sum rename to extended/plugins/send-message-go/go.sum diff --git a/extended/plugins/send-message/internal/plugin/plugin.go b/extended/plugins/send-message-go/internal/plugin/plugin.go similarity index 90% rename from extended/plugins/send-message/internal/plugin/plugin.go rename to extended/plugins/send-message-go/internal/plugin/plugin.go index 61654d3807..65741ae948 100644 --- a/extended/plugins/send-message/internal/plugin/plugin.go +++ b/extended/plugins/send-message-go/internal/plugin/plugin.go @@ -130,6 +130,14 @@ type SlackPostMessageResponse struct { TS string `json:"ts,omitempty"` } +type requestError struct { + message string +} + +func (e requestError) Error() string { + return e.message +} + func NewServer(opts Options) *Server { logger := opts.Logger if logger == nil { @@ -230,6 +238,9 @@ func (s *Server) execute( channel, secretNS, err := s.lookupChannel(ctx, req) if err != nil { + if isRequestError(err) { + return erroredResponse(err) + } return failedResponse(err) } @@ -244,6 +255,9 @@ func (s *Server) execute( payload, outputThreadTS, err := buildSlackPayload(req.Step.Config, channel) if err != nil { + if isRequestError(err) { + return erroredResponse(err) + } return failedResponse(err) } @@ -281,7 +295,9 @@ func (s *Server) lookupChannel( switch ref.Kind { case "MessageChannel": if req.Context.Project == "" { - return nil, "", errors.New("step context project is required for MessageChannel") + return nil, "", requestError{ + message: "step context project is required for MessageChannel", + } } channel, err := s.kubeClient.GetMessageChannel(ctx, req.Context.Project, ref.Name) if err != nil { @@ -295,7 +311,9 @@ func (s *Server) lookupChannel( } return channel, s.systemResourcesNamespace, nil default: - return nil, "", fmt.Errorf("unsupported channel kind %q", ref.Kind) + return nil, "", requestError{ + message: fmt.Sprintf("unsupported channel kind %q", ref.Kind), + } } } @@ -311,11 +329,13 @@ func buildSlackPayload( channelID = strings.TrimSpace(channel.SlackChannelID) } if channelID == "" { - return nil, "", fmt.Errorf( - "%s %q does not define spec.slack.channelID and config.slack.channelID is empty", - channel.ResourceKind, - channel.ResourceName, - ) + return nil, "", requestError{ + message: fmt.Sprintf( + "%s %q does not define spec.slack.channelID and config.slack.channelID is empty", + channel.ResourceKind, + channel.ResourceName, + ), + } } payload = map[string]any{ "channel": channelID, @@ -328,11 +348,15 @@ func buildSlackPayload( return payload, threadTS, nil case "json": if err := json.Unmarshal([]byte(cfg.Message), &payload); err != nil { - return nil, "", fmt.Errorf("error decoding JSON Slack payload: %w", err) + return nil, "", requestError{ + message: fmt.Sprintf("error decoding JSON Slack payload: %v", err), + } } case "yaml": if err := yaml.Unmarshal([]byte(cfg.Message), &payload); err != nil { - return nil, "", fmt.Errorf("error decoding YAML Slack payload: %w", err) + return nil, "", requestError{ + message: fmt.Sprintf("error decoding YAML Slack payload: %v", err), + } } case "xml": var err error @@ -341,20 +365,24 @@ func buildSlackPayload( return nil, "", err } default: - return nil, "", fmt.Errorf("unsupported encodingType %q", cfg.EncodingType) + return nil, "", requestError{ + message: fmt.Sprintf("unsupported encodingType %q", cfg.EncodingType), + } } if payload == nil { - return nil, "", errors.New("Slack payload must decode to an object") + return nil, "", requestError{message: "Slack payload must decode to an object"} } channelID := strings.TrimSpace(channel.SlackChannelID) if channelID == "" { - return nil, "", fmt.Errorf( - "%s %q does not define spec.slack.channelID", - channel.ResourceKind, - channel.ResourceName, - ) + return nil, "", requestError{ + message: fmt.Sprintf( + "%s %q does not define spec.slack.channelID", + channel.ResourceKind, + channel.ResourceName, + ), + } } if _, ok := payload["channel"]; !ok { payload["channel"] = channelID @@ -373,12 +401,14 @@ type xmlPayloadNode struct { func decodeXMLSlackPayload(message string) (map[string]any, error) { var root xmlPayloadNode if err := xml.Unmarshal([]byte(message), &root); err != nil { - return nil, fmt.Errorf("error decoding XML Slack payload: %w", err) + return nil, requestError{ + message: fmt.Sprintf("error decoding XML Slack payload: %v", err), + } } payload := xmlNodeToObject(root) if payload == nil { - return nil, errors.New("Slack payload must decode to an object") + return nil, requestError{message: "Slack payload must decode to an object"} } return payload, nil } @@ -441,6 +471,20 @@ func failedResponse(err error) StepExecuteResponse { } } +func erroredResponse(err error) StepExecuteResponse { + return StepExecuteResponse{ + Status: "Errored", + Message: err.Error(), + Error: err.Error(), + Terminal: true, + } +} + +func isRequestError(err error) bool { + var target requestError + return errors.As(err, &target) +} + func (s *Server) authorize(authHeaderValue string) error { expected, err := os.ReadFile(s.expectedTokenPath) if err != nil { diff --git a/extended/plugins/send-message/internal/plugin/plugin_test.go b/extended/plugins/send-message-go/internal/plugin/plugin_test.go similarity index 79% rename from extended/plugins/send-message/internal/plugin/plugin_test.go rename to extended/plugins/send-message-go/internal/plugin/plugin_test.go index 751ed545cc..1b1c3de2c6 100644 --- a/extended/plugins/send-message/internal/plugin/plugin_test.go +++ b/extended/plugins/send-message-go/internal/plugin/plugin_test.go @@ -33,6 +33,44 @@ func TestHandlerRejectsMissingBearerToken(t *testing.T) { require.Equal(t, "Errored", resp.Status) } +func TestHandlerRejectsInvalidBearerToken(t *testing.T) { + srv := newTestServer(t, nil, nil) + req := httptest.NewRequest( + http.MethodPost, + stepExecutePath, + bytes.NewReader(mustJSON(t, minimalRequest())), + ) + req.Header.Set(authHeader, bearerPrefix+"wrong-token") + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + var resp StepExecuteResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "Errored", resp.Status) + require.Equal(t, "invalid bearer token", resp.Error) +} + +func TestHandlerRejectsInvalidRequestBody(t *testing.T) { + srv := newTestServer(t, nil, nil) + req := httptest.NewRequest( + http.MethodPost, + stepExecutePath, + bytes.NewBufferString("{not-json"), + ) + req.Header.Set(authHeader, bearerPrefix+"expected-token") + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + var resp StepExecuteResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "Errored", resp.Status) + require.Equal(t, "invalid request body", resp.Message) +} + func TestExecuteUsesNamespacedMessageChannel(t *testing.T) { kube := &fakeKubernetesClient{ messageChannels: map[string]*ChannelResource{ @@ -206,6 +244,49 @@ func TestExecuteSupportsJSONEncoding(t *testing.T) { ) } +func TestExecuteSupportsYAMLEncoding(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + slack := &fakeSlackClient{ + response: &SlackPostMessageResponse{ + OK: true, + TS: "1712345678.000410", + }, + } + srv := newTestServer(t, kube, slack) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.EncodingType = "yaml" + req.Step.Config.Message = "text: rich\nblocks:\n- type: section\n" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Succeeded", resp.Status) + require.Equal( + t, + map[string]any{ + "channel": "C123", + "text": "rich", + "blocks": []any{ + map[string]any{"type": "section"}, + }, + }, + slack.lastPayload, + ) +} + func TestExecuteEncodedPayloadIgnoresSlackConfigOverrides(t *testing.T) { kube := &fakeKubernetesClient{ messageChannels: map[string]*ChannelResource{ @@ -358,6 +439,33 @@ func TestExecuteSupportsXMLEncoding(t *testing.T) { ) } +func TestExecuteReturnsErroredForBadYAML(t *testing.T) { + kube := &fakeKubernetesClient{ + messageChannels: map[string]*ChannelResource{ + "demo/send": { + SecretName: "slack-token", + SlackChannelID: "C123", + ResourceKind: "MessageChannel", + ResourceName: "send", + ResourceNS: "demo", + }, + }, + secrets: map[string]map[string]string{ + "demo/slack-token": {"apiKey": "xoxb-demo"}, + }, + } + srv := newTestServer(t, kube, &fakeSlackClient{}) + + req := minimalRequest() + req.Context.Project = "demo" + req.Step.Config.EncodingType = "yaml" + req.Step.Config.Message = "text: [oops" + resp := executeRequest(t, srv, req) + + require.Equal(t, "Errored", resp.Status) + require.Contains(t, resp.Error, "error decoding YAML Slack payload") +} + type fakeKubernetesClient struct { messageChannels map[string]*ChannelResource clusterMessageChannels map[string]*ChannelResource diff --git a/extended/plugins/send-message/manifests/crds.yaml b/extended/plugins/send-message-go/manifests/crds.yaml similarity index 100% rename from extended/plugins/send-message/manifests/crds.yaml rename to extended/plugins/send-message-go/manifests/crds.yaml diff --git a/extended/plugins/send-message/manifests/rbac.yaml b/extended/plugins/send-message-go/manifests/rbac.yaml similarity index 100% rename from extended/plugins/send-message/manifests/rbac.yaml rename to extended/plugins/send-message-go/manifests/rbac.yaml diff --git a/extended/plugins/send-message/plugin.yaml b/extended/plugins/send-message-go/plugin.yaml similarity index 100% rename from extended/plugins/send-message/plugin.yaml rename to extended/plugins/send-message-go/plugin.yaml diff --git a/extended/plugins/send-message/smoke/README.md b/extended/plugins/send-message-go/smoke/README.md similarity index 89% rename from extended/plugins/send-message/smoke/README.md rename to extended/plugins/send-message-go/smoke/README.md index 1bf15b5865..a93e84ebbd 100644 --- a/extended/plugins/send-message/smoke/README.md +++ b/extended/plugins/send-message-go/smoke/README.md @@ -1,7 +1,7 @@ # Local Smoke Notes - Primary smoke entrypoint: - - `extended/plugins/send-message/smoke/smoke-test.sh` + - `extended/plugins/send-message-go/smoke/smoke-test.sh` - The script assumes a working Kargo cluster and CLI are already available. - Required env: - `SEND_MESSAGE_SMOKE_PROJECT` diff --git a/extended/plugins/send-message/smoke/render-plugin-dir.sh b/extended/plugins/send-message-go/smoke/render-plugin-dir.sh similarity index 100% rename from extended/plugins/send-message/smoke/render-plugin-dir.sh rename to extended/plugins/send-message-go/smoke/render-plugin-dir.sh diff --git a/extended/plugins/send-message/smoke/smoke-test.sh b/extended/plugins/send-message-go/smoke/smoke-test.sh similarity index 100% rename from extended/plugins/send-message/smoke/smoke-test.sh rename to extended/plugins/send-message-go/smoke/smoke-test.sh From 19d98a422685ab67d6075c3c6721d3e9b5a6a614 Mon Sep 17 00:00:00 2001 From: Seth Nickell Date: Thu, 9 Apr 2026 23:44:11 -1000 Subject: [PATCH 3/4] test: clarify go send-message smoke output Signed-off-by: Seth Nickell --- .../send-message-go/smoke/smoke-test.sh | 4 +-- extended/tests/e2e_stepplugins.sh | 35 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/extended/plugins/send-message-go/smoke/smoke-test.sh b/extended/plugins/send-message-go/smoke/smoke-test.sh index 772585bc98..59564cbcef 100755 --- a/extended/plugins/send-message-go/smoke/smoke-test.sh +++ b/extended/plugins/send-message-go/smoke/smoke-test.sh @@ -176,8 +176,8 @@ log_info "Create send-message MessageChannel" "$KUBECTL_BIN" apply -f "/tmp/${CHANNEL_NAME}.yaml" log_pass "Create send-message MessageChannel" -STAGE_NAME="smsg-stepplugin-$(date +%s)" -message_text="send-message stepplugin smoke ${STAGE_NAME}" +STAGE_NAME="smsg-go-$(date +%s)" +message_text="send-message GO implementation smoke ${STAGE_NAME}" cat > "/tmp/${STAGE_NAME}.yaml" < Date: Thu, 9 Apr 2026 23:56:01 -1000 Subject: [PATCH 4/4] fix: satisfy go lint for send-message plugin Signed-off-by: Seth Nickell --- .../cmd/send-message-plugin/main.go | 11 ++++++- .../send-message-go/internal/plugin/plugin.go | 32 ++++++++++++------- .../internal/plugin/plugin_test.go | 19 ++++++++--- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/extended/plugins/send-message-go/cmd/send-message-plugin/main.go b/extended/plugins/send-message-go/cmd/send-message-plugin/main.go index b25f2c0280..9f8bfbeb78 100644 --- a/extended/plugins/send-message-go/cmd/send-message-plugin/main.go +++ b/extended/plugins/send-message-go/cmd/send-message-plugin/main.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "os" + "time" "github.com/code-dot-org/kargo-send-message-step-plugin/internal/plugin" ) @@ -15,8 +16,16 @@ func main() { }) addr := ":9765" + httpServer := &http.Server{ + Addr: addr, + Handler: server.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } logger.Printf("send-message step plugin listening on %s", addr) - if err := http.ListenAndServe(addr, server.Handler()); err != nil { + if err := httpServer.ListenAndServe(); err != nil { logger.Fatalf("send-message step plugin server exited: %v", err) } } diff --git a/extended/plugins/send-message-go/internal/plugin/plugin.go b/extended/plugins/send-message-go/internal/plugin/plugin.go index 65741ae948..e2f84db96f 100644 --- a/extended/plugins/send-message-go/internal/plugin/plugin.go +++ b/extended/plugins/send-message-go/internal/plugin/plugin.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "strings" + "time" "gopkg.in/yaml.v3" ) @@ -24,13 +25,15 @@ const ( authHeader = "Authorization" bearerPrefix = "Bearer " - authTokenPath = "/var/run/kargo/token" - serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" - serviceAccountToken = "token" - serviceAccountCA = "ca.crt" - defaultSystemNS = "kargo-system-resources" - defaultSlackAPIBase = "https://slack.com/api" - defaultKubernetesURL = "https://kubernetes.default.svc" + //nolint:gosec // Fixed in-cluster mount path, not a hardcoded credential. + defaultExpectedAuthPath = "/var/run/kargo/token" + serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" + serviceAccountToken = "token" + serviceAccountCA = "ca.crt" + defaultSystemNS = "kargo-system-resources" + defaultSlackAPIBase = "https://slack.com/api" + defaultKubernetesURL = "https://kubernetes.default.svc" + defaultHTTPTimeout = 30 * time.Second ) type Options struct { @@ -146,7 +149,7 @@ func NewServer(opts Options) *Server { expectedTokenPath := opts.ExpectedTokenPath if expectedTokenPath == "" { - expectedTokenPath = authTokenPath + expectedTokenPath = defaultExpectedAuthPath } systemNS := opts.SystemResourcesNamespace @@ -250,7 +253,7 @@ func (s *Server) execute( } token := secret["apiKey"] if token == "" { - return failedResponse(errors.New("Slack Secret is missing key \"apiKey\"")) + return failedResponse(errors.New("slack Secret is missing key \"apiKey\"")) } payload, outputThreadTS, err := buildSlackPayload(req.Step.Config, channel) @@ -271,7 +274,7 @@ func (s *Server) execute( return failedResponse(fmt.Errorf("error posting Slack message: %w", err)) } if !slackResp.OK { - return failedResponse(fmt.Errorf("Slack API error: %s", slackResp.Error)) + return failedResponse(fmt.Errorf("slack API error: %s", slackResp.Error)) } if outputThreadTS == "" { outputThreadTS = slackResp.TS @@ -486,6 +489,7 @@ func isRequestError(err error) bool { } func (s *Server) authorize(authHeaderValue string) error { + //nolint:gosec // Path is plugin configuration or the fixed auth mount path. expected, err := os.ReadFile(s.expectedTokenPath) if err != nil { return fmt.Errorf("error reading auth token: %w", err) @@ -533,7 +537,9 @@ func (RealSlackClient) PostMessage( req.Header.Set("Authorization", bearerPrefix+token) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + client := &http.Client{Timeout: defaultHTTPTimeout} + //nolint:gosec // The request target is the configured Slack API base URL. + resp, err := client.Do(req) if err != nil { return nil, err } @@ -596,6 +602,7 @@ func NewInClusterKubernetesClient() (KubernetesClient, error) { return &inClusterKubernetesClient{ baseURL: baseURL, client: &http.Client{ + Timeout: defaultHTTPTimeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, @@ -727,6 +734,7 @@ func (c *inClusterKubernetesClient) getJSON( } req.Header.Set(authHeader, bearerPrefix+c.token) + //nolint:gosec // The request target is the in-cluster Kubernetes API. resp, err := c.client.Do(req) if err != nil { return err @@ -738,7 +746,7 @@ func (c *inClusterKubernetesClient) getJSON( } if resp.StatusCode >= http.StatusBadRequest { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("Kubernetes API error %s: %s", resp.Status, strings.TrimSpace(string(body))) + return fmt.Errorf("kubernetes API error %s: %s", resp.Status, strings.TrimSpace(string(body))) } return json.NewDecoder(resp.Body).Decode(out) } diff --git a/extended/plugins/send-message-go/internal/plugin/plugin_test.go b/extended/plugins/send-message-go/internal/plugin/plugin_test.go index 1b1c3de2c6..2587140c7e 100644 --- a/extended/plugins/send-message-go/internal/plugin/plugin_test.go +++ b/extended/plugins/send-message-go/internal/plugin/plugin_test.go @@ -103,7 +103,7 @@ func TestExecuteUsesNamespacedMessageChannel(t *testing.T) { resp := executeRequest(t, srv, req) require.Equal(t, "Succeeded", resp.Status) - require.Equal(t, "1712345678.000100", resp.Output["slack"].(map[string]any)["threadTS"]) + require.Equal(t, "1712345678.000100", slackThreadTS(t, resp)) require.Equal( t, map[string]any{ @@ -197,7 +197,7 @@ func TestExecuteHonorsSlackOverrides(t *testing.T) { require.Equal( t, "1700000000.000001", - resp.Output["slack"].(map[string]any)["threadTS"], + slackThreadTS(t, resp), ) } @@ -331,7 +331,7 @@ func TestExecuteEncodedPayloadIgnoresSlackConfigOverrides(t *testing.T) { require.Equal( t, "1700000000.000002", - resp.Output["slack"].(map[string]any)["threadTS"], + slackThreadTS(t, resp), ) } @@ -435,7 +435,7 @@ func TestExecuteSupportsXMLEncoding(t *testing.T) { require.Equal( t, "1700000000.000003", - resp.Output["slack"].(map[string]any)["threadTS"], + slackThreadTS(t, resp), ) } @@ -575,6 +575,17 @@ func executeRequest( return resp } +func slackThreadTS(t *testing.T, resp StepExecuteResponse) string { + t.Helper() + + require.Contains(t, resp.Output, "slack") + slackOutput, ok := resp.Output["slack"].(map[string]any) + require.True(t, ok) + threadTS, ok := slackOutput["threadTS"].(string) + require.True(t, ok) + return threadTS +} + func minimalRequest() StepExecuteRequest { return StepExecuteRequest{ Step: Step{