Merge branch 'main' into vault
						commit
						8edbabf7b0
					
				@ -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,12 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import { runTerraformInit, testRequiredVariables } from "../test";
 | 
			
		||||
 | 
			
		||||
describe("code-server", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {
 | 
			
		||||
    agent_id: "foo",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // More tests depend on shebang refactors
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,21 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
  testRequiredVariables,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
describe("dotfiles", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {
 | 
			
		||||
    agent_id: "foo",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("default output", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
    });
 | 
			
		||||
    expect(state.outputs.dotfiles_uri.value).toBe("");
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,43 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
  testRequiredVariables,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
describe("gcp-region", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {});
 | 
			
		||||
 | 
			
		||||
  it("default output", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {});
 | 
			
		||||
    expect(state.outputs.value.value).toBe("");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("customized default", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      regions: '["asia"]',
 | 
			
		||||
      default: "asia-east1-a",
 | 
			
		||||
    });
 | 
			
		||||
    expect(state.outputs.value.value).toBe("asia-east1-a");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("gpu only invalid default", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      regions: '["us-west2"]',
 | 
			
		||||
      default: "us-west2-a",
 | 
			
		||||
      gpu_only: "true",
 | 
			
		||||
    });
 | 
			
		||||
    expect(state.outputs.value.value).toBe("");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("gpu only valid default", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      regions: '["us-west2"]',
 | 
			
		||||
      default: "us-west2-b",
 | 
			
		||||
      gpu_only: "true",
 | 
			
		||||
    });
 | 
			
		||||
    expect(state.outputs.value.value).toBe("us-west2-b");
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,63 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  executeScriptInContainer,
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
  testRequiredVariables,
 | 
			
		||||
  findResourceInstance,
 | 
			
		||||
  runContainer,
 | 
			
		||||
  TerraformState,
 | 
			
		||||
  execContainer,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
// executes the coder script after installing pip
 | 
			
		||||
const executeScriptInContainerWithPip = async (
 | 
			
		||||
  state: TerraformState,
 | 
			
		||||
  image: string,
 | 
			
		||||
  shell: string = "sh",
 | 
			
		||||
): Promise<{
 | 
			
		||||
  exitCode: number;
 | 
			
		||||
  stdout: string[];
 | 
			
		||||
  stderr: string[];
 | 
			
		||||
}> => {
 | 
			
		||||
  const instance = findResourceInstance(state, "coder_script");
 | 
			
		||||
  const id = await runContainer(image);
 | 
			
		||||
  const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]);
 | 
			
		||||
  const resp = await execContainer(id, [shell, "-c", instance.script]);
 | 
			
		||||
  const stdout = resp.stdout.trim().split("\n");
 | 
			
		||||
  const stderr = resp.stderr.trim().split("\n");
 | 
			
		||||
  return {
 | 
			
		||||
    exitCode: resp.exitCode,
 | 
			
		||||
    stdout,
 | 
			
		||||
    stderr,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe("jupyterlab", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {
 | 
			
		||||
    agent_id: "foo",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("fails without pip3", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
    });
 | 
			
		||||
    const output = await executeScriptInContainer(state, "alpine");
 | 
			
		||||
    expect(output.exitCode).toBe(1);
 | 
			
		||||
    expect(output.stdout).toEqual([
 | 
			
		||||
      "\u001B[0;1mInstalling jupyterlab!",
 | 
			
		||||
      "pip3 is not installed",
 | 
			
		||||
      "Please install pip3 in your Dockerfile/VM image before running this script",
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // TODO: Add faster test to run with pip3.
 | 
			
		||||
  // currently times out.
 | 
			
		||||
  // it("runs with pip3", async () => {
 | 
			
		||||
  //   ...
 | 
			
		||||
  //   const output = await executeScriptInContainerWithPip(state, "alpine");
 | 
			
		||||
  //   ...
 | 
			
		||||
  // });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
import { readableStreamToText, spawn } from "bun";
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  executeScriptInContainer,
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
  testRequiredVariables,
 | 
			
		||||
  runContainer,
 | 
			
		||||
  execContainer,
 | 
			
		||||
  findResourceInstance,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
describe("personalize", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {
 | 
			
		||||
    agent_id: "foo",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("warns without personalize script", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
    });
 | 
			
		||||
    const output = await executeScriptInContainer(state, "alpine");
 | 
			
		||||
    expect(output.exitCode).toBe(0);
 | 
			
		||||
    expect(output.stdout).toEqual([
 | 
			
		||||
      "✨ \u001b[0;1mYou don't have a personalize script!",
 | 
			
		||||
      "",
 | 
			
		||||
      "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.",
 | 
			
		||||
      "It will run every time your workspace starts. Use it to install personal packages!",
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -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"
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  executeScriptInContainer,
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
  testRequiredVariables,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
describe("vscode-desktop", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  testRequiredVariables(import.meta.dir, {
 | 
			
		||||
    agent_id: "foo",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("default output", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
    });
 | 
			
		||||
    expect(state.outputs.vscode_url.value).toBe(
 | 
			
		||||
      "vscode://coder.coder-remote/open?owner=default&workspace=default&token=$SESSION_TOKEN",
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,63 @@
 | 
			
		||||
import { describe, expect, it } from "bun:test";
 | 
			
		||||
import {
 | 
			
		||||
  executeScriptInContainer,
 | 
			
		||||
  runTerraformApply,
 | 
			
		||||
  runTerraformInit,
 | 
			
		||||
} from "../test";
 | 
			
		||||
 | 
			
		||||
describe("vscode-web", async () => {
 | 
			
		||||
  await runTerraformInit(import.meta.dir);
 | 
			
		||||
 | 
			
		||||
  // replaces testRequiredVariables due to license variable
 | 
			
		||||
  // may add a testRequiredVariablesWithLicense function later
 | 
			
		||||
  it("missing agent_id", async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      await runTerraformApply(import.meta.dir, {
 | 
			
		||||
        accept_license: "true",
 | 
			
		||||
      });
 | 
			
		||||
    } catch (ex) {
 | 
			
		||||
      expect(ex.message).toContain('input variable "agent_id" is not set');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("invalid license_agreement", async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      await runTerraformApply(import.meta.dir, {
 | 
			
		||||
        agent_id: "foo",
 | 
			
		||||
      });
 | 
			
		||||
    } catch (ex) {
 | 
			
		||||
      expect(ex.message).toContain(
 | 
			
		||||
        "You must accept the VS Code license agreement by setting accept_license=true",
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("fails without curl", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
      accept_license: "true",
 | 
			
		||||
    });
 | 
			
		||||
    const output = await executeScriptInContainer(state, "alpine");
 | 
			
		||||
    expect(output.exitCode).toBe(1);
 | 
			
		||||
    expect(output.stdout).toEqual([
 | 
			
		||||
      "\u001b[0;1mInstalling vscode-cli!",
 | 
			
		||||
      "Failed to install vscode-cli:", // TODO: manually test error log
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("runs with curl", async () => {
 | 
			
		||||
    const state = await runTerraformApply(import.meta.dir, {
 | 
			
		||||
      agent_id: "foo",
 | 
			
		||||
      accept_license: "true",
 | 
			
		||||
    });
 | 
			
		||||
    const output = await executeScriptInContainer(state, "alpine/curl");
 | 
			
		||||
    expect(output.exitCode).toBe(0);
 | 
			
		||||
    expect(output.stdout).toEqual([
 | 
			
		||||
      "\u001b[0;1mInstalling vscode-cli!",
 | 
			
		||||
      "🥳 vscode-cli has been installed.",
 | 
			
		||||
      "",
 | 
			
		||||
      "👷 Running /tmp/vscode-cli/bin/code serve-web --port 13338 --without-connection-token --accept-server-license-terms in the background...",
 | 
			
		||||
      "Check logs at /tmp/vscode-web.log!",
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue