Skip to content

Commit 0068642

Browse files
authored
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
1 parent c7c9fa9 commit 0068642

File tree

6 files changed

+391
-0
lines changed

6 files changed

+391
-0
lines changed

.icons/slack.svg

+6
Loading

slackme/README.md

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
display_name: Slack Me
3+
description: Send a Slack message when a command finishes inside a workspace!
4+
icon: ../.icons/slack.svg
5+
maintainer_github: coder
6+
verified: true
7+
tags: [helper]
8+
---
9+
10+
# Slack Me
11+
12+
Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running.
13+
14+
```bash
15+
$ slackme npm run long-build
16+
```
17+
18+
## Setup
19+
20+
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:
21+
22+
```json
23+
{
24+
"display_information": {
25+
"name": "Command Notify",
26+
"description": "Notify developers when commands finish running inside Coder!",
27+
"background_color": "#1b1b1c"
28+
},
29+
"features": {
30+
"bot_user": {
31+
"display_name": "Command Notify"
32+
}
33+
},
34+
"oauth_config": {
35+
"redirect_urls": [
36+
"https://<your coder deployment>/external-auth/slack/callback"
37+
],
38+
"scopes": {
39+
"bot": ["chat:write"]
40+
}
41+
}
42+
}
43+
```
44+
45+
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:
46+
47+
```env
48+
CODER_EXTERNAL_AUTH_1_TYPE=slack
49+
CODER_EXTERNAL_AUTH_1_SCOPES="chat:write"
50+
CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me"
51+
CODER_EXTERNAL_AUTH_1_CLIENT_ID="<your client id>
52+
CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="<your client secret>"
53+
```
54+
55+
3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`:
56+
57+
```hcl
58+
module "slackme" {
59+
source = "https://registry.coder.com/modules/slackme"
60+
agent_id = coder_agent.example.id
61+
auth_provider_id = "slack"
62+
}
63+
```
64+
65+
## Examples
66+
67+
### Custom Slack Message
68+
69+
- `$COMMAND` is replaced with the command the user executed.
70+
- `$DURATION` is replaced with a human-readable duration the command took to execute.
71+
72+
```hcl
73+
module "slackme" {
74+
source = "https://registry.coder.com/modules/slackme"
75+
agent_id = coder_agent.example.id
76+
auth_provider_id = "slack"
77+
slack_message = <<EOF
78+
👋 Hey there from Coder! $COMMAND took $DURATION to execute!
79+
EOF
80+
}
81+
```

slackme/main.test.ts

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { serve } from "bun";
2+
import { describe, expect, it } from "bun:test";
3+
import {
4+
createJSONResponse,
5+
execContainer,
6+
findResourceInstance,
7+
runContainer,
8+
runTerraformApply,
9+
runTerraformInit,
10+
testRequiredVariables,
11+
} from "../test";
12+
13+
describe("slackme", async () => {
14+
await runTerraformInit(import.meta.dir);
15+
16+
testRequiredVariables(import.meta.dir, {
17+
agent_id: "foo",
18+
auth_provider_id: "foo",
19+
});
20+
21+
it("writes to path as executable", async () => {
22+
const { instance, id } = await setupContainer();
23+
await writeCoder(id, "exit 0");
24+
let exec = await execContainer(id, ["sh", "-c", instance.script]);
25+
expect(exec.exitCode).toBe(0);
26+
exec = await execContainer(id, ["sh", "-c", "which slackme"]);
27+
expect(exec.exitCode).toBe(0);
28+
expect(exec.stdout.trim()).toEqual("/usr/bin/slackme");
29+
});
30+
31+
it("prints usage with no command", async () => {
32+
const { instance, id } = await setupContainer();
33+
await writeCoder(id, "echo 👋");
34+
let exec = await execContainer(id, ["sh", "-c", instance.script]);
35+
expect(exec.exitCode).toBe(0);
36+
exec = await execContainer(id, ["sh", "-c", "slackme"]);
37+
expect(exec.stdout.trim()).toStartWith(
38+
"slackme — Send a Slack notification when a command finishes",
39+
);
40+
});
41+
42+
it("displays url when not authenticated", async () => {
43+
const { instance, id } = await setupContainer();
44+
await writeCoder(id, "echo 'some-url' && exit 1");
45+
let exec = await execContainer(id, ["sh", "-c", instance.script]);
46+
expect(exec.exitCode).toBe(0);
47+
exec = await execContainer(id, ["sh", "-c", "slackme echo test"]);
48+
expect(exec.stdout.trim()).toEndWith("some-url");
49+
});
50+
51+
it("default output", async () => {
52+
await assertSlackMessage({
53+
command: "echo test",
54+
durationMS: 2,
55+
output: "👨‍💻 `echo test` completed in 2ms",
56+
});
57+
});
58+
59+
it("formats multiline message", async () => {
60+
await assertSlackMessage({
61+
command: "echo test",
62+
format: `this command:
63+
\`$COMMAND\`
64+
executed`,
65+
output: `this command:
66+
\`echo test\`
67+
executed`,
68+
});
69+
});
70+
71+
it("formats execution with milliseconds", async () => {
72+
await assertSlackMessage({
73+
command: "echo test",
74+
format: `$COMMAND took $DURATION`,
75+
durationMS: 150,
76+
output: "echo test took 150ms",
77+
});
78+
});
79+
80+
it("formats execution with seconds", async () => {
81+
await assertSlackMessage({
82+
command: "echo test",
83+
format: `$COMMAND took $DURATION`,
84+
durationMS: 15000,
85+
output: "echo test took 15.0s",
86+
});
87+
});
88+
89+
it("formats execution with minutes", async () => {
90+
await assertSlackMessage({
91+
command: "echo test",
92+
format: `$COMMAND took $DURATION`,
93+
durationMS: 120000,
94+
output: "echo test took 2m 0.0s",
95+
});
96+
});
97+
98+
it("formats execution with hours", async () => {
99+
await assertSlackMessage({
100+
command: "echo test",
101+
format: `$COMMAND took $DURATION`,
102+
durationMS: 60000 * 60,
103+
output: "echo test took 1hr 0m 0.0s",
104+
});
105+
});
106+
});
107+
108+
const setupContainer = async (
109+
image = "alpine",
110+
vars: Record<string, string> = {},
111+
) => {
112+
const state = await runTerraformApply(import.meta.dir, {
113+
agent_id: "foo",
114+
auth_provider_id: "foo",
115+
...vars,
116+
});
117+
const instance = findResourceInstance(state, "coder_script");
118+
const id = await runContainer(image);
119+
return { id, instance };
120+
};
121+
122+
const writeCoder = async (id: string, script: string) => {
123+
const exec = await execContainer(id, [
124+
"sh",
125+
"-c",
126+
`echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
127+
]);
128+
expect(exec.exitCode).toBe(0);
129+
};
130+
131+
const assertSlackMessage = async (opts: {
132+
command: string;
133+
format?: string;
134+
durationMS?: number;
135+
output: string;
136+
}) => {
137+
let url: URL;
138+
const fakeSlackHost = serve({
139+
fetch: (req) => {
140+
url = new URL(req.url);
141+
if (url.pathname === "/api/chat.postMessage")
142+
return createJSONResponse({
143+
ok: true,
144+
});
145+
return createJSONResponse({}, 404);
146+
},
147+
port: 0,
148+
});
149+
const { instance, id } = await setupContainer(
150+
"alpine/curl",
151+
opts.format && {
152+
slack_message: opts.format,
153+
},
154+
);
155+
await writeCoder(id, "echo 'token'");
156+
let exec = await execContainer(id, ["sh", "-c", instance.script]);
157+
expect(exec.exitCode).toBe(0);
158+
exec = await execContainer(id, [
159+
"sh",
160+
"-c",
161+
`DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${
162+
fakeSlackHost.hostname
163+
}:${fakeSlackHost.port}" slackme ${opts.command}`,
164+
]);
165+
expect(exec.stderr.trim()).toBe("");
166+
expect(url.pathname).toEqual("/api/chat.postMessage");
167+
expect(url.searchParams.get("channel")).toEqual("token");
168+
expect(url.searchParams.get("text")).toEqual(opts.output);
169+
};

slackme/main.tf

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
terraform {
2+
required_version = ">= 1.0"
3+
4+
required_providers {
5+
coder = {
6+
source = "coder/coder"
7+
version = ">= 0.12"
8+
}
9+
}
10+
}
11+
12+
variable "agent_id" {
13+
type = string
14+
description = "The ID of a Coder agent."
15+
}
16+
17+
variable "auth_provider_id" {
18+
type = string
19+
description = "The ID of an external auth provider."
20+
}
21+
22+
variable "slack_message" {
23+
type = string
24+
description = "The message to send to Slack."
25+
default = "👨‍💻 `$COMMAND` completed in $DURATION"
26+
}
27+
28+
resource "coder_script" "install_slackme" {
29+
agent_id = var.agent_id
30+
display_name = "install_slackme"
31+
run_on_start = true
32+
script = <<OUTER
33+
#!/usr/bin/env bash
34+
set -e
35+
36+
CODER_DIR=$(dirname $(which coder))
37+
cat > $CODER_DIR/slackme <<INNER
38+
${replace(templatefile("${path.module}/slackme.sh", {
39+
PROVIDER_ID : var.auth_provider_id,
40+
SLACK_MESSAGE : replace(var.slack_message, "`", "\\`"),
41+
}), "$", "\\$")}
42+
INNER
43+
44+
chmod +x $CODER_DIR/slackme
45+
OUTER
46+
}

slackme/slackme.sh

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env sh
2+
3+
PROVIDER_ID=${PROVIDER_ID}
4+
SLACK_MESSAGE=$(cat << "EOF"
5+
${SLACK_MESSAGE}
6+
EOF
7+
)
8+
SLACK_URL=$${SLACK_URL:-https://slack.com}
9+
10+
usage() {
11+
cat <<EOF
12+
slackme — Send a Slack notification when a command finishes
13+
Usage: slackme <command>
14+
15+
Example: slackme npm run long-build
16+
EOF
17+
}
18+
19+
pretty_duration() {
20+
local duration_ms=$1
21+
22+
# If the duration is less than 1 second, display in milliseconds
23+
if [ $duration_ms -lt 1000 ]; then
24+
echo "$${duration_ms}ms"
25+
return
26+
fi
27+
28+
# Convert the duration to seconds
29+
local duration_sec=$((duration_ms / 1000))
30+
local remaining_ms=$((duration_ms % 1000))
31+
32+
# If the duration is less than 1 minute, display in seconds (with ms)
33+
if [ $duration_sec -lt 60 ]; then
34+
echo "$${duration_sec}.$${remaining_ms}s"
35+
return
36+
fi
37+
38+
# Convert the duration to minutes
39+
local duration_min=$((duration_sec / 60))
40+
local remaining_sec=$((duration_sec % 60))
41+
42+
# If the duration is less than 1 hour, display in minutes and seconds
43+
if [ $duration_min -lt 60 ]; then
44+
echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s"
45+
return
46+
fi
47+
48+
# Convert the duration to hours
49+
local duration_hr=$((duration_min / 60))
50+
local remaining_min=$((duration_min % 60))
51+
52+
# Display in hours, minutes, and seconds
53+
echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s"
54+
}
55+
56+
if [ $# -eq 0 ]; then
57+
usage
58+
exit 1
59+
fi
60+
61+
BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID)
62+
if [ $? -ne 0 ]; then
63+
printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n"
64+
exit 1
65+
fi
66+
67+
USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id")
68+
if [ $? -ne 0 ]; then
69+
printf "Failed to get authenticated user ID:\n$USER_ID\n"
70+
exit 1
71+
fi
72+
73+
START=$(date +%s%N)
74+
# Run all arguments as a command
75+
$@
76+
END=$(date +%s%N)
77+
DURATION_MS=$${DURATION_MS:-$(( (END - START) / 1000000 ))}
78+
PRETTY_DURATION=$(pretty_duration $DURATION_MS)
79+
80+
set -e
81+
COMMAND=$(echo $@)
82+
SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g")
83+
SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g")
84+
85+
curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \
86+
-G --data-urlencode "text=$${SLACK_MESSAGE}" \
87+
"$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1"

0 commit comments

Comments
 (0)