EDIT 2020-07-03 The CEL syntax for splitting strings changed in Triggers 0.5.0, the examples here don’t reflect those changes.

Slack “slash commands” are an easy way to communicate from Slack to services.

Simply, you can add a command to a Slack app, and trigger HTTP POST requests from Slack to an endpoint of your choosing.

These are sent as HTTP POST requests, with URL form-encoding of the data.

Tekton Triggers provide EventListeners which can receive and act on webhooks, to drive TaskRuns and PipelineRuns.

Unfortunately, EventListeners only support JSON bodies, and Slack commands are form-encoded.

The params are documented here

The example looks like this:

token=gIkuvaNzQIHg97ATvDxqgjtO
&team_id=T0001
&team_domain=example
&enterprise_id=E0001
&enterprise_name=Globular%20Construct%20Inc
&channel_id=C2147483705
&channel_name=test
&user_id=U2147483697
&user_name=Steve
&command=/weather
&text=94070
&response_url=https://hooks.slack.com/commands/1234/5678
&trigger_id=13345224609.738474920.8088930838d88f008e0

You can see that there’s a “command” and “text” field that contain the command and the text after the command from the user, along with other fields.

How can you connect a Slack command to a Tekton Pipeline?

Introducing…

slack-webhook-interceptor

I’ve implemented a simple Webhook interceptor for Tekton here.

This is a simple introduction, to getting a Task executing (but, it’d be trivial to adapt for Pipelines).

Simple Task

Here’s a simple script driven task, for now it just echoes the provided params:

slack-webhook-task.yaml

apiVersion: tekton.dev/v1alpha1
kind: Task
metadata:
  name: slack-webhook-task
spec:
  inputs:
    params:
    - name: command
      description: the command from Slack
    - name: repo
      description: the repo to source
    - name: sha
      description: the specific SHA in the repo
  steps:
  - image: registry.access.redhat.com/ubi8/ubi-minimal
    script: |
        #!/usr/bin/env bash
        echo $(inputs.params.command) $(inputs.params.repo) $(inputs.params.sha)

Trigger Template Binding

We want to bind to the following parameters: command, repo and sha.

task-run-binding.yaml

apiVersion: tekton.dev/v1alpha1
kind: TriggerBinding
metadata:
  name: task-run-binding
spec:
  params:
  - name: command
    value: $(body.slack.command)
  - name: repo
    value: $(body.slack.repo)
  - name: sha
    value: $(body.slack.sha)

Notice that these are coming in a JSON body that should be formatted like this:

{
  "slack": {
    "command": "/build",
    "repo": "https://github.com/bigkevmcd/slack-webhook-interceptor",
    "sha": "ca82a6dff817ec66f44342007202690a93763949"
  }
}

Trigger Template

For this example, I just want to execute the Task defined above:

task-run-template.yaml

apiVersion: tekton.dev/v1alpha1
kind: TriggerTemplate
metadata:
  name: task-run-template
spec:
  params:
  - name: command
    description: The command from Slack
  - name: repo
    description: The repo to process.
  - name: sha
    description: The specific commit (SHA) to process.
  resourcetemplates:
  - apiVersion: tekton.dev/v1alpha1
    kind: TaskRun
    metadata:
      generateName: slack-task-run-
    spec:
      taskRef:
        name: slack-webhook-task
      inputs:
        params:
        - name: command
          value: $(params.command)
        - name: repo
          value: $(params.repo)
        - name: sha
          value: $(params.sha)

EventListener

And here comes the Slack specific section:

listener-interceptor.yaml

apiVersion: tekton.dev/v1alpha1
kind: EventListener
metadata:
  name: listener-interceptor
spec:
  triggers:
    - name: foo-trig
      interceptors:
        - webhook:
            header:
              - name: Slack-Decodeprefix
                value: slack
            objectRef:
              kind: Service
              name: slack-webhook-interceptor
              apiVersion: v1
              namespace: default
        - cel:
            filter: body.slack.command == '/build'
            overlays:
            - key: slack.repo
              expression: "split(body.slack.text, ' ')[0]"
            - key: slack.sha
              expression: "split(body.slack.text, ' ')[1]"
            - key: slack.command
              expression: "split(body.slack.command, '/')[1]"
      bindings:
        - name: task-run-binding
      template:
        name: task-run-template

This is using a Webhook interceptor to convert the incoming form-encoded data to JSON.

The Webhook interceptor is sending an additional header, Slack-Decodeprefix: slack which configures the top-level object key to store the form parameters in.

Form values can be duplicated in a request, and all values should be retained, this is handled by the Go url.Values as type Values map[string][]string.

By default, the interceptor flattens these values resulting in squashing the results into non-arrays in the response, this is useful if you know that you’ll only get one value for keys as it shortens access to the values, instead of body.slack.command[0] == '/build' I can write body.slack.command == '/build'.

You can disable this with SlackDecodenoflatten: true if you need multiple values in the response body.

This in combination with the CEL filter results in triggering the Task if the command is /build, you can point multiple “slash commands” at the same EventListener and match on the correct command, caveat: doing this will trigger multiple requests to the interceptor.

The CEL interceptor uses two new features introduced in Tekton Triggers v0.3.0, overlays and the new split function.

The Webhook interceptor

Finally, you’ll need a running version of the webhook interceptor.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: slack-webhook-interceptor
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: slack-webhook-interceptor
  template:
    metadata:
     labels:
       app.kubernetes.io/name: slack-webhook-interceptor
    spec:
      serviceAccountName: default
      containers:
        - name: slack-webhook-interceptor
          image: PATH_TO_IMAGE
          imagePullPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  name: slack-webhook-interceptor
  namespace: default
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: slack-webhook-interceptor
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

Replace PATH_TO_IMAGE with the Quay.io/Docker Hub/ECR reference for a build of the image.

Bring up your environment

Save the files above into a directory (or use the ones in https://github.com/bigkevmcd/slack-webhook-interceptor/tree/master/example) and apply them to your Kubernetes cluster with Tekton installed

Ensure that the pods are all up and running before continuing.

Creating a Slash command

To create a new App in Slack, you need to go to https://api.slack.com/apps?new_app=1 in a workspace you have permission to create new apps.

For obvious reasons, you might not have permission in all workspaces, so you’ll want to use a workspace you can add apps to.

Create a new Slack App

From here, you want to add a new Slash command:

Click on “Slash commands” on the App page:

Add a new Slack command

And then click to create a new command:

Click to confirm

For this example, I’m creating a simple slash command “/build” which I’ll provide a repo and sha for.

Configure the slack command

You’ll need to put an Internet accessible URL into the “Request URL” field, if you’re just testing this locally, you can port-forward and ngrok:

kubectl port-forward svc/el-listener-interceptor 8080 # run this in one console
ngrok http 8080 # run this in a second console

You can then use the ngrok.io address provided by ngrok.

Triggering the command from Slack

triggering the Slack command

When the command executes, it will output the result of triggering the execution.

Slack trigger result

Part of the payload of the Slash command has a URL that can be used to send a response back with better information.

Viewing the results of triggering the command

With the tkn tool, it’s fairly simple to track the TaskRun status:

$ tkn taskrun list
NAME                   STARTED          DURATION     STATUS                            
slack-task-run-skc65   15 seconds ago   13 seconds   Succeeded                         

And simply viewing the logs shows that it’s doing the right thing.

$ tkn taskrun logs slack-task-run-skc65
[unnamed-0] {"level":"info","ts":1583136445.1732686,"logger":"fallback-logger","caller":"logging/config.go:69","msg":"Fetch GitHub commit ID from kodata failed: \"KO_DATA_PATH\" does not exist or is empty"}
[unnamed-0] build https://github.com/bigkevmcd/slack-webhook-interceptor 57112af9d9419f97e46731c9f0768b7988baa3ff

The simple demo task just outputs the command, repo and SHA separated by spaces.

You can see the benefits of the split function from the CEL interceptor for manipulating the strings from the incoming post, it’s splitting the repo and sha apart, and trimming the leading / from the command too.

Alternatives - Slack apps

This uses Slack “slash-commands”, but Slack has introduced a newer mechanism called Slack Apps, these can also drive Triggers, but slash commands are quick and easy to setup.

Sending responses back to Slack

I’ll cover sending responses back to Slack from a TaskRun in a subsequent post.