feat: add slackme module (#85)

* Add Slackme module

* Don't require auth

* Improve slackme

* Make run on starT

* Try new heredoc syntax

* Escape execute calls

* Improve portability

* Fix fmt

* Improve slackme script features

* Fix whitespace

* Fix linting
This commit is contained in:
Kyle Carberry
2023-10-11 00:35:36 -05:00
committed by GitHub
parent c7c9fa9279
commit 0068642d3b
6 changed files with 391 additions and 0 deletions

81
slackme/README.md Normal file
View File

@@ -0,0 +1,81 @@
---
display_name: Slack Me
description: Send a Slack message when a command finishes inside a workspace!
icon: ../.icons/slack.svg
maintainer_github: coder
verified: true
tags: [helper]
---
# Slack Me
Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running.
```bash
$ slackme npm run long-build
```
## Setup
1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment:
```json
{
"display_information": {
"name": "Command Notify",
"description": "Notify developers when commands finish running inside Coder!",
"background_color": "#1b1b1c"
},
"features": {
"bot_user": {
"display_name": "Command Notify"
}
},
"oauth_config": {
"redirect_urls": [
"https://<your coder deployment>/external-auth/slack/callback"
],
"scopes": {
"bot": ["chat:write"]
}
}
}
```
2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment:
```env
CODER_EXTERNAL_AUTH_1_TYPE=slack
CODER_EXTERNAL_AUTH_1_SCOPES="chat:write"
CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me"
CODER_EXTERNAL_AUTH_1_CLIENT_ID="<your client id>
CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="<your client secret>"
```
3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`:
```hcl
module "slackme" {
source = "https://registry.coder.com/modules/slackme"
agent_id = coder_agent.example.id
auth_provider_id = "slack"
}
```
## Examples
### Custom Slack Message
- `$COMMAND` is replaced with the command the user executed.
- `$DURATION` is replaced with a human-readable duration the command took to execute.
```hcl
module "slackme" {
source = "https://registry.coder.com/modules/slackme"
agent_id = coder_agent.example.id
auth_provider_id = "slack"
slack_message = <<EOF
👋 Hey there from Coder! $COMMAND took $DURATION to execute!
EOF
}
```

169
slackme/main.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { serve } from "bun";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
execContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
describe("slackme", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
auth_provider_id: "foo",
});
it("writes to path as executable", async () => {
const { instance, id } = await setupContainer();
await writeCoder(id, "exit 0");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
exec = await execContainer(id, ["sh", "-c", "which slackme"]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout.trim()).toEqual("/usr/bin/slackme");
});
it("prints usage with no command", async () => {
const { instance, id } = await setupContainer();
await writeCoder(id, "echo 👋");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
exec = await execContainer(id, ["sh", "-c", "slackme"]);
expect(exec.stdout.trim()).toStartWith(
"slackme — Send a Slack notification when a command finishes",
);
});
it("displays url when not authenticated", async () => {
const { instance, id } = await setupContainer();
await writeCoder(id, "echo 'some-url' && exit 1");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
exec = await execContainer(id, ["sh", "-c", "slackme echo test"]);
expect(exec.stdout.trim()).toEndWith("some-url");
});
it("default output", async () => {
await assertSlackMessage({
command: "echo test",
durationMS: 2,
output: "👨‍💻 `echo test` completed in 2ms",
});
});
it("formats multiline message", async () => {
await assertSlackMessage({
command: "echo test",
format: `this command:
\`$COMMAND\`
executed`,
output: `this command:
\`echo test\`
executed`,
});
});
it("formats execution with milliseconds", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
durationMS: 150,
output: "echo test took 150ms",
});
});
it("formats execution with seconds", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
durationMS: 15000,
output: "echo test took 15.0s",
});
});
it("formats execution with minutes", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
durationMS: 120000,
output: "echo test took 2m 0.0s",
});
});
it("formats execution with hours", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
durationMS: 60000 * 60,
output: "echo test took 1hr 0m 0.0s",
});
});
});
const setupContainer = async (
image = "alpine",
vars: Record<string, string> = {},
) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
auth_provider_id: "foo",
...vars,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
return { id, instance };
};
const writeCoder = async (id: string, script: string) => {
const exec = await execContainer(id, [
"sh",
"-c",
`echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
]);
expect(exec.exitCode).toBe(0);
};
const assertSlackMessage = async (opts: {
command: string;
format?: string;
durationMS?: number;
output: string;
}) => {
let url: URL;
const fakeSlackHost = serve({
fetch: (req) => {
url = new URL(req.url);
if (url.pathname === "/api/chat.postMessage")
return createJSONResponse({
ok: true,
});
return createJSONResponse({}, 404);
},
port: 0,
});
const { instance, id } = await setupContainer(
"alpine/curl",
opts.format && {
slack_message: opts.format,
},
);
await writeCoder(id, "echo 'token'");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
exec = await execContainer(id, [
"sh",
"-c",
`DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${
fakeSlackHost.hostname
}:${fakeSlackHost.port}" slackme ${opts.command}`,
]);
expect(exec.stderr.trim()).toBe("");
expect(url.pathname).toEqual("/api/chat.postMessage");
expect(url.searchParams.get("channel")).toEqual("token");
expect(url.searchParams.get("text")).toEqual(opts.output);
};

46
slackme/main.tf Normal file
View File

@@ -0,0 +1,46 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "auth_provider_id" {
type = string
description = "The ID of an external auth provider."
}
variable "slack_message" {
type = string
description = "The message to send to Slack."
default = "👨‍💻 `$COMMAND` completed in $DURATION"
}
resource "coder_script" "install_slackme" {
agent_id = var.agent_id
display_name = "install_slackme"
run_on_start = true
script = <<OUTER
#!/usr/bin/env bash
set -e
CODER_DIR=$(dirname $(which coder))
cat > $CODER_DIR/slackme <<INNER
${replace(templatefile("${path.module}/slackme.sh", {
PROVIDER_ID : var.auth_provider_id,
SLACK_MESSAGE : replace(var.slack_message, "`", "\\`"),
}), "$", "\\$")}
INNER
chmod +x $CODER_DIR/slackme
OUTER
}

87
slackme/slackme.sh Normal file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env sh
PROVIDER_ID=${PROVIDER_ID}
SLACK_MESSAGE=$(cat << "EOF"
${SLACK_MESSAGE}
EOF
)
SLACK_URL=$${SLACK_URL:-https://slack.com}
usage() {
cat <<EOF
slackme — Send a Slack notification when a command finishes
Usage: slackme <command>
Example: slackme npm run long-build
EOF
}
pretty_duration() {
local duration_ms=$1
# If the duration is less than 1 second, display in milliseconds
if [ $duration_ms -lt 1000 ]; then
echo "$${duration_ms}ms"
return
fi
# Convert the duration to seconds
local duration_sec=$((duration_ms / 1000))
local remaining_ms=$((duration_ms % 1000))
# If the duration is less than 1 minute, display in seconds (with ms)
if [ $duration_sec -lt 60 ]; then
echo "$${duration_sec}.$${remaining_ms}s"
return
fi
# Convert the duration to minutes
local duration_min=$((duration_sec / 60))
local remaining_sec=$((duration_sec % 60))
# If the duration is less than 1 hour, display in minutes and seconds
if [ $duration_min -lt 60 ]; then
echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s"
return
fi
# Convert the duration to hours
local duration_hr=$((duration_min / 60))
local remaining_min=$((duration_min % 60))
# Display in hours, minutes, and seconds
echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s"
}
if [ $# -eq 0 ]; then
usage
exit 1
fi
BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID)
if [ $? -ne 0 ]; then
printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n"
exit 1
fi
USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id")
if [ $? -ne 0 ]; then
printf "Failed to get authenticated user ID:\n$USER_ID\n"
exit 1
fi
START=$(date +%s%N)
# Run all arguments as a command
$@
END=$(date +%s%N)
DURATION_MS=$${DURATION_MS:-$(( (END - START) / 1000000 ))}
PRETTY_DURATION=$(pretty_duration $DURATION_MS)
set -e
COMMAND=$(echo $@)
SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g")
SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g")
curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \
-G --data-urlencode "text=$${SLACK_MESSAGE}" \
"$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1"