diff --git a/extended/docs/proposals/0004-send-message-step-plugin/checklist.ruby.md b/extended/docs/proposals/0004-send-message-step-plugin/checklist.ruby.md new file mode 100644 index 0000000000..60db08e308 --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/checklist.ruby.md @@ -0,0 +1,76 @@ +# 0004 Ruby Checklist + +## Baseline + +- [x] Plugin work lives under `extended/plugins/send-message-ruby/`. +- [x] The subtree stands alone. +- [x] No committed Slack credential appears anywhere. +- [x] The plugin owns Kubernetes reads and Slack calls. + +## Phase 0: pin the contract + +- [x] Re-read `proposal.md`. +- [x] Re-read `spec.md`. +- [x] Re-read the `send-message` slice in proposal `0002`. +- [x] Keep scope to Slack only. + +## Phase 1: create the tiny repo + +- [x] Create `extended/plugins/send-message-ruby/`. +- [x] Add runtime source. +- [x] Add image build. +- [x] Add `plugin.yaml`. +- [x] Add CRD manifests. +- [x] Add RBAC manifests. +- [x] Add smoke assets. + +## Phase 2: implement the runtime + +- [x] Implement `POST /api/v1/step.execute`. +- [x] Enforce bearer auth from `/var/run/kargo/token`. +- [x] Read `MessageChannel`. +- [x] Read `ClusterMessageChannel`. +- [x] Read referenced `Secret`. +- [x] Send plaintext Slack payloads. +- [x] Send encoded Slack payloads. +- [x] Return `slack.threadTS`. + +## Phase 3: test the contract + +- [x] Add auth tests. +- [x] Add channel lookup tests. +- [x] Add Secret lookup tests. +- [x] Add plaintext payload tests. +- [x] Add encoded payload tests. +- [x] Add XML decode tests. +- [x] Add Slack failure tests. + +## Phase 4: smoke + +- [x] Add plugin-owned `smoke/smoke_test.rb`. +- [x] Keep smoke orchestration in Ruby, not shell. +- [x] Build the image. +- [x] Load it into kind. +- [x] Install CRDs and RBAC. +- [x] Install StepPlugin `ConfigMap`. +- [x] Create local-only test Secret. +- [x] Create test `MessageChannel`. +- [x] Run a `Stage` with `uses: send-message`. +- [x] Assert `Succeeded`. +- [x] Assert non-empty `slack.threadTS`. + +## Phase 5: mandatory radical simplification pass 1 + +- [x] Ask "can I replace this gem with stdlib and make the repo smaller?" +- [x] Ask "can I collapse this into fewer files and still keep it readable?" +- [x] Ask "am I carrying Ruby framework habits into a tiny sidecar?" +- [x] Delete anything that fails those checks. + +## Phase 6: mandatory radical simplification pass 2 + +- [x] Re-run tests and smoke from a green tree. +- [x] Ask "can I make this radically simpler?" +- [x] Remove wrappers that only save a few obvious lines. +- [x] Remove folder structure that makes the plugin look more official than + small. +- [x] Stop only when the repo still reads like a small third-party plugin. diff --git a/extended/docs/proposals/0004-send-message-step-plugin/plan.ruby.md b/extended/docs/proposals/0004-send-message-step-plugin/plan.ruby.md new file mode 100644 index 0000000000..9065dcde63 --- /dev/null +++ b/extended/docs/proposals/0004-send-message-step-plugin/plan.ruby.md @@ -0,0 +1,93 @@ +# 0004 Ruby Plan + +## Goal + +- Show the same `send-message` plugin contract in a form that is tiny, + readable, and comfortable for a scripting-language user. +- Keep all plugin-owned code under `extended/plugins/send-message-ruby/`. +- Keep the subtree standalone enough to behave like its own Git repo. +- Match the same Slack-only contract as `spec.md`. + +## Design Rule + +- Prefer stdlib unless a gem removes real complexity. +- Prefer direct HTTPS to Kubernetes and Slack if that keeps the repo smaller. +- Do not chase a Ruby-flavored framework stack. +- If a gem exists only to save a small amount of obvious code, do not use it. + +## Runtime Shape + +- Runtime: + - Ruby +- Suggested layout: + - `extended/plugins/send-message-ruby/` + - `server.rb` + - `smoke/smoke_test.rb` + - `Gemfile` + - `Dockerfile` + - `plugin.yaml` + - `manifests/` + - `smoke/` +- Suggested server shape: + - one small HTTP server + - one request parser + - one Kubernetes reader + - one Slack sender + +## Minimal Dependency Target + +- Strong preference: + - stdlib `webrick` + - stdlib `json` + - stdlib `psych` + - stdlib `rexml` + - stdlib `net/http` +- Avoid: + - Rails-adjacent stacks + - Sinatra unless it clearly removes code + - large Kubernetes or Slack client wrappers + +## Behavior + +- Implement `POST /api/v1/step.execute`. +- Enforce bearer auth from `/var/run/kargo/token`. +- Read `MessageChannel` and `ClusterMessageChannel` directly from Kubernetes. +- Read referenced `Secret` directly from Kubernetes. +- Send Slack messages directly or through a tiny client if one clearly helps. +- Support: + - plaintext + - `json` + - `yaml` + - `xml` +- Match the response contract in `spec.md`. + +## Tests + +- Keep tests inside the subtree. +- Prefer a small test file and direct fixtures. +- Cover: + - auth + - channel lookup + - Secret lookup + - plaintext payload shaping + - encoded payload shaping + - XML decode shape + - Slack error handling + +## Smoke + +- Own `smoke/smoke_test.rb` inside the subtree. +- Assume Kargo already exists. +- Use Ruby for the smoke orchestration too. +- Build image, install manifests, create Secret and channel, run a Stage, + assert `Succeeded`, assert non-empty `slack.threadTS`. + +## Mandatory Simplify Passes + +- Simplify pass 1, before full smoke: + - ask "can I replace this gem with stdlib and make the repo smaller?" + - ask "can I collapse this into fewer files?" +- Simplify pass 2, after green: + - ask "can I make this radically simpler?" + - remove any Ruby structure that exists to feel proper rather than to keep + the plugin clear diff --git a/extended/plugins/send-message-ruby/Dockerfile b/extended/plugins/send-message-ruby/Dockerfile new file mode 100644 index 0000000000..5c45c8a015 --- /dev/null +++ b/extended/plugins/send-message-ruby/Dockerfile @@ -0,0 +1,14 @@ +FROM ruby:3.4.7-alpine3.22 + +RUN gem install --no-document webrick + +RUN addgroup -S plugin && adduser -S -G plugin -u 65532 plugin + +WORKDIR /app + +COPY send_message_plugin.rb /app/send_message_plugin.rb +COPY lib /app/lib + +USER 65532:65532 + +ENTRYPOINT ["ruby", "/app/send_message_plugin.rb"] diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin.rb new file mode 100644 index 0000000000..a4d2e6792d --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin.rb @@ -0,0 +1,39 @@ +require "json" +require "logger" +require "net/http" +require "openssl" +require "psych" +require "rexml/document" +require "uri" +require "webrick" + +require_relative "send_message_plugin/errors" +require_relative "send_message_plugin/kubernetes_client" +require_relative "send_message_plugin/payload_builder" +require_relative "send_message_plugin/slack_client" +require_relative "send_message_plugin/app" + +module SendMessagePlugin + STEP_EXECUTE_PATH = "/api/v1/step.execute" + AUTH_HEADER = "Authorization" + BEARER_PREFIX = "Bearer " + DEFAULT_TOKEN_PATH = "/var/run/kargo/token" + DEFAULT_SYSTEM_RESOURCES_NAMESPACE = "kargo-system-resources" + DEFAULT_SLACK_API_BASE_URL = "https://slack.com/api" + + def self.run! + app = App.new + server = WEBrick::HTTPServer.new( + BindAddress: "0.0.0.0", + Port: Integer(ENV.fetch("PORT", "9765")), + AccessLog: [], + Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN) + ) + server.mount_proc(STEP_EXECUTE_PATH) do |req, res| + app.serve(req, res) + end + trap("INT") { server.shutdown } + trap("TERM") { server.shutdown } + server.start + end +end diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin/app.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin/app.rb new file mode 100644 index 0000000000..8138d11f55 --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin/app.rb @@ -0,0 +1,175 @@ +module SendMessagePlugin + class App + def initialize( + token_path: DEFAULT_TOKEN_PATH, + system_resources_namespace: ENV.fetch( + "SYSTEM_RESOURCES_NAMESPACE", + DEFAULT_SYSTEM_RESOURCES_NAMESPACE + ), + slack_api_base_url: ENV.fetch( + "SLACK_API_BASE_URL", + DEFAULT_SLACK_API_BASE_URL + ), + kubernetes_client: nil, + slack_client: nil, + logger: nil + ) + @token_path = token_path + @system_resources_namespace = system_resources_namespace + @slack_api_base_url = slack_api_base_url + @kubernetes_client = kubernetes_client || KubernetesClient.new + @slack_client = slack_client || SlackClient.new + @logger = logger || Logger.new($stderr, level: Logger::WARN) + end + + def call(method:, path:, headers:, body:) + return [404, nil] if path != STEP_EXECUTE_PATH + return [405, nil] if method != "POST" + + authorize!(headers[AUTH_HEADER] || headers[AUTH_HEADER.downcase]) + request = JSON.parse(body) + [200, execute(request)] + rescue AuthError => error + [403, errored(error.message, error.message)] + rescue JSON::ParserError => error + [400, errored("invalid request body", error.message)] + rescue RequestError => error + [200, errored(error.message, error.message)] + rescue ExecutionError => error + [200, failed(error.message)] + rescue StandardError => error + [200, failed(error.message)] + end + + def serve(req, res) + headers = req.header.each_with_object({}) do |(key, value), memo| + memo[key] = Array(value).first + end + status, response = call( + method: req.request_method, + path: req.path, + headers: headers, + body: req.body.to_s + ) + if response.nil? + res.status = status + return + end + + res.status = status + res["Content-Type"] = "application/json" + res.body = JSON.generate(response) + rescue StandardError => error + @logger.error(error.full_message) + res.status = 500 + res["Content-Type"] = "application/json" + res.body = JSON.generate(failed(error.message)) + end + + private + + def authorize!(header_value) + expected = File.read(@token_path).strip + raise AuthError, "missing bearer token" unless header_value&.start_with?(BEARER_PREFIX) + + received = header_value.delete_prefix(BEARER_PREFIX).strip + raise AuthError, "invalid bearer token" if received != expected + rescue Errno::ENOENT => error + raise AuthError, "error reading auth token: #{error.message}" + end + + def execute(request) + step = fetch_hash(request, "step", "step") + config = fetch_hash(step, "config", "step.config") + context = optional_hash(request["context"], "context") + raise RequestError, "unsupported step kind #{step["kind"].inspect}" unless step["kind"].to_s == "send-message" + raise RequestError, "step.config.message is required" if config["message"].nil? + + channel_ref = fetch_hash(config, "channel", "step.config.channel") + channel_kind = channel_ref["kind"].to_s.strip + channel_name = channel_ref["name"].to_s.strip + raise RequestError, "step.config.channel.kind is required" if channel_kind.empty? + raise RequestError, "step.config.channel.name is required" if channel_name.empty? + + channel, secret_namespace = lookup_channel( + project: context["project"].to_s.strip, + channel_kind: channel_kind, + channel_name: channel_name + ) + secret = @kubernetes_client.get_secret(secret_namespace, channel.fetch("secret_name")) + token = secret["apiKey"].to_s + raise RequestError, 'Slack Secret is missing key "apiKey"' if token.empty? + + payload, output_thread_ts = PayloadBuilder.new( + config: config, + channel: channel + ).build + + slack_response = @slack_client.post_message( + api_base_url: @slack_api_base_url, + token: token, + payload: payload + ) + unless slack_response["ok"] + detail = slack_response["error"].to_s + detail = "unknown_error" if detail.empty? + raise ExecutionError, "Slack API error: #{detail}" + end + + output_thread_ts = slack_response["ts"].to_s if output_thread_ts.to_s.empty? + { + "status" => "Succeeded", + "output" => { + "slack" => { + "threadTS" => output_thread_ts + } + } + } + end + + def lookup_channel(project:, channel_kind:, channel_name:) + case channel_kind + when "MessageChannel" + raise RequestError, "step context project is required for MessageChannel" if project.empty? + + [@kubernetes_client.get_message_channel(project, channel_name), project] + when "ClusterMessageChannel" + [@kubernetes_client.get_cluster_message_channel(channel_name), @system_resources_namespace] + else + raise RequestError, "unsupported channel kind #{channel_kind.inspect}" + end + end + + def fetch_hash(object, key, path) + value = object[key] + optional_hash(value, path).tap do |hash| + raise RequestError, "#{path} is required" if hash.empty? && !value.is_a?(Hash) + end + end + + def optional_hash(value, path) + return {} if value.nil? + return value if value.is_a?(Hash) + + raise RequestError, "#{path} must be an object" + end + + def errored(message, error) + { + "status" => "Errored", + "message" => message, + "error" => error, + "terminal" => true + } + end + + def failed(message) + { + "status" => "Failed", + "message" => message, + "error" => message, + "terminal" => true + } + end + end +end diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin/errors.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin/errors.rb new file mode 100644 index 0000000000..393ff1a2cf --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin/errors.rb @@ -0,0 +1,10 @@ +module SendMessagePlugin + class RequestError < StandardError + end + + class AuthError < RequestError + end + + class ExecutionError < StandardError + end +end diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin/kubernetes_client.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin/kubernetes_client.rb new file mode 100644 index 0000000000..58a358f31e --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin/kubernetes_client.rb @@ -0,0 +1,93 @@ +module SendMessagePlugin + class KubernetesClient + DEFAULT_KUBERNETES_HOST = "kubernetes.default.svc" + DEFAULT_KUBERNETES_PORT = "443" + DEFAULT_SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount" + + def initialize( + host: ENV.fetch("KUBERNETES_SERVICE_HOST", DEFAULT_KUBERNETES_HOST), + port: ENV.fetch("KUBERNETES_SERVICE_PORT_HTTPS", DEFAULT_KUBERNETES_PORT), + token_path: File.join(DEFAULT_SERVICE_ACCOUNT_DIR, "token"), + ca_path: File.join(DEFAULT_SERVICE_ACCOUNT_DIR, "ca.crt") + ) + @base_uri = URI("https://#{host}:#{port}") + @token_path = token_path + @ca_path = ca_path + end + + def get_message_channel(namespace, name) + payload = get_json("/apis/ee.kargo.akuity.io/v1alpha1/namespaces/#{namespace}/messagechannels/#{name}") + build_channel(payload) + rescue RequestError + raise + rescue StandardError => error + raise ExecutionError, "error getting MessageChannel: #{error.message}" + end + + def get_cluster_message_channel(name) + payload = get_json("/apis/ee.kargo.akuity.io/v1alpha1/clustermessagechannels/#{name}") + build_channel(payload) + rescue RequestError + raise + rescue StandardError => error + raise ExecutionError, "error getting ClusterMessageChannel: #{error.message}" + end + + def get_secret(namespace, name) + payload = get_json("/api/v1/namespaces/#{namespace}/secrets/#{name}") + decode_secret(payload) + rescue StandardError => error + raise ExecutionError, "error getting Slack Secret: #{error.message}" + end + + private + + def get_json(path) + request = Net::HTTP::Get.new(path) + request[AUTH_HEADER] = "#{BEARER_PREFIX}#{File.read(@token_path).strip}" + + response = http_client.start do |http| + http.request(request) + end + + parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body) + return parsed if response.code.to_i < 400 + + detail = parsed["message"].to_s + detail = response.message if detail.empty? + raise ExecutionError, detail.empty? ? "HTTP #{response.code}" : detail + rescue Errno::ENOENT => error + raise ExecutionError, error.message + end + + def http_client + @http_client ||= Net::HTTP.new(@base_uri.host, @base_uri.port).tap do |http| + http.use_ssl = true + http.ca_file = @ca_path if File.exist?(@ca_path) + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end + + def build_channel(payload) + { + "secret_name" => payload.dig("spec", "secretRef", "name").to_s.strip, + "slack_channel_id" => payload.dig("spec", "slack", "channelID").to_s.strip, + "resource_kind" => payload["kind"].to_s, + "resource_name" => payload.dig("metadata", "name").to_s + }.tap do |channel| + if channel["secret_name"].empty? + raise RequestError, %(#{channel["resource_kind"]} "#{channel["resource_name"]}" does not define spec.secretRef.name) + end + end + end + + def decode_secret(payload) + data = payload["data"] + return {} unless data.is_a?(Hash) + + data.each_with_object({}) do |(key, value), decoded| + decoded[key] = value.nil? ? "" : value.unpack1("m0") + end + end + end +end diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin/payload_builder.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin/payload_builder.rb new file mode 100644 index 0000000000..a761dd1fab --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin/payload_builder.rb @@ -0,0 +1,124 @@ +module SendMessagePlugin + class PayloadBuilder + def initialize(config:, channel:) + @config = config + @channel = channel + end + + def build + encoding_type = @config["encodingType"].to_s.strip + return build_plaintext if encoding_type.empty? + + payload = decode_encoded_payload(encoding_type) + raise RequestError, "Slack payload must decode to an object" unless payload.is_a?(Hash) + + if payload["channel"].nil? + raise RequestError, missing_encoded_channel_id_message if @channel["slack_channel_id"].to_s.empty? + + payload["channel"] = @channel["slack_channel_id"] + end + [payload, payload["thread_ts"].to_s] + end + + private + + def build_plaintext + slack_options = optional_hash(@config["slack"], "step.config.slack") + channel_id = slack_options["channelID"].to_s.strip + channel_id = @channel["slack_channel_id"] if channel_id.empty? + raise RequestError, missing_plaintext_channel_id_message if channel_id.to_s.empty? + + payload = { + "channel" => channel_id, + "text" => @config["message"] + } + thread_ts = slack_options["threadTS"].to_s.strip + payload["thread_ts"] = thread_ts unless thread_ts.empty? + [payload, thread_ts] + end + + def decode_encoded_payload(encoding_type) + case encoding_type + when "json" + JSON.parse(@config["message"]) + when "yaml" + Psych.safe_load(@config["message"], aliases: false) + when "xml" + decode_xml_payload(@config["message"]) + else + raise RequestError, "unsupported encodingType #{encoding_type.inspect}" + end + rescue JSON::ParserError, Psych::SyntaxError, REXML::ParseException => error + raise RequestError, "error decoding #{encoding_type.upcase} Slack payload: #{error.message}" + end + + def decode_xml_payload(message) + document = REXML::Document.new(message) + root = document.root + raise RequestError, "error decoding XML Slack payload: empty document" if root.nil? + + xml_root_to_hash(root) + end + + def xml_root_to_hash(element) + payload = {} + element.attributes.each_attribute do |attribute| + payload[attribute.expanded_name] = attribute.value + end + element.elements.each do |child| + append_xml_value(payload, child.name, xml_element_value(child)) + end + + text = direct_text(element) + return payload if text.empty? + + if payload.empty? + payload["text"] = text + else + payload["#text"] = text + end + payload + end + + def xml_element_value(element) + return direct_text(element) if element.attributes.empty? && element.elements.empty? + + payload = {} + element.attributes.each_attribute do |attribute| + payload[attribute.expanded_name] = attribute.value + end + element.elements.each do |child| + append_xml_value(payload, child.name, xml_element_value(child)) + end + text = direct_text(element) + payload["#text"] = text unless text.empty? + payload + end + + def direct_text(element) + element.children.grep(REXML::Text).map(&:value).join.strip + end + + def append_xml_value(payload, key, value) + return payload[key] = value unless payload.key?(key) + + existing = payload[key] + payload[key] = existing.is_a?(Array) ? existing + [value] : [existing, value] + end + + def optional_hash(value, path) + return {} if value.nil? + return value if value.is_a?(Hash) + + raise RequestError, "#{path} must be an object" + end + + def missing_plaintext_channel_id_message + %(#{@channel["resource_kind"]} "#{@channel["resource_name"]}" does not define spec.slack.channelID and config.slack.channelID is empty) + end + + def missing_encoded_channel_id_message + %(#{@channel["resource_kind"]} "#{@channel["resource_name"]}" does not define spec.slack.channelID) + end + end +end diff --git a/extended/plugins/send-message-ruby/lib/send_message_plugin/slack_client.rb b/extended/plugins/send-message-ruby/lib/send_message_plugin/slack_client.rb new file mode 100644 index 0000000000..28e7e958a8 --- /dev/null +++ b/extended/plugins/send-message-ruby/lib/send_message_plugin/slack_client.rb @@ -0,0 +1,22 @@ +module SendMessagePlugin + class SlackClient + def post_message(api_base_url:, token:, payload:) + uri = URI("#{api_base_url.sub(%r{/\z}, "")}/chat.postMessage") + request = Net::HTTP::Post.new(uri) + request[AUTH_HEADER] = "#{BEARER_PREFIX}#{token}" + request["Content-Type"] = "application/json" + request.body = JSON.generate(payload) + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| + http.request(request) + end + + parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body) + if response.code.to_i >= 400 + parsed["ok"] = false + parsed["error"] = response.message if parsed["error"].to_s.empty? + end + parsed + end + end +end diff --git a/extended/plugins/send-message-ruby/manifests/crds.yaml b/extended/plugins/send-message-ruby/manifests/crds.yaml new file mode 100644 index 0000000000..008f9931f8 --- /dev/null +++ b/extended/plugins/send-message-ruby/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-ruby/manifests/rbac.yaml b/extended/plugins/send-message-ruby/manifests/rbac.yaml new file mode 100644 index 0000000000..df10af6b7f --- /dev/null +++ b/extended/plugins/send-message-ruby/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-ruby/plugin.yaml b/extended/plugins/send-message-ruby/plugin.yaml new file mode 100644 index 0000000000..e7f79935a1 --- /dev/null +++ b/extended/plugins/send-message-ruby/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-ruby: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-ruby/send_message_plugin.rb b/extended/plugins/send-message-ruby/send_message_plugin.rb new file mode 100644 index 0000000000..0272b466da --- /dev/null +++ b/extended/plugins/send-message-ruby/send_message_plugin.rb @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require_relative "lib/send_message_plugin" + +SendMessagePlugin.run! if $PROGRAM_NAME == __FILE__ diff --git a/extended/plugins/send-message-ruby/smoke/README.md b/extended/plugins/send-message-ruby/smoke/README.md new file mode 100644 index 0000000000..83cc923729 --- /dev/null +++ b/extended/plugins/send-message-ruby/smoke/README.md @@ -0,0 +1,23 @@ +# Local Smoke Notes + +- Primary smoke entrypoint: + - `extended/plugins/send-message-ruby/smoke/smoke_test.rb` +- 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` + - `SEND_MESSAGE_SMOKE_SECRET_NAME` + - `SEND_MESSAGE_SMOKE_CHANNEL_NAME` + - `SEND_MESSAGE_SMOKE_CONFIGMAP_NAME` + - `KUBECTL_BIN` + - `DOCKER_BIN` + - `KIND_BIN` +- No committed file in this subtree contains a real token value or local token + source. diff --git a/extended/plugins/send-message-ruby/smoke/smoke_runner.rb b/extended/plugins/send-message-ruby/smoke/smoke_runner.rb new file mode 100644 index 0000000000..1e968dacd7 --- /dev/null +++ b/extended/plugins/send-message-ruby/smoke/smoke_runner.rb @@ -0,0 +1,452 @@ +require "fileutils" +require "json" +require "open3" +require "tmpdir" +require "yaml" + +module SendMessageRubySmoke + class SmokeFailure < StandardError + end + + class Shell + def capture(*command, chdir: nil) + stdout, stderr, status = + if chdir + Open3.capture3(*command, chdir: chdir) + else + Open3.capture3(*command) + end + return stdout if status.success? + + raise SmokeFailure, "#{command.join(' ')} failed\n#{stdout}#{stderr}" + end + + def run_command(*command, chdir: nil) + output = capture(*command, chdir: chdir) + print output unless output.empty? + true + end + end + + class SmokeRunner + REQUIRED_ENV = %w[ + SEND_MESSAGE_SMOKE_PROJECT + SEND_MESSAGE_SMOKE_WAREHOUSE + SEND_MESSAGE_SMOKE_FREIGHT_NAME + SEND_MESSAGE_SMOKE_SLACK_API_KEY + SEND_MESSAGE_SMOKE_CHANNEL_ID + ].freeze + + def initialize(shell: Shell.new) + @shell = shell + @plugin_dir = File.expand_path("..", __dir__) + @kargo_bin = ENV.fetch("KARGO_BIN", "kargo") + @kargo_flags = ENV.fetch("KARGO_FLAGS", "") + @kubectl_bin = ENV.fetch("KUBECTL_BIN", "kubectl") + @docker_bin = ENV.fetch("DOCKER_BIN", "docker") + @kind_bin = ENV.fetch("KIND_BIN", "kind") + @system_resources_namespace = ENV.fetch( + "SEND_MESSAGE_SMOKE_SYSTEM_RESOURCES_NAMESPACE", + "kargo-system-resources" + ) + @secret_name = ENV.fetch( + "SEND_MESSAGE_SMOKE_SECRET_NAME", + "send-message-slack-token" + ) + @channel_name = ENV.fetch( + "SEND_MESSAGE_SMOKE_CHANNEL_NAME", + "send-message-smoke" + ) + @configmap_name = ENV.fetch( + "SEND_MESSAGE_SMOKE_CONFIGMAP_NAME", + "send-message-step-plugin" + ) + @cluster_role_name = "send-message-step-plugin-reader" + @project = ENV["SEND_MESSAGE_SMOKE_PROJECT"] + @warehouse = ENV["SEND_MESSAGE_SMOKE_WAREHOUSE"] + @freight_name = ENV["SEND_MESSAGE_SMOKE_FREIGHT_NAME"] + @slack_api_key = ENV["SEND_MESSAGE_SMOKE_SLACK_API_KEY"] + @slack_channel_id = ENV["SEND_MESSAGE_SMOKE_CHANNEL_ID"] + @tmp_dir = nil + @stage_name = nil + @promotion_name = nil + @cluster_role_binding_name = nil + end + + def run + require_env! + ensure_kind_context! + build_and_load_image + render_plugin_dir + build_plugin_configmap + install_manifests + create_test_resources + run_stage + poll_promotion + pass("send-message StepPlugin smoke promotion finished successfully") + ensure + cleanup + end + + private + + def require_env! + missing = REQUIRED_ENV.select { |name| ENV[name].to_s.empty? } + return if missing.empty? + + noun = missing.length == 1 ? "is" : "are" + raise SmokeFailure, "#{missing.join(', ')} #{noun} required" + end + + def ensure_kind_context! + context = capture(@kubectl_bin, "config", "current-context").strip + return if context.start_with?("kind-") + + raise SmokeFailure, "send-message smoke requires a kind context, got: #{context}" + end + + def build_and_load_image + kind_name = capture(@kubectl_bin, "config", "current-context").strip.delete_prefix("kind-") + @image_tag = "send-message-step-plugin-ruby:e2e-#{Time.now.to_i}" + + info("Build send-message StepPlugin image") + run_command(@docker_bin, "build", "-t", @image_tag, @plugin_dir) + pass("Build send-message StepPlugin image") + + info("Load send-message StepPlugin image into kind") + run_command(@kind_bin, "load", "docker-image", "--name", kind_name, @image_tag) + pass("Load send-message StepPlugin image into kind") + end + + def render_plugin_dir + @tmp_dir = Dir.mktmpdir("send-message-stepplugin-ruby-") + plugin_yaml = File.read(File.join(@plugin_dir, "plugin.yaml")) + plugin_yaml = plugin_yaml.gsub("namespace: kargo-system-resources", "namespace: #{@system_resources_namespace}") + plugin_yaml = plugin_yaml.gsub("image: send-message-step-plugin-ruby:dev", "image: #{@image_tag}") + plugin_yaml = plugin_yaml.gsub("value: kargo-system-resources", "value: #{@system_resources_namespace}") + File.write(File.join(@tmp_dir, "plugin.yaml"), plugin_yaml) + end + + def build_plugin_configmap + info("Build send-message StepPlugin ConfigMap") + run_command(@kargo_bin, "step-plugin", "build", ".", chdir: @tmp_dir) + pass("Build send-message StepPlugin ConfigMap") + end + + def install_manifests + info("Install send-message CRDs") + run_command(@kubectl_bin, "apply", "-f", File.join(@plugin_dir, "manifests", "crds.yaml")) + pass("Install send-message CRDs") + + info("Install send-message ClusterRole") + run_command(@kubectl_bin, "apply", "-f", File.join(@plugin_dir, "manifests", "rbac.yaml")) + pass("Install send-message ClusterRole") + + @cluster_role_binding_name = "send-message-step-plugin-reader-#{@project}" + binding_path = File.join(@tmp_dir, "#{@cluster_role_binding_name}.yaml") + File.write(binding_path, YAML.dump(cluster_role_binding)) + info("Bind send-message ClusterRole to test project default ServiceAccount") + run_command(@kubectl_bin, "apply", "-f", binding_path) + pass("Bind send-message ClusterRole to test project default ServiceAccount") + + info("Install send-message StepPlugin ConfigMap in system resources namespace") + run_command( + @kubectl_bin, + "apply", + "-f", + File.join(@tmp_dir, "#{@configmap_name}-configmap.yaml") + ) + pass("Install send-message StepPlugin ConfigMap in system resources namespace") + end + + def create_test_resources + info("Create send-message Slack Secret") + run_command( + @kubectl_bin, + "apply", + "-f", + write_yaml("#{@secret_name}.yaml", secret_manifest) + ) + pass("Create send-message Slack Secret") + + info("Create send-message MessageChannel") + run_command( + @kubectl_bin, + "apply", + "-f", + write_yaml("#{@channel_name}.yaml", channel_manifest) + ) + pass("Create send-message MessageChannel") + end + + def run_stage + @stage_name = "smsgrb-#{Time.now.to_i}" + info("Create send-message StepPlugin smoke stage") + output = capture( + @kargo_bin, + "apply", + "-f", + write_yaml("#{@stage_name}.yaml", stage_manifest), + *split_flags + ) + puts output + unless output.include?("stage.kargo.akuity.io/#{@stage_name}") + raise SmokeFailure, "send-message smoke stage apply output did not mention #{@stage_name}" + end + pass("Create send-message StepPlugin smoke stage") + + info("Approve freight for send-message StepPlugin smoke stage") + run_command( + @kargo_bin, + "approve", + "--project=#{@project}", + "--freight=#{@freight_name}", + "--stage=#{@stage_name}", + *split_flags + ) + pass("Approve freight for send-message StepPlugin smoke stage") + + @promotion_name = "#{@stage_name}.manual" + info("Create send-message StepPlugin smoke promotion") + run_command( + @kubectl_bin, + "apply", + "-f", + write_yaml("#{@promotion_name}.yaml", promotion_manifest) + ) + pass("Create send-message StepPlugin smoke promotion") + end + + def poll_promotion + 90.times do + promotion_resource = fetch_promotion + phase = promotion_resource.dig("status", "phase").to_s + case phase + when "Succeeded" + thread_ts = promotion_resource.dig("status", "stepExecutionMetadata", 0, "output", "slack", "threadTS") + thread_ts ||= promotion_resource.dig("status", "state", "step-1", "slack", "threadTS") + return unless thread_ts.to_s.empty? + + raise SmokeFailure, "send-message smoke did not produce slack.threadTS output" + when "Failed", "Errored", "Aborted" + dump = capture( + @kubectl_bin, + "get", + "promotion.kargo.akuity.io", + promotion_resource.dig("metadata", "name"), + "-n", + @project, + "-o", + "yaml" + ) + raise SmokeFailure, "send-message smoke promotion reached terminal phase #{phase}\n#{dump}" + end + sleep 2 + end + + dump = capture( + @kubectl_bin, + "get", + "promotion.kargo.akuity.io", + "-n", + @project, + "-o", + "yaml" + ) + raise SmokeFailure, "send-message smoke promotion did not succeed in time\n#{dump}" + end + + def fetch_promotion + return {} unless @promotion_name + + payload = capture( + @kubectl_bin, + "get", + "promotion.kargo.akuity.io", + @promotion_name, + "-n", + @project, + "-o", + "json" + ) + JSON.parse(payload) + rescue JSON::ParserError + {} + rescue SmokeFailure + {} + end + + def cleanup + delete("stage.kargo.akuity.io", @stage_name, namespace: @project) if @stage_name + delete("promotion.kargo.akuity.io", @promotion_name, namespace: @project) if @promotion_name + delete("messagechannel.ee.kargo.akuity.io", @channel_name, namespace: @project) + delete("secret", @secret_name, namespace: @project) + delete("configmap", @configmap_name, namespace: @system_resources_namespace) + delete("clusterrolebinding", @cluster_role_binding_name) if @cluster_role_binding_name + delete("clusterrole", @cluster_role_name) + run_command( + @kubectl_bin, + "delete", + "-f", + File.join(@plugin_dir, "manifests", "crds.yaml"), + "--ignore-not-found" + ) + rescue StandardError + nil + ensure + FileUtils.remove_entry(@tmp_dir) if @tmp_dir && File.exist?(@tmp_dir) + end + + def delete(kind, name, namespace: nil) + return if name.to_s.empty? + + command = [@kubectl_bin, "delete", kind, name] + command += ["-n", namespace] if namespace + command << "--ignore-not-found" + run_command(*command) + end + + def cluster_role_binding + { + "apiVersion" => "rbac.authorization.k8s.io/v1", + "kind" => "ClusterRoleBinding", + "metadata" => { + "name" => @cluster_role_binding_name + }, + "subjects" => [ + { + "kind" => "ServiceAccount", + "name" => "default", + "namespace" => @project + } + ], + "roleRef" => { + "apiGroup" => "rbac.authorization.k8s.io", + "kind" => "ClusterRole", + "name" => @cluster_role_name + } + } + end + + def secret_manifest + { + "apiVersion" => "v1", + "kind" => "Secret", + "metadata" => { + "name" => @secret_name, + "namespace" => @project + }, + "type" => "Opaque", + "stringData" => { + "apiKey" => @slack_api_key + } + } + end + + def channel_manifest + { + "apiVersion" => "ee.kargo.akuity.io/v1alpha1", + "kind" => "MessageChannel", + "metadata" => { + "name" => @channel_name, + "namespace" => @project + }, + "spec" => { + "secretRef" => { + "name" => @secret_name + }, + "slack" => { + "channelID" => @slack_channel_id + } + } + } + end + + def stage_manifest + { + "apiVersion" => "kargo.akuity.io/v1alpha1", + "kind" => "Stage", + "metadata" => { + "name" => @stage_name, + "namespace" => @project + }, + "spec" => { + "requestedFreight" => [ + { + "origin" => { + "kind" => "Warehouse", + "name" => @warehouse + }, + "sources" => { + "direct" => true + } + } + ], + "promotionTemplate" => { + "spec" => { + "steps" => [ + { + "uses" => "send-message", + "config" => { + "channel" => { + "kind" => "MessageChannel", + "name" => @channel_name + }, + "message" => "send-message StepPlugin smoke from the Ruby implementation #{@stage_name}" + } + } + ] + } + } + } + } + end + + def promotion_manifest + { + "apiVersion" => "kargo.akuity.io/v1alpha1", + "kind" => "Promotion", + "metadata" => { + "name" => @promotion_name, + "namespace" => @project + }, + "spec" => { + "stage" => @stage_name, + "freight" => @freight_name, + "steps" => stage_manifest.dig( + "spec", + "promotionTemplate", + "spec", + "steps" + ) + } + } + end + + def split_flags + @kargo_flags.split.reject(&:empty?) + end + + def write_yaml(name, object) + path = File.join(@tmp_dir, name) + File.write(path, YAML.dump(object)) + path + end + + def capture(*command, chdir: nil) + @shell.capture(*command, chdir: chdir) + end + + def run_command(*command, chdir: nil) + @shell.run_command(*command, chdir: chdir) + end + + def info(message) + puts "[INFO] #{message}" + end + + def pass(message) + puts "[PASS] #{message}" + end + end +end diff --git a/extended/plugins/send-message-ruby/smoke/smoke_test.rb b/extended/plugins/send-message-ruby/smoke/smoke_test.rb new file mode 100644 index 0000000000..8824df687d --- /dev/null +++ b/extended/plugins/send-message-ruby/smoke/smoke_test.rb @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require_relative "smoke_runner" + +SendMessageRubySmoke::SmokeRunner.new.run diff --git a/extended/plugins/send-message-ruby/test/server_test.rb b/extended/plugins/send-message-ruby/test/server_test.rb new file mode 100644 index 0000000000..3587a35144 --- /dev/null +++ b/extended/plugins/send-message-ruby/test/server_test.rb @@ -0,0 +1,501 @@ +require "json" +require "logger" +require "minitest/autorun" +require "tmpdir" + +require_relative "../lib/send_message_plugin" + +class SendMessagePluginTest < Minitest::Test + def test_rejects_missing_bearer_token + app = build_app + + status, response = app.call( + method: "POST", + path: SendMessagePlugin::STEP_EXECUTE_PATH, + headers: {}, + body: JSON.generate(minimal_request) + ) + + assert_equal 403, status + assert_equal "Errored", response["status"] + assert_equal "missing bearer token", response["error"] + end + + def test_rejects_invalid_bearer_token + app = build_app + + status, response = app.call( + method: "POST", + path: SendMessagePlugin::STEP_EXECUTE_PATH, + headers: { "Authorization" => "Bearer wrong-token" }, + body: JSON.generate(minimal_request) + ) + + assert_equal 403, status + assert_equal "Errored", response["status"] + assert_equal "invalid bearer token", response["error"] + end + + def test_rejects_invalid_request_body + app = build_app + + status, response = app.call( + method: "POST", + path: SendMessagePlugin::STEP_EXECUTE_PATH, + headers: { "Authorization" => "Bearer expected-token" }, + body: "{not-json" + ) + + assert_equal 400, status + assert_equal "Errored", response["status"] + assert_equal "invalid request body", response["message"] + end + + def test_uses_namespaced_message_channel + kube = FakeKubernetesClient.new( + message_channels: { + "demo/send" => { + "secret_name" => "slack-token", + "slack_channel_id" => "C123", + "resource_kind" => "MessageChannel", + "resource_name" => "send" + } + }, + secrets: { + "demo/slack-token" => { "apiKey" => "xoxb-demo" } + } + ) + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000100" + ) + app = build_app(kube: kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "hello from plugin" + }, + slack.last_payload + ) + assert_equal( + "1712345678.000100", + response.dig("output", "slack", "threadTS") + ) + end + + def test_uses_cluster_message_channel + kube = FakeKubernetesClient.new( + cluster_message_channels: { + "send" => { + "secret_name" => "slack-token", + "slack_channel_id" => "C777", + "resource_kind" => "ClusterMessageChannel", + "resource_name" => "send" + } + }, + secrets: { + "kargo-system-resources/slack-token" => { "apiKey" => "xoxb-demo" } + } + ) + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000200" + ) + app = build_app(kube: kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["channel"] = { + "kind" => "ClusterMessageChannel", + "name" => "send" + } + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C777", + "text" => "hello from plugin" + }, + slack.last_payload + ) + end + + def test_plaintext_honors_slack_overrides + kube = standard_kube + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000300" + ) + app = build_app(kube: kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["slack"] = { + "channelID" => "C999", + "threadTS" => "1700000000.000001" + } + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C999", + "text" => "hello from plugin", + "thread_ts" => "1700000000.000001" + }, + slack.last_payload + ) + assert_equal( + "1700000000.000001", + response.dig("output", "slack", "threadTS") + ) + end + + def test_supports_json_payloads + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000400" + ) + app = build_app(kube: standard_kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "json" + request["step"]["config"]["message"] = <<~JSON + {"text":"rich","blocks":[{"type":"section"}]} + JSON + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "rich", + "blocks" => [ + { "type" => "section" } + ] + }, + slack.last_payload + ) + end + + def test_supports_yaml_payloads + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000410" + ) + app = build_app(kube: standard_kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "yaml" + request["step"]["config"]["message"] = <<~YAML + text: rich + blocks: + - type: section + YAML + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "rich", + "blocks" => [ + { "type" => "section" } + ] + }, + slack.last_payload + ) + end + + def test_encoded_payload_ignores_outer_slack_overrides + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000450" + ) + app = build_app(kube: standard_kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "json" + request["step"]["config"]["message"] = '{"text":"rich","thread_ts":"1700000000.000002"}' + request["step"]["config"]["slack"] = { + "channelID" => "C999", + "threadTS" => "1700000000.000001" + } + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "rich", + "thread_ts" => "1700000000.000002" + }, + slack.last_payload + ) + assert_equal( + "1700000000.000002", + response.dig("output", "slack", "threadTS") + ) + end + + def test_supports_xml_payloads + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000500" + ) + app = build_app(kube: standard_kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "xml" + request["step"]["config"]["message"] = <<~XML + + rich + 1700000000.000003 + + XML + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "rich", + "thread_ts" => "1700000000.000003" + }, + slack.last_payload + ) + assert_equal( + "1700000000.000003", + response.dig("output", "slack", "threadTS") + ) + end + + def test_xml_decode_repeats_siblings_into_arrays + slack = FakeSlackClient.new( + "ok" => true, + "ts" => "1712345678.000510" + ) + app = build_app(kube: standard_kube, slack: slack) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "xml" + request["step"]["config"]["message"] = <<~XML + + rich + + section + + mrkdwn + *hello* + + + + divider + + + XML + response = execute_request(app, request) + + assert_equal "Succeeded", response["status"] + assert_equal( + { + "channel" => "C123", + "text" => "rich", + "blocks" => [ + { + "type" => "section", + "text" => { + "type" => "mrkdwn", + "text" => "*hello*" + } + }, + { + "type" => "divider" + } + ] + }, + slack.last_payload + ) + end + + def test_secret_lookup_failure_returns_failed + kube = FakeKubernetesClient.new( + message_channels: { + "demo/send" => { + "secret_name" => "slack-token", + "slack_channel_id" => "C123", + "resource_kind" => "MessageChannel", + "resource_name" => "send" + } + } + ) + app = build_app(kube: kube, slack: FakeSlackClient.new("ok" => true, "ts" => "0")) + + request = minimal_request + request["context"] = { "project" => "demo" } + response = execute_request(app, request) + + assert_equal "Failed", response["status"] + assert_match(/error getting Slack Secret/, response["error"]) + end + + def test_slack_rejection_returns_failed + app = build_app( + kube: standard_kube, + slack: FakeSlackClient.new( + "ok" => false, + "error" => "channel_not_found" + ) + ) + + request = minimal_request + request["context"] = { "project" => "demo" } + response = execute_request(app, request) + + assert_equal "Failed", response["status"] + assert_equal "Slack API error: channel_not_found", response["error"] + end + + def test_bad_yaml_is_errored + app = build_app(kube: standard_kube, slack: FakeSlackClient.new("ok" => true, "ts" => "0")) + + request = minimal_request + request["context"] = { "project" => "demo" } + request["step"]["config"]["encodingType"] = "yaml" + request["step"]["config"]["message"] = "text: [oops" + response = execute_request(app, request) + + assert_equal "Errored", response["status"] + assert_match(/error decoding YAML Slack payload/, response["error"]) + end + + def test_slack_transport_error_returns_failed + app = build_app( + kube: standard_kube, + slack: FakeSlackClient.new( + { "ok" => true, "ts" => "0" }, + error: StandardError.new("dial tcp timeout") + ) + ) + + request = minimal_request + request["context"] = { "project" => "demo" } + response = execute_request(app, request) + + assert_equal "Failed", response["status"] + assert_equal "dial tcp timeout", response["error"] + end + + private + + def build_app(kube: FakeKubernetesClient.new, slack: FakeSlackClient.new("ok" => true, "ts" => "0")) + token_dir = Dir.mktmpdir + token_path = File.join(token_dir, "token") + File.write(token_path, "expected-token") + + SendMessagePlugin::App.new( + token_path: token_path, + system_resources_namespace: "kargo-system-resources", + kubernetes_client: kube, + slack_client: slack, + logger: Logger.new(File::NULL) + ) + end + + def execute_request(app, request) + status, response = app.call( + method: "POST", + path: SendMessagePlugin::STEP_EXECUTE_PATH, + headers: { "Authorization" => "Bearer expected-token" }, + body: JSON.generate(request) + ) + + assert_equal 200, status + response + end + + def minimal_request + { + "step" => { + "kind" => "send-message", + "config" => { + "channel" => { + "kind" => "MessageChannel", + "name" => "send" + }, + "message" => "hello from plugin" + } + } + } + end + + def standard_kube + FakeKubernetesClient.new( + message_channels: { + "demo/send" => { + "secret_name" => "slack-token", + "slack_channel_id" => "C123", + "resource_kind" => "MessageChannel", + "resource_name" => "send" + } + }, + secrets: { + "demo/slack-token" => { "apiKey" => "xoxb-demo" } + } + ) + end +end + +class FakeKubernetesClient + def initialize(message_channels: {}, cluster_message_channels: {}, secrets: {}) + @message_channels = message_channels + @cluster_message_channels = cluster_message_channels + @secrets = secrets + end + + def get_message_channel(namespace, name) + @message_channels.fetch("#{namespace}/#{name}") do + raise SendMessagePlugin::ExecutionError, "message channel not found" + end + end + + def get_cluster_message_channel(name) + @cluster_message_channels.fetch(name) do + raise SendMessagePlugin::ExecutionError, "cluster message channel not found" + end + end + + def get_secret(namespace, name) + @secrets.fetch("#{namespace}/#{name}") do + raise SendMessagePlugin::ExecutionError, "error getting Slack Secret: secret not found" + end + end +end + +class FakeSlackClient + attr_reader :last_payload + + def initialize(response, error: nil) + @response = response + @error = error + end + + def post_message(api_base_url:, token:, payload:) + @last_payload = payload + raise @error if @error + + @response + end +end