diff --git a/.icons/github.svg b/.icons/github.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/.icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6aa77cf..557171e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ To create a new module, clone this repository and run: A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers. +The testing suite must be able to run docker containers with the `--network=host` flag, which typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop. + Reference existing `*.test.ts` files for implementation. ```shell diff --git a/github-upload-public-key/README.md b/github-upload-public-key/README.md new file mode 100644 index 0000000..d776619 --- /dev/null +++ b/github-upload-public-key/README.md @@ -0,0 +1,53 @@ +--- +display_name: Github Upload Public Key +description: Automates uploading Coder public key to Github so users don't have to. +icon: ../.icons/github.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# github-upload-public-key + +Templates that utilize Github External Auth can automatically ensure that the Coder public key is uploaded to Github so that users can clone repositories without needing to upload the public key themselves. + +```tf +module "github-upload-public-key" { + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.13" + agent_id = coder_agent.example.id +} +``` + +# Requirements + +This module requires `curl` and `jq` to be installed inside your workspace. + +Github External Auth must be enabled in the workspace for this module to work. The Github app that is configured for external auth must have both read and write permissions to "Git SSH keys" in order to upload the public key. Additionally, a Coder admin must also have the `admin:public_key` scope added to the external auth configuration of the Coder deployment. For example: + +``` +CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID" +CODER_EXTERNAL_AUTH_0_TYPE=github +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key" +``` + +Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes. + +# Example + +Using a coder github external auth with a non-default id: (default is `github`) + +```tf +data "coder_external_auth" "github" { + id = "myauthid" +} + +module "github-upload-public-key" { + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.13" + agent_id = coder_agent.example.id + external_auth_id = data.coder_external_auth.github.id +} +``` diff --git a/github-upload-public-key/main.test.ts b/github-upload-public-key/main.test.ts new file mode 100644 index 0000000..fb1b977 --- /dev/null +++ b/github-upload-public-key/main.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test"; +import { + createJSONResponse, + execContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeCoder, +} from "../test"; +import { Server, serve } from "bun"; + +describe("github-upload-public-key", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("creates new key if one does not exist", async () => { + const { instance, id, server } = await setupContainer(); + await writeCoder(id, "echo foo"); + let exec = await execContainer(id, [ + "env", + "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1), + "GITHUB_API_URL=" + server.url.toString().slice(0, -1), + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key has been added to GitHub!", + ); + expect(exec.exitCode).toBe(0); + // we need to increase timeout to pull the container + }, 15000); + + it("does nothing if one already exists", async () => { + const { instance, id, server } = await setupContainer(); + // use keyword to make server return a existing key + await writeCoder(id, "echo findkey"); + let exec = await execContainer(id, [ + "env", + "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1), + "GITHUB_API_URL=" + server.url.toString().slice(0, -1), + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key is already on GitHub!", + ); + expect(exec.exitCode).toBe(0); + }); +}); + +const setupContainer = async ( + image = "lorello/alpine-bash", + vars: Record = {}, +) => { + const server = await setupServer(); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance, server }; +}; + +const setupServer = async (): Promise => { + let url: URL; + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/v2/users/me/gitsshkey") { + return createJSONResponse({ + public_key: "exists", + }); + } + + if (url.pathname === "/user/keys") { + if (req.method === "POST") { + return createJSONResponse( + { + key: "created", + }, + 201, + ); + } + + // case: key already exists + if (req.headers.get("Authorization") == "Bearer findkey") { + return createJSONResponse([ + { + key: "foo", + }, + { + key: "exists", + }, + ]); + } + + // case: key does not exist + return createJSONResponse([ + { + key: "foo", + }, + ]); + } + + return createJSONResponse( + { + error: "not_found", + }, + 404, + ); + }, + port: 0, + }); + + return fakeSlackHost; +}; diff --git a/github-upload-public-key/main.tf b/github-upload-public-key/main.tf new file mode 100644 index 0000000..b35d246 --- /dev/null +++ b/github-upload-public-key/main.tf @@ -0,0 +1,42 @@ +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 "external_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "github_api_url" { + type = string + description = "The URL of the GitHub instance." + default = "https://api.github.com" +} + +data "coder_workspace" "me" {} + +resource "coder_script" "github_upload_public_key" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_OWNER_SESSION_TOKEN : data.coder_workspace.me.owner_session_token, + CODER_ACCESS_URL : data.coder_workspace.me.access_url, + CODER_EXTERNAL_AUTH_ID : var.external_auth_id, + GITHUB_API_URL : var.github_api_url, + }) + display_name = "Github Upload Public Key" + icon = "/icon/github.svg" + run_on_start = true +} \ No newline at end of file diff --git a/github-upload-public-key/run.sh b/github-upload-public-key/run.sh new file mode 100755 index 0000000..a382a40 --- /dev/null +++ b/github-upload-public-key/run.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +if [ -z "$CODER_ACCESS_URL" ]; then + if [ -z "${CODER_ACCESS_URL}" ]; then + echo "CODER_ACCESS_URL is empty!" + exit 1 + fi + CODER_ACCESS_URL=${CODER_ACCESS_URL} +fi + +if [ -z "$CODER_OWNER_SESSION_TOKEN" ]; then + if [ -z "${CODER_OWNER_SESSION_TOKEN}" ]; then + echo "CODER_OWNER_SESSION_TOKEN is empty!" + exit 1 + fi + CODER_OWNER_SESSION_TOKEN=${CODER_OWNER_SESSION_TOKEN} +fi + +if [ -z "$CODER_EXTERNAL_AUTH_ID" ]; then + if [ -z "${CODER_EXTERNAL_AUTH_ID}" ]; then + echo "CODER_EXTERNAL_AUTH_ID is empty!" + exit 1 + fi + CODER_EXTERNAL_AUTH_ID=${CODER_EXTERNAL_AUTH_ID} +fi + +if [ -z "$GITHUB_API_URL" ]; then + if [ -z "${GITHUB_API_URL}" ]; then + echo "GITHUB_API_URL is empty!" + exit 1 + fi + GITHUB_API_URL=${GITHUB_API_URL} +fi + +echo "Fetching GitHub token..." +GITHUB_TOKEN=$(coder external-auth access-token $CODER_EXTERNAL_AUTH_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Github to automatically upload Coder public key:\n$GITHUB_TOKEN\n" + exit 1 +fi + +echo "Fetching public key from Coder..." +PUBLIC_KEY_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H 'accept: application/json' \ + -H "cookie: coder_session_token=$CODER_OWNER_SESSION_TOKEN" \ + "$CODER_ACCESS_URL/api/v2/users/me/gitsshkey" +) +PUBLIC_KEY_RESPONSE_STATUS=$(tail -n1 <<< "$PUBLIC_KEY_RESPONSE") +PUBLIC_KEY_BODY=$(sed \$d <<< "$PUBLIC_KEY_RESPONSE") + +if [ "$PUBLIC_KEY_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $PUBLIC_KEY_RESPONSE_STATUS!" + echo "$PUBLIC_KEY_BODY" + exit 1 +fi +PUBLIC_KEY=$(jq -r '.public_key' <<< "$PUBLIC_KEY_BODY") +if [ -z "$PUBLIC_KEY" ]; then + echo "No Coder public SSH key found!" + exit 1 +fi + +echo "Fetching public keys from GitHub..." +GITHUB_KEYS_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys +) +GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE") +GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE") + +if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!" + echo "$GITHUB_KEYS_RESPONSE_BODY" + exit 1 +fi + +GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY") + +if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then + echo "Your Coder public key is already on GitHub!" + exit 0 +fi + +echo "Your Coder public key is not in GitHub. Adding it now..." +CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces" +UPLOAD_RESPONSE=$( + curl -L -s \ + -X POST \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys \ + -d "{\"title\":\"$CODER_PUBLIC_KEY_NAME\",\"key\":\"$PUBLIC_KEY\"}" +) +UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE") +UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE") + +if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then + echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!" + echo "$UPLOAD_RESPONSE_BODY" + exit 1 +fi + +echo "Your Coder public key has been added to GitHub!" diff --git a/slackme/main.test.ts b/slackme/main.test.ts index 53c6b2e..402a690 100644 --- a/slackme/main.test.ts +++ b/slackme/main.test.ts @@ -8,6 +8,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, + writeCoder, } from "../test"; describe("slackme", async () => { @@ -119,15 +120,6 @@ const setupContainer = async ( 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; diff --git a/test.ts b/test.ts index 97416cf..c2eb65e 100644 --- a/test.ts +++ b/test.ts @@ -222,4 +222,13 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { }, status: statusCode, }) -} \ No newline at end of file +} + +export 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); +};