Skip to content

Commit cd0c730

Browse files
authored
Merge pull request coder#241 from coder/f0ssel/github-key
feat: Add github-upload-public-key module
2 parents 9062b4c + 873207f commit cd0c730

File tree

8 files changed

+347
-10
lines changed

8 files changed

+347
-10
lines changed

.icons/github.svg

Lines changed: 1 addition & 0 deletions
Loading

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ To create a new module, clone this repository and run:
1010

1111
A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
1212

13+
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.
14+
1315
Reference existing `*.test.ts` files for implementation.
1416

1517
```shell

github-upload-public-key/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
display_name: Github Upload Public Key
3+
description: Automates uploading Coder public key to Github so users don't have to.
4+
icon: ../.icons/github.svg
5+
maintainer_github: coder
6+
verified: true
7+
tags: [helper, git]
8+
---
9+
10+
# github-upload-public-key
11+
12+
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.
13+
14+
```tf
15+
module "github-upload-public-key" {
16+
source = "registry.coder.com/modules/github-upload-public-key/coder"
17+
version = "1.0.13"
18+
agent_id = coder_agent.example.id
19+
}
20+
```
21+
22+
# Requirements
23+
24+
This module requires `curl` and `jq` to be installed inside your workspace.
25+
26+
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:
27+
28+
```
29+
CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID"
30+
CODER_EXTERNAL_AUTH_0_TYPE=github
31+
CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
32+
CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
33+
CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key"
34+
```
35+
36+
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.
37+
38+
# Example
39+
40+
Using a coder github external auth with a non-default id: (default is `github`)
41+
42+
```tf
43+
data "coder_external_auth" "github" {
44+
id = "myauthid"
45+
}
46+
47+
module "github-upload-public-key" {
48+
source = "registry.coder.com/modules/github-upload-public-key/coder"
49+
version = "1.0.13"
50+
agent_id = coder_agent.example.id
51+
external_auth_id = data.coder_external_auth.github.id
52+
}
53+
```

github-upload-public-key/main.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
createJSONResponse,
4+
execContainer,
5+
findResourceInstance,
6+
runContainer,
7+
runTerraformApply,
8+
runTerraformInit,
9+
testRequiredVariables,
10+
writeCoder,
11+
} from "../test";
12+
import { Server, serve } from "bun";
13+
14+
describe("github-upload-public-key", async () => {
15+
await runTerraformInit(import.meta.dir);
16+
17+
testRequiredVariables(import.meta.dir, {
18+
agent_id: "foo",
19+
});
20+
21+
it("creates new key if one does not exist", async () => {
22+
const { instance, id, server } = await setupContainer();
23+
await writeCoder(id, "echo foo");
24+
let exec = await execContainer(id, [
25+
"env",
26+
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
27+
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
28+
"CODER_OWNER_SESSION_TOKEN=foo",
29+
"CODER_EXTERNAL_AUTH_ID=github",
30+
"bash",
31+
"-c",
32+
instance.script,
33+
]);
34+
expect(exec.stdout).toContain(
35+
"Your Coder public key has been added to GitHub!",
36+
);
37+
expect(exec.exitCode).toBe(0);
38+
// we need to increase timeout to pull the container
39+
}, 15000);
40+
41+
it("does nothing if one already exists", async () => {
42+
const { instance, id, server } = await setupContainer();
43+
// use keyword to make server return a existing key
44+
await writeCoder(id, "echo findkey");
45+
let exec = await execContainer(id, [
46+
"env",
47+
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
48+
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
49+
"CODER_OWNER_SESSION_TOKEN=foo",
50+
"CODER_EXTERNAL_AUTH_ID=github",
51+
"bash",
52+
"-c",
53+
instance.script,
54+
]);
55+
expect(exec.stdout).toContain(
56+
"Your Coder public key is already on GitHub!",
57+
);
58+
expect(exec.exitCode).toBe(0);
59+
});
60+
});
61+
62+
const setupContainer = async (
63+
image = "lorello/alpine-bash",
64+
vars: Record<string, string> = {},
65+
) => {
66+
const server = await setupServer();
67+
const state = await runTerraformApply(import.meta.dir, {
68+
agent_id: "foo",
69+
...vars,
70+
});
71+
const instance = findResourceInstance(state, "coder_script");
72+
const id = await runContainer(image);
73+
return { id, instance, server };
74+
};
75+
76+
const setupServer = async (): Promise<Server> => {
77+
let url: URL;
78+
const fakeSlackHost = serve({
79+
fetch: (req) => {
80+
url = new URL(req.url);
81+
if (url.pathname === "/api/v2/users/me/gitsshkey") {
82+
return createJSONResponse({
83+
public_key: "exists",
84+
});
85+
}
86+
87+
if (url.pathname === "/user/keys") {
88+
if (req.method === "POST") {
89+
return createJSONResponse(
90+
{
91+
key: "created",
92+
},
93+
201,
94+
);
95+
}
96+
97+
// case: key already exists
98+
if (req.headers.get("Authorization") == "Bearer findkey") {
99+
return createJSONResponse([
100+
{
101+
key: "foo",
102+
},
103+
{
104+
key: "exists",
105+
},
106+
]);
107+
}
108+
109+
// case: key does not exist
110+
return createJSONResponse([
111+
{
112+
key: "foo",
113+
},
114+
]);
115+
}
116+
117+
return createJSONResponse(
118+
{
119+
error: "not_found",
120+
},
121+
404,
122+
);
123+
},
124+
port: 0,
125+
});
126+
127+
return fakeSlackHost;
128+
};

github-upload-public-key/main.tf

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 "external_auth_id" {
18+
type = string
19+
description = "The ID of the GitHub external auth."
20+
default = "github"
21+
}
22+
23+
variable "github_api_url" {
24+
type = string
25+
description = "The URL of the GitHub instance."
26+
default = "https://api.github.com"
27+
}
28+
29+
data "coder_workspace" "me" {}
30+
31+
resource "coder_script" "github_upload_public_key" {
32+
agent_id = var.agent_id
33+
script = templatefile("${path.module}/run.sh", {
34+
CODER_OWNER_SESSION_TOKEN : data.coder_workspace.me.owner_session_token,
35+
CODER_ACCESS_URL : data.coder_workspace.me.access_url,
36+
CODER_EXTERNAL_AUTH_ID : var.external_auth_id,
37+
GITHUB_API_URL : var.github_api_url,
38+
})
39+
display_name = "Github Upload Public Key"
40+
icon = "/icon/github.svg"
41+
run_on_start = true
42+
}

github-upload-public-key/run.sh

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env bash
2+
3+
if [ -z "$CODER_ACCESS_URL" ]; then
4+
if [ -z "${CODER_ACCESS_URL}" ]; then
5+
echo "CODER_ACCESS_URL is empty!"
6+
exit 1
7+
fi
8+
CODER_ACCESS_URL=${CODER_ACCESS_URL}
9+
fi
10+
11+
if [ -z "$CODER_OWNER_SESSION_TOKEN" ]; then
12+
if [ -z "${CODER_OWNER_SESSION_TOKEN}" ]; then
13+
echo "CODER_OWNER_SESSION_TOKEN is empty!"
14+
exit 1
15+
fi
16+
CODER_OWNER_SESSION_TOKEN=${CODER_OWNER_SESSION_TOKEN}
17+
fi
18+
19+
if [ -z "$CODER_EXTERNAL_AUTH_ID" ]; then
20+
if [ -z "${CODER_EXTERNAL_AUTH_ID}" ]; then
21+
echo "CODER_EXTERNAL_AUTH_ID is empty!"
22+
exit 1
23+
fi
24+
CODER_EXTERNAL_AUTH_ID=${CODER_EXTERNAL_AUTH_ID}
25+
fi
26+
27+
if [ -z "$GITHUB_API_URL" ]; then
28+
if [ -z "${GITHUB_API_URL}" ]; then
29+
echo "GITHUB_API_URL is empty!"
30+
exit 1
31+
fi
32+
GITHUB_API_URL=${GITHUB_API_URL}
33+
fi
34+
35+
echo "Fetching GitHub token..."
36+
GITHUB_TOKEN=$(coder external-auth access-token $CODER_EXTERNAL_AUTH_ID)
37+
if [ $? -ne 0 ]; then
38+
printf "Authenticate with Github to automatically upload Coder public key:\n$GITHUB_TOKEN\n"
39+
exit 1
40+
fi
41+
42+
echo "Fetching public key from Coder..."
43+
PUBLIC_KEY_RESPONSE=$(
44+
curl -L -s \
45+
-w "\n%%{http_code}" \
46+
-H 'accept: application/json' \
47+
-H "cookie: coder_session_token=$CODER_OWNER_SESSION_TOKEN" \
48+
"$CODER_ACCESS_URL/api/v2/users/me/gitsshkey"
49+
)
50+
PUBLIC_KEY_RESPONSE_STATUS=$(tail -n1 <<< "$PUBLIC_KEY_RESPONSE")
51+
PUBLIC_KEY_BODY=$(sed \$d <<< "$PUBLIC_KEY_RESPONSE")
52+
53+
if [ "$PUBLIC_KEY_RESPONSE_STATUS" -ne 200 ]; then
54+
echo "Failed to fetch Coder public SSH key with status code $PUBLIC_KEY_RESPONSE_STATUS!"
55+
echo "$PUBLIC_KEY_BODY"
56+
exit 1
57+
fi
58+
PUBLIC_KEY=$(jq -r '.public_key' <<< "$PUBLIC_KEY_BODY")
59+
if [ -z "$PUBLIC_KEY" ]; then
60+
echo "No Coder public SSH key found!"
61+
exit 1
62+
fi
63+
64+
echo "Fetching public keys from GitHub..."
65+
GITHUB_KEYS_RESPONSE=$(
66+
curl -L -s \
67+
-w "\n%%{http_code}" \
68+
-H "Accept: application/vnd.github+json" \
69+
-H "Authorization: Bearer $GITHUB_TOKEN" \
70+
-H "X-GitHub-Api-Version: 2022-11-28" \
71+
$GITHUB_API_URL/user/keys
72+
)
73+
GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE")
74+
GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE")
75+
76+
if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then
77+
echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!"
78+
echo "$GITHUB_KEYS_RESPONSE_BODY"
79+
exit 1
80+
fi
81+
82+
GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY")
83+
84+
if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then
85+
echo "Your Coder public key is already on GitHub!"
86+
exit 0
87+
fi
88+
89+
echo "Your Coder public key is not in GitHub. Adding it now..."
90+
CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces"
91+
UPLOAD_RESPONSE=$(
92+
curl -L -s \
93+
-X POST \
94+
-w "\n%%{http_code}" \
95+
-H "Accept: application/vnd.github+json" \
96+
-H "Authorization: Bearer $GITHUB_TOKEN" \
97+
-H "X-GitHub-Api-Version: 2022-11-28" \
98+
$GITHUB_API_URL/user/keys \
99+
-d "{\"title\":\"$CODER_PUBLIC_KEY_NAME\",\"key\":\"$PUBLIC_KEY\"}"
100+
)
101+
UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE")
102+
UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE")
103+
104+
if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then
105+
echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!"
106+
echo "$UPLOAD_RESPONSE_BODY"
107+
exit 1
108+
fi
109+
110+
echo "Your Coder public key has been added to GitHub!"

slackme/main.test.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
runTerraformApply,
99
runTerraformInit,
1010
testRequiredVariables,
11+
writeCoder,
1112
} from "../test";
1213

1314
describe("slackme", async () => {
@@ -119,15 +120,6 @@ const setupContainer = async (
119120
return { id, instance };
120121
};
121122

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-
131123
const assertSlackMessage = async (opts: {
132124
command: string;
133125
format?: string;

test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,13 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => {
222222
},
223223
status: statusCode,
224224
})
225-
}
225+
}
226+
227+
export const writeCoder = async (id: string, script: string) => {
228+
const exec = await execContainer(id, [
229+
"sh",
230+
"-c",
231+
`echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
232+
]);
233+
expect(exec.exitCode).toBe(0);
234+
};

0 commit comments

Comments
 (0)