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 lintingslackme/exitcode
parent
c7c9fa9279
commit
0068642d3b
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
|
||||||
|
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
|
||||||
|
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
|
||||||
|
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1019 B |
@ -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
|
||||||
|
}
|
||||||
|
```
|
@ -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);
|
||||||
|
};
|
@ -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
|
||||||
|
}
|
@ -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"
|
Loading…
Reference in New Issue