From 0068642d3bf4996239a8a76f39b074ba87285e5a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 11 Oct 2023 00:35:36 -0500 Subject: [PATCH] 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 --- .icons/slack.svg | 6 ++ slackme/README.md | 81 +++++++++++++++++++++ slackme/main.test.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ slackme/main.tf | 46 ++++++++++++ slackme/slackme.sh | 87 ++++++++++++++++++++++ test.ts | 2 + 6 files changed, 391 insertions(+) create mode 100644 .icons/slack.svg create mode 100644 slackme/README.md create mode 100644 slackme/main.test.ts create mode 100644 slackme/main.tf create mode 100644 slackme/slackme.sh diff --git a/.icons/slack.svg b/.icons/slack.svg new file mode 100644 index 0000000..fb55f72 --- /dev/null +++ b/.icons/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/slackme/README.md b/slackme/README.md new file mode 100644 index 0000000..017f06a --- /dev/null +++ b/slackme/README.md @@ -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:///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=" + CODER_EXTERNAL_AUTH_1_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 = < { + 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 = {}, +) => { + 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); +}; diff --git a/slackme/main.tf b/slackme/main.tf new file mode 100644 index 0000000..5fe948e --- /dev/null +++ b/slackme/main.tf @@ -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 = < $CODER_DIR/slackme < + +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" diff --git a/test.ts b/test.ts index a32a995..37e0805 100644 --- a/test.ts +++ b/test.ts @@ -13,6 +13,8 @@ export const runContainer = async ( "-d", "--label", "modules-test=true", + "--network", + "host", "--entrypoint", "sh", image,