Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3eee866d1 | ||
|
|
bf175a1247 | ||
|
|
8fd54e0e78 | ||
|
|
e8ee02c044 | ||
|
|
aebdc9b434 | ||
|
|
d98bfcb20b | ||
|
|
894e507bb3 | ||
|
|
3f8f6181e0 | ||
|
|
b23d85327c | ||
|
|
a8580fe6b9 | ||
|
|
49f060549e | ||
|
|
b4153a6aaa | ||
|
|
13a8877791 | ||
|
|
fd2f91c043 | ||
|
|
c59eb0c0cc | ||
|
|
a381c3ee29 | ||
|
|
d9d1be08a3 | ||
|
|
7a8483d816 | ||
|
|
ec2c8edfb2 | ||
|
|
78f91a542a | ||
|
|
78c948094d | ||
|
|
16f96d3693 | ||
|
|
8262b29063 | ||
|
|
4ab72575ac | ||
|
|
f369697112 | ||
|
|
f82c7fd7a1 | ||
|
|
05a20a9e1f | ||
|
|
90e15cd90c | ||
|
|
5869eb86d4 | ||
|
|
25c90001f4 | ||
|
|
6409ee2bba | ||
|
|
7d366ff92a | ||
|
|
de00f6334f | ||
|
|
264584e673 | ||
|
|
83ecba2293 | ||
|
|
b2807640aa | ||
|
|
33d44fdf17 | ||
|
|
f335cd343d | ||
|
|
aebf095075 | ||
|
|
b283ac3129 | ||
|
|
5f418c3253 | ||
|
|
b09c4cb084 | ||
|
|
8aff87fdf7 | ||
|
|
f3c30abeb4 | ||
|
|
a9a75b675f | ||
|
|
ef4c87e48e | ||
|
|
1a0a8659cc | ||
|
|
c7a4fced4c | ||
|
|
5ec1b207d1 | ||
|
|
702271133f | ||
|
|
652fc6b84f | ||
|
|
8195cf4453 | ||
|
|
d5cfadb4e7 | ||
|
|
fba0f842a9 | ||
|
|
14e3fc5b6b | ||
|
|
0b6975c266 | ||
|
|
d530d68b12 | ||
|
|
047ccd67ca | ||
|
|
c7aa8253e3 | ||
|
|
452f41aa86 | ||
|
|
29209d546e | ||
|
|
aab5e55663 | ||
|
|
ff96b3f653 | ||
|
|
20795aa2b6 | ||
|
|
45456ab394 | ||
|
|
c652dbe320 | ||
|
|
4021d856ba | ||
|
|
72eaf8a9e1 | ||
|
|
249cb2fe9e | ||
|
|
49cff4b2aa | ||
|
|
c6b457e7fe | ||
|
|
beaa33b682 | ||
|
|
0d7bc37f9c | ||
|
|
dcd605c52e | ||
|
|
f5d41520cf | ||
|
|
cd0c730c95 | ||
|
|
873207fddf | ||
|
|
282e1f8c57 | ||
|
|
c068082e6b | ||
|
|
85e73c2071 | ||
|
|
4bdb428244 | ||
|
|
daed803530 | ||
|
|
a239212f0b | ||
|
|
67fef297da | ||
|
|
aced7547bc | ||
|
|
36fa871e7b | ||
|
|
46bf422d61 | ||
|
|
180e10c3ee | ||
|
|
a45706ad3a | ||
|
|
5030fcb988 | ||
|
|
cff60c4a7e | ||
|
|
5a33af28ac | ||
|
|
428f386c4c | ||
|
|
2e43788584 | ||
|
|
e8ce194ff7 | ||
|
|
1273378ca8 | ||
|
|
edc163b5f2 | ||
|
|
c9e418aaf5 | ||
|
|
9062b4c004 | ||
|
|
b2e87ef038 | ||
|
|
d4db52017d | ||
|
|
c36f4e03d7 | ||
|
|
443485a2d7 | ||
|
|
b686f2dbd5 | ||
|
|
76c60e9971 | ||
|
|
b0d6224e23 | ||
|
|
c50c4259d9 | ||
|
|
b93471a381 | ||
|
|
53083a5718 | ||
|
|
7de78d2ef5 | ||
|
|
89135671b2 | ||
|
|
ac648cc0a9 | ||
|
|
748a180ac3 | ||
|
|
ec922c7c3d | ||
|
|
9f8eee55b2 | ||
|
|
0e7644b284 | ||
|
|
bf06e8d3ac | ||
|
|
12fd16f701 | ||
|
|
1197e6bf0d | ||
|
|
c5c521fabd |
5
.icons/desktop.svg
Normal file
5
.icons/desktop.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M31 6V22C31 23.65 29.65 25 28 25H4C2.35 25 1 23.65 1 22V6C1 4.35 2.35 3 4 3H28C29.65 3 31 4.35 31 6Z" fill="#2197F3"/>
|
||||
<path d="M21 27H17V24C17 23.4478 16.5522 23 16 23C15.4478 23 15 23.4478 15 24V27H11C10.4478 27 10 27.4478 10 28C10 28.5522 10.4478 29 11 29H21C21.5522 29 22 28.5522 22 28C22 27.4478 21.5522 27 21 27Z" fill="#FFC10A"/>
|
||||
<path d="M31 17V22C31 23.65 29.65 25 28 25H4C2.35 25 1 23.65 1 22V17H31Z" fill="#3F51B5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 540 B |
1
.icons/github.svg
Normal file
1
.icons/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
@@ -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
|
||||
|
||||
@@ -15,57 +15,9 @@ A module that adds Apache Airflow in your Coder template.
|
||||
```tf
|
||||
module "airflow" {
|
||||
source = "registry.coder.com/modules/apache-airflow/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
version = "1.0.13"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1
|
||||
|
||||
Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
|
||||
|
||||
```tf
|
||||
module "airflow" {
|
||||
source = "registry.coder.com/modules/apache-airflow/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = [
|
||||
"dracula-theme.theme-dracula"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Enter the `<author>.<name>` into the extensions array and code-server will automatically install on start.
|
||||
|
||||
### Example 2
|
||||
|
||||
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
|
||||
|
||||
```tf
|
||||
module "airflow" {
|
||||
source = "registry.coder.com/modules/apache-airflow/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
"workbench.colorTheme" = "Dracula"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3
|
||||
|
||||
Run code-server in the background, don't fetch it from GitHub:
|
||||
|
||||
```tf
|
||||
module "airflow" {
|
||||
source = "registry.coder.com/modules/apache-airflow/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -28,7 +28,7 @@ module "code-server" {
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
install_version = "4.8.3"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = [
|
||||
"dracula-theme.theme-dracula"
|
||||
@@ -58,7 +58,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -74,7 +74,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
}
|
||||
@@ -89,7 +89,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
use_cached = true
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
@@ -101,7 +101,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
```tf
|
||||
module "code-server" {
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
|
||||
@@ -95,12 +95,24 @@ variable "use_cached" {
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_cached_extensions" {
|
||||
type = bool
|
||||
description = "Uses cached copy of extensions, otherwise do a forced upgrade"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "extensions_dir" {
|
||||
type = string
|
||||
description = "Override the directory to store extensions in."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "auto_install_extensions" {
|
||||
type = bool
|
||||
description = "Automatically install recommended extensions when code-server starts."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_script" "code-server" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "code-server"
|
||||
@@ -116,7 +128,10 @@ resource "coder_script" "code-server" {
|
||||
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
|
||||
OFFLINE : var.offline,
|
||||
USE_CACHED : var.use_cached,
|
||||
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
|
||||
EXTENSIONS_DIR : var.extensions_dir,
|
||||
FOLDER : var.folder,
|
||||
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
|
||||
@@ -25,36 +25,53 @@ if [ ! -f ~/.local/share/code-server/User/settings.json ]; then
|
||||
echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json
|
||||
fi
|
||||
|
||||
# Check if code-server is already installed for offline or cached mode
|
||||
if [ -f "$CODE_SERVER" ]; then
|
||||
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
|
||||
# Check if code-server is already installed for offline
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
if [ -f "$CODE_SERVER" ]; then
|
||||
echo "🥳 Found a copy of code-server"
|
||||
run_code_server
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# Offline mode always expects a copy of code-server to be present
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
# Offline mode always expects a copy of code-server to be present
|
||||
echo "Failed to find a copy of code-server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "$${BOLD}Installing code-server!\n"
|
||||
# If there is no cached install OR we don't want to use a cached install
|
||||
if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then
|
||||
printf "$${BOLD}Installing code-server!\n"
|
||||
|
||||
ARGS=(
|
||||
"--method=standalone"
|
||||
"--prefix=${INSTALL_PREFIX}"
|
||||
)
|
||||
if [ -n "${VERSION}" ]; then
|
||||
ARGS+=("--version=${VERSION}")
|
||||
ARGS=(
|
||||
"--method=standalone"
|
||||
"--prefix=${INSTALL_PREFIX}"
|
||||
)
|
||||
if [ -n "${VERSION}" ]; then
|
||||
ARGS+=("--version=${VERSION}")
|
||||
fi
|
||||
|
||||
output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install code-server: $output"
|
||||
exit 1
|
||||
fi
|
||||
printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n"
|
||||
fi
|
||||
|
||||
output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install code-server: $output"
|
||||
exit 1
|
||||
fi
|
||||
printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n"
|
||||
# Get the list of installed extensions...
|
||||
LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG)
|
||||
readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS"
|
||||
function extension_installed() {
|
||||
if [ "${USE_CACHED_EXTENSIONS}" != true ]; then
|
||||
return 1
|
||||
fi
|
||||
for _extension in "$${EXTENSIONS_ARRAY[@]}"; do
|
||||
if [ "$_extension" == "$1" ]; then
|
||||
echo "Extension $1 was already installed."
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install each extension...
|
||||
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
|
||||
@@ -62,12 +79,38 @@ for extension in "$${EXTENSIONLIST[@]}"; do
|
||||
if [ -z "$extension" ]; then
|
||||
continue
|
||||
fi
|
||||
if extension_installed "$extension"; then
|
||||
continue
|
||||
fi
|
||||
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
|
||||
output=$($CODE_SERVER "$EXTENSION_ARG" --install-extension "$extension")
|
||||
output=$($CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install extension: $extension: $output"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
if ! command -v jq > /dev/null; then
|
||||
echo "jq is required to install extensions from a workspace file."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
|
||||
for extension in $extensions; do
|
||||
if extension_installed "$extension"; then
|
||||
continue
|
||||
fi
|
||||
$CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
run_code_server
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically logs the user into Coder when creating their workspace.
|
||||
```tf
|
||||
module "coder-login" {
|
||||
source = "registry.coder.com/modules/coder-login/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.12"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@ variable "agent_id" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_script" "coder-login" {
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
CODER_USER_TOKEN : data.coder_workspace.me.owner_session_token,
|
||||
CODER_USER_TOKEN : data.coder_workspace_owner.me.session_token,
|
||||
CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url
|
||||
})
|
||||
display_name = "Coder Login"
|
||||
|
||||
@@ -9,16 +9,61 @@ tags: [helper]
|
||||
|
||||
# Dotfiles
|
||||
|
||||
Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io)! Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command.
|
||||
Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io).
|
||||
|
||||
This will prompt the user for their dotfiles repository URL on template creation using a `coder_parameter`.
|
||||
|
||||
Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command.
|
||||
|
||||
```tf
|
||||
module "dotfiles" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Apply dotfiles as the current user
|
||||
|
||||
```tf
|
||||
module "dotfiles" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
### Apply dotfiles as another user (only works if sudo is passwordless)
|
||||
|
||||
```tf
|
||||
module "dotfiles" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
```
|
||||
|
||||
### Apply the same dotfiles as the current user and root (the root dotfiles can only be applied if sudo is passwordless)
|
||||
|
||||
```tf
|
||||
module "dotfiles" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
}
|
||||
```
|
||||
|
||||
## Setting a default dotfiles repository
|
||||
|
||||
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
|
||||
@@ -26,7 +71,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
```tf
|
||||
module "dotfiles" {
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -16,10 +16,23 @@ variable "agent_id" {
|
||||
|
||||
variable "default_dotfiles_uri" {
|
||||
type = string
|
||||
description = "The default dotfiles URI if the workspace user does not provide one."
|
||||
description = "The default dotfiles URI if the workspace user does not provide one"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "coder_parameter_order" {
|
||||
type = number
|
||||
description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
|
||||
@@ -27,9 +40,11 @@ variable "coder_parameter_order" {
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_uri" {
|
||||
count = var.dotfiles_uri == null ? 1 : 0
|
||||
|
||||
type = "string"
|
||||
name = "dotfiles_uri"
|
||||
display_name = "Dotfiles URL (optional)"
|
||||
display_name = "Dotfiles URL"
|
||||
order = var.coder_parameter_order
|
||||
default = var.default_dotfiles_uri
|
||||
description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
|
||||
@@ -37,14 +52,17 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
icon = "/icon/dotfiles.svg"
|
||||
}
|
||||
|
||||
resource "coder_script" "personalize" {
|
||||
agent_id = var.agent_id
|
||||
script = <<-EOT
|
||||
DOTFILES_URI="${data.coder_parameter.dotfiles_uri.value}"
|
||||
if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee -a ~/.dotfiles.log
|
||||
fi
|
||||
EOT
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
}
|
||||
|
||||
resource "coder_script" "dotfiles" {
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
icon = "/icon/dotfiles.svg"
|
||||
run_on_start = true
|
||||
@@ -52,5 +70,5 @@ resource "coder_script" "personalize" {
|
||||
|
||||
output "dotfiles_uri" {
|
||||
description = "Dotfiles URI"
|
||||
value = data.coder_parameter.dotfiles_uri.value
|
||||
value = local.dotfiles_uri
|
||||
}
|
||||
|
||||
23
dotfiles/run.sh
Normal file
23
dotfiles/run.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
DOTFILES_URI="${DOTFILES_URI}"
|
||||
DOTFILES_USER="${DOTFILES_USER}"
|
||||
|
||||
if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
if [ -z "$DOTFILES_USER" ]; then
|
||||
DOTFILES_USER="$USER"
|
||||
fi
|
||||
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||
|
||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
else
|
||||
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
|
||||
# eval echo ~coder -> "/home/coder"
|
||||
# eval echo ~root -> "/root"
|
||||
|
||||
CODER_BIN=$(which coder)
|
||||
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
|
||||
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
|
||||
fi
|
||||
fi
|
||||
@@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's
|
||||
```tf
|
||||
module "git-config" {
|
||||
source = "registry.coder.com/modules/git-config/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -28,7 +28,7 @@ TODO: Add screenshot
|
||||
```tf
|
||||
module "git-config" {
|
||||
source = "registry.coder.com/modules/git-config/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
allow_email_change = true
|
||||
}
|
||||
@@ -41,7 +41,7 @@ TODO: Add screenshot
|
||||
```tf
|
||||
module "git-config" {
|
||||
source = "registry.coder.com/modules/git-config/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
allow_username_change = false
|
||||
allow_email_change = false
|
||||
|
||||
@@ -20,10 +20,13 @@ describe("git-config", async () => {
|
||||
});
|
||||
|
||||
const resources = state.resources;
|
||||
expect(resources).toHaveLength(3);
|
||||
expect(resources).toHaveLength(6);
|
||||
expect(resources).toMatchObject([
|
||||
{ type: "coder_workspace", name: "me" },
|
||||
{ type: "coder_workspace_owner", name: "me" },
|
||||
{ type: "coder_env", name: "git_author_email" },
|
||||
{ type: "coder_env", name: "git_author_name" },
|
||||
{ type: "coder_env", name: "git_commmiter_email" },
|
||||
{ type: "coder_env", name: "git_commmiter_name" },
|
||||
]);
|
||||
});
|
||||
@@ -35,12 +38,15 @@ describe("git-config", async () => {
|
||||
});
|
||||
|
||||
const resources = state.resources;
|
||||
expect(resources).toHaveLength(5);
|
||||
expect(resources).toHaveLength(8);
|
||||
expect(resources).toMatchObject([
|
||||
{ type: "coder_parameter", name: "user_email" },
|
||||
{ type: "coder_parameter", name: "username" },
|
||||
{ type: "coder_workspace", name: "me" },
|
||||
{ type: "coder_workspace_owner", name: "me" },
|
||||
{ type: "coder_env", name: "git_author_email" },
|
||||
{ type: "coder_env", name: "git_author_name" },
|
||||
{ type: "coder_env", name: "git_commmiter_email" },
|
||||
{ type: "coder_env", name: "git_commmiter_name" },
|
||||
]);
|
||||
});
|
||||
@@ -53,13 +59,14 @@ describe("git-config", async () => {
|
||||
allow_username_change: "false",
|
||||
allow_email_change: "false",
|
||||
},
|
||||
{ CODER_WORKSPACE_OWNER_EMAIL: "foo@emai.com" },
|
||||
{ CODER_WORKSPACE_OWNER_EMAIL: "foo@email.com" },
|
||||
);
|
||||
|
||||
const resources = state.resources;
|
||||
expect(resources).toHaveLength(5);
|
||||
expect(resources).toHaveLength(6);
|
||||
expect(resources).toMatchObject([
|
||||
{ type: "coder_workspace", name: "me" },
|
||||
{ type: "coder_workspace_owner", name: "me" },
|
||||
{ type: "coder_env", name: "git_author_email" },
|
||||
{ type: "coder_env", name: "git_author_name" },
|
||||
{ type: "coder_env", name: "git_commmiter_email" },
|
||||
@@ -75,12 +82,23 @@ describe("git-config", async () => {
|
||||
allow_email_change: "true",
|
||||
coder_parameter_order: order.toString(),
|
||||
});
|
||||
expect(state.resources).toHaveLength(5);
|
||||
const resources = state.resources;
|
||||
expect(resources).toHaveLength(8);
|
||||
expect(resources).toMatchObject([
|
||||
{ type: "coder_parameter", name: "user_email" },
|
||||
{ type: "coder_parameter", name: "username" },
|
||||
{ type: "coder_workspace", name: "me" },
|
||||
{ type: "coder_workspace_owner", name: "me" },
|
||||
{ type: "coder_env", name: "git_author_email" },
|
||||
{ type: "coder_env", name: "git_author_name" },
|
||||
{ type: "coder_env", name: "git_commmiter_email" },
|
||||
{ type: "coder_env", name: "git_commmiter_name" },
|
||||
]);
|
||||
// user_email order is the same as the order
|
||||
expect(state.resources[0].instances[0].attributes.order).toBe(order);
|
||||
expect(resources[0].instances[0].attributes.order).toBe(order);
|
||||
// username order is incremented by 1
|
||||
// @ts-ignore: Object is possibly 'null'.
|
||||
expect(state.resources[1].instances[0]?.attributes.order).toBe(order + 1);
|
||||
expect(resources[1].instances[0]?.attributes.order).toBe(order + 1);
|
||||
});
|
||||
|
||||
it("set custom order for coder_parameter for just username", async () => {
|
||||
@@ -91,9 +109,19 @@ describe("git-config", async () => {
|
||||
allow_username_change: "true",
|
||||
coder_parameter_order: order.toString(),
|
||||
});
|
||||
expect(state.resources).toHaveLength(4);
|
||||
const resources = state.resources;
|
||||
expect(resources).toHaveLength(7);
|
||||
expect(resources).toMatchObject([
|
||||
{ type: "coder_parameter", name: "username" },
|
||||
{ type: "coder_workspace", name: "me" },
|
||||
{ type: "coder_workspace_owner", name: "me" },
|
||||
{ type: "coder_env", name: "git_author_email" },
|
||||
{ type: "coder_env", name: "git_author_name" },
|
||||
{ type: "coder_env", name: "git_commmiter_email" },
|
||||
{ type: "coder_env", name: "git_commmiter_name" },
|
||||
]);
|
||||
// user_email was not created
|
||||
// username order is incremented by 1
|
||||
expect(state.resources[0].instances[0].attributes.order).toBe(order + 1);
|
||||
expect(resources[0].instances[0].attributes.order).toBe(order + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.13"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ variable "coder_parameter_order" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_parameter" "user_email" {
|
||||
count = var.allow_email_change ? 1 : 0
|
||||
@@ -59,25 +60,25 @@ data "coder_parameter" "username" {
|
||||
resource "coder_env" "git_author_name" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_AUTHOR_NAME"
|
||||
value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace.me.owner_name, data.coder_workspace.me.owner)
|
||||
value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
}
|
||||
|
||||
resource "coder_env" "git_commmiter_name" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_COMMITTER_NAME"
|
||||
value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace.me.owner_name, data.coder_workspace.me.owner)
|
||||
value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
}
|
||||
|
||||
resource "coder_env" "git_author_email" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_AUTHOR_EMAIL"
|
||||
value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email)
|
||||
count = data.coder_workspace.me.owner_email != "" ? 1 : 0
|
||||
value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email)
|
||||
count = data.coder_workspace_owner.me.email != "" ? 1 : 0
|
||||
}
|
||||
|
||||
resource "coder_env" "git_commmiter_email" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_COMMITTER_EMAIL"
|
||||
value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email)
|
||||
count = data.coder_workspace.me.owner_email != "" ? 1 : 0
|
||||
value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email)
|
||||
count = data.coder_workspace_owner.me.email != "" ? 1 : 0
|
||||
}
|
||||
|
||||
53
github-upload-public-key/README.md
Normal file
53
github-upload-public-key/README.md
Normal file
@@ -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.15"
|
||||
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.15"
|
||||
agent_id = coder_agent.example.id
|
||||
external_auth_id = data.coder_external_auth.github.id
|
||||
}
|
||||
```
|
||||
128
github-upload-public-key/main.test.ts
Normal file
128
github-upload-public-key/main.test.ts
Normal file
@@ -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<string, string> = {},
|
||||
) => {
|
||||
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<Server> => {
|
||||
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;
|
||||
};
|
||||
43
github-upload-public-key/main.tf
Normal file
43
github-upload-public-key/main.tf
Normal file
@@ -0,0 +1,43 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" {}
|
||||
data "coder_workspace_owner" "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_owner.me.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
|
||||
}
|
||||
110
github-upload-public-key/run.sh
Executable file
110
github-upload-public-key/run.sh
Executable file
@@ -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!"
|
||||
@@ -14,7 +14,7 @@ This module adds a JetBrains Gateway Button to open any workspace with a single
|
||||
```tf
|
||||
module "jetbrains_gateway" {
|
||||
source = "registry.coder.com/modules/jetbrains-gateway/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.13"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = "example"
|
||||
folder = "/home/coder/example"
|
||||
@@ -32,7 +32,7 @@ module "jetbrains_gateway" {
|
||||
```tf
|
||||
module "jetbrains_gateway" {
|
||||
source = "registry.coder.com/modules/jetbrains-gateway/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.13"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = "example"
|
||||
folder = "/home/coder/example"
|
||||
@@ -46,7 +46,7 @@ module "jetbrains_gateway" {
|
||||
```tf
|
||||
module "jetbrains_gateway" {
|
||||
source = "registry.coder.com/modules/jetbrains-gateway/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.13"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = "example"
|
||||
folder = "/home/coder/example"
|
||||
@@ -61,7 +61,7 @@ module "jetbrains_gateway" {
|
||||
```tf
|
||||
module "jetbrains_gateway" {
|
||||
source = "registry.coder.com/modules/jetbrains-gateway/coder"
|
||||
version = "1.0.12"
|
||||
version = "1.0.13"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = "example"
|
||||
folder = "/home/coder/example"
|
||||
|
||||
@@ -17,7 +17,7 @@ Install the JF CLI and authenticate package managers with Artifactory using OAut
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-oauth/coder"
|
||||
version = "1.0.5"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://example.jfrog.io"
|
||||
username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
|
||||
@@ -44,7 +44,7 @@ Configure the Python pip package manager to fetch packages from Artifactory whil
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-oauth/coder"
|
||||
version = "1.0.5"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://example.jfrog.io"
|
||||
username_field = "email"
|
||||
@@ -72,7 +72,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-oauth/coder"
|
||||
version = "1.0.5"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://example.jfrog.io"
|
||||
username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.12.4"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,12 @@ EOF
|
||||
|
||||
locals {
|
||||
# The username field to use for artifactory
|
||||
username = var.username_field == "email" ? data.coder_workspace.me.owner_email : data.coder_workspace.me.owner
|
||||
username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name
|
||||
jfrog_host = replace(var.jfrog_url, "https://", "")
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_external_auth" "jfrog" {
|
||||
id = var.external_auth_id
|
||||
@@ -87,7 +88,7 @@ resource "coder_script" "jfrog" {
|
||||
JFROG_HOST : local.jfrog_host,
|
||||
JFROG_SERVER_ID : var.jfrog_server_id,
|
||||
ARTIFACTORY_USERNAME : local.username,
|
||||
ARTIFACTORY_EMAIL : data.coder_workspace.me.owner_email,
|
||||
ARTIFACTORY_EMAIL : data.coder_workspace_owner.me.email,
|
||||
ARTIFACTORY_ACCESS_TOKEN : data.coder_external_auth.jfrog.access_token,
|
||||
CONFIGURE_CODE_SERVER : var.configure_code_server,
|
||||
REPOSITORY_NPM : lookup(var.package_managers, "npm", ""),
|
||||
|
||||
@@ -15,7 +15,7 @@ Install the JF CLI and authenticate package managers with Artifactory using Arti
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-token/coder"
|
||||
version = "1.0.10"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://XXXX.jfrog.io"
|
||||
artifactory_access_token = var.artifactory_access_token
|
||||
@@ -41,7 +41,7 @@ For detailed instructions, please see this [guide](https://coder.com/docs/v2/lat
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-token/coder"
|
||||
version = "1.0.10"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://YYYY.jfrog.io"
|
||||
artifactory_access_token = var.artifactory_access_token # An admin access token
|
||||
@@ -74,7 +74,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
|
||||
```tf
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-token/coder"
|
||||
version = "1.0.10"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://XXXX.jfrog.io"
|
||||
artifactory_access_token = var.artifactory_access_token
|
||||
@@ -94,11 +94,11 @@ data "coder_workspace" "me" {}
|
||||
|
||||
module "jfrog" {
|
||||
source = "registry.coder.com/modules/jfrog-token/coder"
|
||||
version = "1.0.10"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
jfrog_url = "https://XXXX.jfrog.io"
|
||||
artifactory_access_token = var.artifactory_access_token
|
||||
token_description = "Token for Coder workspace: ${data.coder_workspace.me.owner}/${data.coder_workspace.me.name}"
|
||||
token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}"
|
||||
package_managers = {
|
||||
"npm" : "npm",
|
||||
"go" : "go",
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.12.4"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
artifactory = {
|
||||
source = "registry.terraform.io/jfrog/artifactory"
|
||||
@@ -95,7 +95,7 @@ EOF
|
||||
|
||||
locals {
|
||||
# The username field to use for artifactory
|
||||
username = var.username_field == "email" ? data.coder_workspace.me.owner_email : data.coder_workspace.me.owner
|
||||
username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name
|
||||
jfrog_host = replace(var.jfrog_url, "https://", "")
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ resource "artifactory_scoped_token" "me" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_script" "jfrog" {
|
||||
agent_id = var.agent_id
|
||||
@@ -127,7 +128,7 @@ resource "coder_script" "jfrog" {
|
||||
JFROG_HOST : local.jfrog_host,
|
||||
JFROG_SERVER_ID : var.jfrog_server_id,
|
||||
ARTIFACTORY_USERNAME : local.username,
|
||||
ARTIFACTORY_EMAIL : data.coder_workspace.me.owner_email,
|
||||
ARTIFACTORY_EMAIL : data.coder_workspace_owner.me.email,
|
||||
ARTIFACTORY_ACCESS_TOKEN : artifactory_scoped_token.me.access_token,
|
||||
CONFIGURE_CODE_SERVER : var.configure_code_server,
|
||||
REPOSITORY_NPM : lookup(var.package_managers, "npm", ""),
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -18,9 +18,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
|
||||
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
|
||||
"version": "20.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
|
||||
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@@ -45,12 +45,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.4.tgz",
|
||||
"integrity": "sha512-E1kk0FNpxpkSSlCVXEa4HfyhSUEpKtCFrybPVyz1A4TEnBGy5bqqtSYkyjKTfKScdyZTBeFrTxJLiKGOIRWgwg==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.16.tgz",
|
||||
"integrity": "sha512-LpAh8dQe4NKvhSW390Rkftw0ume0moSkRm575e1JZ1PwI/dXjbXyjpntq+2F0bVW1FV7V6B8EfWx088b+dNurw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "~20.11.3",
|
||||
"@types/node": "~20.12.8",
|
||||
"@types/ws": "~8.5.10"
|
||||
}
|
||||
},
|
||||
@@ -144,10 +144,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
|
||||
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -235,15 +236,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
|
||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
|
||||
"integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
test.ts
103
test.ts
@@ -29,8 +29,10 @@ export const runContainer = async (
|
||||
return containerID.trim();
|
||||
};
|
||||
|
||||
// executeScriptInContainer finds the only "coder_script"
|
||||
// resource in the given state and runs it in a container.
|
||||
/**
|
||||
* Finds the only "coder_script" resource in the given state and runs it in a
|
||||
* container.
|
||||
*/
|
||||
export const executeScriptInContainer = async (
|
||||
state: TerraformState,
|
||||
image: string,
|
||||
@@ -76,27 +78,30 @@ export const execContainer = async (
|
||||
};
|
||||
};
|
||||
|
||||
type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
type TerraformStateResource = {
|
||||
type: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
instances: [{ attributes: Record<string, any> }];
|
||||
};
|
||||
|
||||
export interface TerraformState {
|
||||
outputs: {
|
||||
[key: string]: {
|
||||
type: string;
|
||||
value: any;
|
||||
};
|
||||
}
|
||||
resources: [
|
||||
{
|
||||
type: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
instances: [
|
||||
{
|
||||
attributes: {
|
||||
[key: string]: any;
|
||||
};
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
resources: [TerraformStateResource, ...TerraformStateResource[]];
|
||||
}
|
||||
|
||||
export interface CoderScriptAttributes {
|
||||
@@ -105,10 +110,11 @@ export interface CoderScriptAttributes {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// findResourceInstance finds the first instance of the given resource
|
||||
// type in the given state. If name is specified, it will only find
|
||||
// the instance with the given name.
|
||||
export const findResourceInstance = <T extends "coder_script" | string>(
|
||||
/**
|
||||
* finds the first instance of the given resource type in the given state. If
|
||||
* name is specified, it will only find the instance with the given name.
|
||||
*/
|
||||
export const findResourceInstance = <T extends string>(
|
||||
state: TerraformState,
|
||||
type: T,
|
||||
name?: string,
|
||||
@@ -131,12 +137,13 @@ export const findResourceInstance = <T extends "coder_script" | string>(
|
||||
return resource.instances[0].attributes as any;
|
||||
};
|
||||
|
||||
// testRequiredVariables creates a test-case
|
||||
// for each variable provided and ensures that
|
||||
// the apply fails without it.
|
||||
export const testRequiredVariables = (
|
||||
/**
|
||||
* Creates a test-case for each variable provided and ensures that the apply
|
||||
* fails without it.
|
||||
*/
|
||||
export const testRequiredVariables = <TVars extends Record<string, string>>(
|
||||
dir: string,
|
||||
vars: Record<string, string>,
|
||||
vars: TVars,
|
||||
) => {
|
||||
// Ensures that all required variables are provided.
|
||||
it("required variables", async () => {
|
||||
@@ -165,16 +172,25 @@ export const testRequiredVariables = (
|
||||
});
|
||||
};
|
||||
|
||||
// runTerraformApply runs terraform apply in the given directory
|
||||
// with the given variables. It is fine to run in parallel with
|
||||
// other instances of this function, as it uses a random state file.
|
||||
export const runTerraformApply = async (
|
||||
/**
|
||||
* Runs terraform apply in the given directory with the given variables. It is
|
||||
* fine to run in parallel with other instances of this function, as it uses a
|
||||
* random state file.
|
||||
*/
|
||||
export const runTerraformApply = async <
|
||||
TVars extends Readonly<Record<string, string | boolean>>,
|
||||
>(
|
||||
dir: string,
|
||||
vars: Record<string, string>,
|
||||
env: Record<string, string> = {},
|
||||
vars: TVars,
|
||||
env?: Record<string, string>,
|
||||
): Promise<TerraformState> => {
|
||||
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
|
||||
Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key]));
|
||||
|
||||
const combinedEnv = env === undefined ? {} : { ...env };
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
combinedEnv[`TF_VAR_${key}`] = String(value);
|
||||
}
|
||||
|
||||
const proc = spawn(
|
||||
[
|
||||
"terraform",
|
||||
@@ -188,22 +204,26 @@ export const runTerraformApply = async (
|
||||
],
|
||||
{
|
||||
cwd: dir,
|
||||
env,
|
||||
env: combinedEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const text = await readableStreamToText(proc.stderr);
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const content = await readFile(stateFile, "utf8");
|
||||
await unlink(stateFile);
|
||||
return JSON.parse(content);
|
||||
};
|
||||
|
||||
// runTerraformInit runs terraform init in the given directory.
|
||||
/**
|
||||
* Runs terraform init in the given directory.
|
||||
*/
|
||||
export const runTerraformInit = async (dir: string) => {
|
||||
const proc = spawn(["terraform", "init"], {
|
||||
cwd: dir,
|
||||
@@ -221,5 +241,14 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: statusCode,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
```tf
|
||||
module "vscode" {
|
||||
source = "registry.coder.com/modules/vscode-desktop/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -28,7 +28,7 @@ module "vscode" {
|
||||
```tf
|
||||
module "vscode" {
|
||||
source = "registry.coder.com/modules/vscode-desktop/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
|
||||
@@ -18,11 +18,57 @@ describe("vscode-desktop", async () => {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.outputs.vscode_url.value).toBe(
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&token=$SESSION_TOKEN",
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
|
||||
);
|
||||
|
||||
const resources: any = state.resources;
|
||||
expect(resources[1].instances[0].attributes.order).toBeNull();
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type == "coder_app" && res.name == "vscode",
|
||||
);
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app.instances.length).toBe(1);
|
||||
expect(coder_app.instances[0].attributes.order).toBeNull();
|
||||
});
|
||||
|
||||
it("adds folder", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/foo/bar",
|
||||
});
|
||||
expect(state.outputs.vscode_url.value).toBe(
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder and open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/foo/bar",
|
||||
open_recent: "true",
|
||||
});
|
||||
expect(state.outputs.vscode_url.value).toBe(
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder but not open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/foo/bar",
|
||||
openRecent: "false",
|
||||
});
|
||||
expect(state.outputs.vscode_url.value).toBe(
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
open_recent: "true",
|
||||
});
|
||||
expect(state.outputs.vscode_url.value).toBe(
|
||||
"vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("expect order to be set", async () => {
|
||||
@@ -31,7 +77,11 @@ describe("vscode-desktop", async () => {
|
||||
order: "22",
|
||||
});
|
||||
|
||||
const resources: any = state.resources;
|
||||
expect(resources[1].instances[0].attributes.order).toBe(22);
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type == "coder_app" && res.name == "vscode",
|
||||
);
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app.instances.length).toBe(1);
|
||||
expect(coder_app.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.17"
|
||||
version = ">= 0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ variable "folder" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "open_recent" {
|
||||
type = bool
|
||||
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
@@ -27,6 +33,7 @@ variable "order" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_app" "vscode" {
|
||||
agent_id = var.agent_id
|
||||
@@ -35,22 +42,17 @@ resource "coder_app" "vscode" {
|
||||
slug = "vscode"
|
||||
display_name = "VS Code Desktop"
|
||||
order = var.order
|
||||
url = var.folder != "" ? join("", [
|
||||
"vscode://coder.coder-remote/open?owner=",
|
||||
data.coder_workspace.me.owner,
|
||||
url = join("", [
|
||||
"vscode://coder.coder-remote/open",
|
||||
"?owner=",
|
||||
data.coder_workspace_owner.me.name,
|
||||
"&workspace=",
|
||||
data.coder_workspace.me.name,
|
||||
"&folder=",
|
||||
var.folder,
|
||||
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
|
||||
var.open_recent ? "&openRecent" : "",
|
||||
"&url=",
|
||||
data.coder_workspace.me.access_url,
|
||||
"&token=$SESSION_TOKEN",
|
||||
]) : join("", [
|
||||
"vscode://coder.coder-remote/open?owner=",
|
||||
data.coder_workspace.me.owner,
|
||||
"&workspace=",
|
||||
data.coder_workspace.me.name,
|
||||
"&token=$SESSION_TOKEN",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
source = "registry.coder.com/modules/vscode-web/coder"
|
||||
version = "1.0.11"
|
||||
version = "1.0.14"
|
||||
agent_id = coder_agent.example.id
|
||||
accept_license = true
|
||||
}
|
||||
@@ -29,7 +29,7 @@ module "vscode-web" {
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
source = "registry.coder.com/modules/vscode-web/coder"
|
||||
version = "1.0.11"
|
||||
version = "1.0.14"
|
||||
agent_id = coder_agent.example.id
|
||||
install_prefix = "/home/coder/.vscode-web"
|
||||
folder = "/home/coder"
|
||||
@@ -42,7 +42,7 @@ module "vscode-web" {
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
source = "registry.coder.com/modules/vscode-web/coder"
|
||||
version = "1.0.11"
|
||||
version = "1.0.14"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
|
||||
accept_license = true
|
||||
@@ -56,7 +56,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
source = "registry.coder.com/modules/vscode-web/coder"
|
||||
version = "1.0.11"
|
||||
version = "1.0.14"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
|
||||
42
vscode-web/main.test.ts
Normal file
42
vscode-web/main.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { runTerraformApply, runTerraformInit } from "../test";
|
||||
|
||||
describe("vscode-web", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
it("accept_license should be set to true", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "false",
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Invalid value for variable");
|
||||
});
|
||||
|
||||
it("use_cached and offline can not be used together", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
use_cached: "true",
|
||||
offline: "true",
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline and Use Cached can not be used together");
|
||||
});
|
||||
|
||||
it("offline and extensions can not be used together", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
offline: "true",
|
||||
extensions: '["1", "2"]',
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline mode does not allow extensions to be installed");
|
||||
});
|
||||
|
||||
// More tests depend on shebang refactors
|
||||
});
|
||||
@@ -97,6 +97,30 @@ variable "settings" {
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "offline" {
|
||||
type = bool
|
||||
description = "Just run VS Code Web in the background, don't fetch it from the internet."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_cached" {
|
||||
type = bool
|
||||
description = "Uses cached copy of VS Code Web in the background, otherwise fetches it from internet."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "extensions_dir" {
|
||||
type = string
|
||||
description = "Override the directory to store extensions in."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "auto_install_extensions" {
|
||||
type = bool
|
||||
description = "Automatically install recommended extensions when VS Code Web starts."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_script" "vscode-web" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "VS Code Web"
|
||||
@@ -109,8 +133,25 @@ resource "coder_script" "vscode-web" {
|
||||
TELEMETRY_LEVEL : var.telemetry_level,
|
||||
// This is necessary otherwise the quotes are stripped!
|
||||
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
|
||||
OFFLINE : var.offline,
|
||||
USE_CACHED : var.use_cached,
|
||||
EXTENSIONS_DIR : var.extensions_dir,
|
||||
FOLDER : var.folder,
|
||||
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = !var.offline || length(var.extensions) == 0
|
||||
error_message = "Offline mode does not allow extensions to be installed"
|
||||
}
|
||||
|
||||
precondition {
|
||||
condition = !var.offline || !var.use_cached
|
||||
error_message = "Offline and Use Cached can not be used together"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_app" "vscode-web" {
|
||||
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
EXTENSIONS=("${EXTENSIONS}")
|
||||
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
|
||||
|
||||
# Set extension directory
|
||||
EXTENSION_ARG=""
|
||||
if [ -n "${EXTENSIONS_DIR}" ]; then
|
||||
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
|
||||
fi
|
||||
|
||||
run_vscode_web() {
|
||||
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
|
||||
}
|
||||
|
||||
# Check if the settings file exists...
|
||||
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
|
||||
echo "⚙️ Creating settings file..."
|
||||
mkdir -p ~/.vscode-server/data/Machine
|
||||
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
|
||||
fi
|
||||
|
||||
# Check if vscode-server is already installed for offline or cached mode
|
||||
if [ -f "$VSCODE_WEB" ]; then
|
||||
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
|
||||
echo "🥳 Found a copy of VS Code Web"
|
||||
run_vscode_web
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# Offline mode always expects a copy of vscode-server to be present
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
echo "Failed to find a copy of VS Code Web"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create install prefix
|
||||
mkdir -p ${INSTALL_PREFIX}
|
||||
@@ -26,9 +60,7 @@ if [ $? -ne 0 ]; then
|
||||
echo "Failed to install Microsoft Visual Studio Code Server: $output"
|
||||
exit 1
|
||||
fi
|
||||
printf "$${BOLD}Microsoft Visual Studio Code Server has been installed.\n"
|
||||
|
||||
VSCODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
|
||||
printf "$${BOLD}VS Code Web has been installed.\n"
|
||||
|
||||
# Install each extension...
|
||||
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
|
||||
@@ -37,20 +69,31 @@ for extension in "$${EXTENSIONLIST[@]}"; do
|
||||
continue
|
||||
fi
|
||||
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
|
||||
output=$($VSCODE_SERVER --install-extension "$extension" --force)
|
||||
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install extension: $extension: $output"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if the settings file exists...
|
||||
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
|
||||
echo "⚙️ Creating settings file..."
|
||||
mkdir -p ~/.vscode-server/data/Machine
|
||||
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
|
||||
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
if ! command -v jq > /dev/null; then
|
||||
echo "jq is required to install extensions from a workspace file."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
|
||||
for extension in $extensions; do
|
||||
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "👷 Running ${INSTALL_PREFIX}/bin/code-server serve-local --port ${PORT} --host 127.0.0.1 --accept-server-license-terms serve-local --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
"${INSTALL_PREFIX}/bin/code-server" serve-local --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms serve-local --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
|
||||
run_vscode_web
|
||||
|
||||
57
windows-rdp/README.md
Normal file
57
windows-rdp/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
display_name: Windows RDP
|
||||
description: RDP Server and Web Client, powered by Devolutions Gateway
|
||||
icon: ../.icons/desktop.svg
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [windows, rdp, web, desktop]
|
||||
---
|
||||
|
||||
# Windows RDP
|
||||
|
||||
Enable Remote Desktop + a web based client on Windows workspaces, powered by [devolutions-gateway](https://github.com/Devolutions/devolutions-gateway).
|
||||
|
||||
```tf
|
||||
# AWS example. See below for examples of using this module with other providers
|
||||
module "windows_rdp" {
|
||||
source = "registry.coder.com/coder/module/windows-rdp"
|
||||
version = "1.0.16"
|
||||
count = data.coder_workspace.me.start_count
|
||||
agent_id = resource.coder_agent.main.id
|
||||
resource_id = resource.aws_instance.dev.id
|
||||
}
|
||||
```
|
||||
|
||||
## Video
|
||||
|
||||
https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02
|
||||
|
||||
## Examples
|
||||
|
||||
### With AWS
|
||||
|
||||
```tf
|
||||
module "windows_rdp" {
|
||||
source = "registry.coder.com/coder/module/windows-rdp"
|
||||
version = "1.0.16"
|
||||
count = data.coder_workspace.me.start_count
|
||||
agent_id = resource.coder_agent.main.id
|
||||
resource_id = resource.aws_instance.dev.id
|
||||
}
|
||||
```
|
||||
|
||||
### With Google Cloud
|
||||
|
||||
```tf
|
||||
module "windows_rdp" {
|
||||
source = "registry.coder.com/coder/module/windows-rdp"
|
||||
version = "1.0.16"
|
||||
count = data.coder_workspace.me.start_count
|
||||
agent_id = resource.coder_agent.main.id
|
||||
resource_id = resource.google_compute_instance.dev[0].id
|
||||
}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Test on Microsoft Azure.
|
||||
409
windows-rdp/devolutions-patch.js
Normal file
409
windows-rdp/devolutions-patch.js
Normal file
@@ -0,0 +1,409 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* @file Defines the custom logic for patching in UI changes/behavior into the
|
||||
* base Devolutions Gateway Angular app.
|
||||
*
|
||||
* Defined as a JS file to remove the need to have a separate compilation step.
|
||||
* It is highly recommended that you work on this file from within VS Code so
|
||||
* that you can take advantage of the @ts-check directive and get some type-
|
||||
* checking still.
|
||||
*
|
||||
* Other notes about the weird ways this file is set up:
|
||||
* - A lot of the HTML selectors in this file will look nonstandard. This is
|
||||
* because they are actually custom Angular components.
|
||||
* - It is strongly advised that you avoid template literals that use the
|
||||
* placeholder syntax via the dollar sign. The Terraform file is treating this
|
||||
* as a template file, and because it also uses a similar syntax, there's a
|
||||
* risk that some values will trigger false positives. If a template literal
|
||||
* must be used, be sure to use a double dollar sign to escape things.
|
||||
* - All the CSS should be written via custom style tags and the !important
|
||||
* directive (as much as that is a bad idea most of the time). We do not
|
||||
* control the Angular app, so we have to modify things from afar to ensure
|
||||
* that as Angular's internal state changes, it doesn't modify its HTML nodes
|
||||
* in a way that causes our custom styles to get wiped away.
|
||||
*
|
||||
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
|
||||
* @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries
|
||||
*/
|
||||
|
||||
/**
|
||||
* The communication protocol to set Devolutions to.
|
||||
*/
|
||||
const PROTOCOL = "RDP";
|
||||
|
||||
/**
|
||||
* The hostname to use with Devolutions.
|
||||
*/
|
||||
const HOSTNAME = "localhost";
|
||||
|
||||
/**
|
||||
* How often to poll the screen for the main Devolutions form.
|
||||
*/
|
||||
const SCREEN_POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* The fields in the Devolutions sign-in form that should be populated with
|
||||
* values from the Coder workspace.
|
||||
*
|
||||
* All properties should be defined as placeholder templates in the form
|
||||
* VALUE_NAME. The Coder module, when spun up, should then run some logic to
|
||||
* replace the template slots with actual values. These values should never
|
||||
* change from within JavaScript itself.
|
||||
*
|
||||
* @satisfies {FormFieldEntries}
|
||||
*/
|
||||
const formFieldEntries = {
|
||||
/** @readonly */
|
||||
username: {
|
||||
/** @readonly */
|
||||
querySelector: "web-client-username-control input",
|
||||
|
||||
/** @readonly */
|
||||
value: "${CODER_USERNAME}",
|
||||
},
|
||||
|
||||
/** @readonly */
|
||||
password: {
|
||||
/** @readonly */
|
||||
querySelector: "web-client-password-control input",
|
||||
|
||||
/** @readonly */
|
||||
value: "${CODER_PASSWORD}",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles typing in the values for the input form. All values are written
|
||||
* immediately, even though that would be physically impossible with a real
|
||||
* keyboard.
|
||||
*
|
||||
* Note: this code will never break, but you might get warnings in the console
|
||||
* from Angular about unexpected value changes. Angular patches over a lot of
|
||||
* the built-in browser APIs to support its component change detection system.
|
||||
* As part of that, it has validations for checking whether an input it
|
||||
* previously had control over changed without it doing anything.
|
||||
*
|
||||
* But the only way to simulate a keyboard input is by setting the input's
|
||||
* .value property, and then firing an input event. So basically, the inner
|
||||
* value will change, which Angular won't be happy about, but then the input
|
||||
* event will fire and sync everything back together.
|
||||
*
|
||||
* @param {HTMLInputElement} inputField
|
||||
* @param {string} inputText
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function setInputValue(inputField, inputText) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Adding timeout for input event, even though we'll be dispatching it
|
||||
// immediately, just in the off chance that something in the Angular app
|
||||
// intercepts it or stops it from propagating properly
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
reject(new Error("Input event did not get processed correctly in time."));
|
||||
}, 3_000);
|
||||
|
||||
const handleSuccessfulDispatch = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
inputField.removeEventListener("input", handleSuccessfulDispatch);
|
||||
resolve();
|
||||
};
|
||||
|
||||
inputField.addEventListener("input", handleSuccessfulDispatch);
|
||||
|
||||
// Code assumes that Angular will have an event handler in place to handle
|
||||
// the new event
|
||||
const inputEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
inputField.value = inputText;
|
||||
inputField.dispatchEvent(inputEvent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a Devolutions remote session form, auto-fills it with data, and then
|
||||
* submits it.
|
||||
*
|
||||
* The logic here is more convoluted than it should be for two main reasons:
|
||||
* 1. Devolutions' HTML markup has errors. There are labels, but they aren't
|
||||
* bound to the inputs they're supposed to describe. This means no easy hooks
|
||||
* for selecting the elements, unfortunately.
|
||||
* 2. Trying to modify the .value properties on some of the inputs doesn't
|
||||
* work. Probably some combo of Angular data-binding and some inputs having
|
||||
* the readonly attribute. Have to simulate user input to get around this.
|
||||
*
|
||||
* @param {HTMLFormElement} myForm
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function autoSubmitForm(myForm) {
|
||||
const setProtocolValue = () => {
|
||||
/** @type {HTMLDivElement | null} */
|
||||
const protocolDropdownTrigger = myForm.querySelector('div[role="button"]');
|
||||
if (protocolDropdownTrigger === null) {
|
||||
throw new Error("No clickable trigger for setting protocol value");
|
||||
}
|
||||
|
||||
protocolDropdownTrigger.click();
|
||||
|
||||
// Can't use form as container for querying the list of dropdown options,
|
||||
// because the elements don't actually exist inside the form. They're placed
|
||||
// in the top level of the HTML doc, and repositioned to make it look like
|
||||
// they're part of the form. Avoids CSS stacking context issues, maybe?
|
||||
/** @type {HTMLLIElement | null} */
|
||||
const protocolOption = document.querySelector(
|
||||
'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li',
|
||||
);
|
||||
|
||||
if (protocolOption === null) {
|
||||
throw new Error(
|
||||
"Unable to find protocol option on screen that matches desired protocol",
|
||||
);
|
||||
}
|
||||
|
||||
protocolOption.click();
|
||||
};
|
||||
|
||||
const setHostname = () => {
|
||||
/** @type {HTMLInputElement | null} */
|
||||
const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
|
||||
|
||||
if (hostnameInput === null) {
|
||||
throw new Error("Unable to find field for adding hostname");
|
||||
}
|
||||
|
||||
return setInputValue(hostnameInput, HOSTNAME);
|
||||
};
|
||||
|
||||
const setCoderFormFieldValues = async () => {
|
||||
// The RDP form will not appear on screen unless the dropdown is set to use
|
||||
// the RDP protocol
|
||||
const rdpSubsection = myForm.querySelector("rdp-form");
|
||||
if (rdpSubsection === null) {
|
||||
throw new Error(
|
||||
"Unable to find RDP subsection. Is the value of the protocol set to RDP?",
|
||||
);
|
||||
}
|
||||
|
||||
for (const { value, querySelector } of Object.values(formFieldEntries)) {
|
||||
/** @type {HTMLInputElement | null} */
|
||||
const input = document.querySelector(querySelector);
|
||||
|
||||
if (input === null) {
|
||||
throw new Error(
|
||||
'Unable to element that matches query "' + querySelector + '"',
|
||||
);
|
||||
}
|
||||
|
||||
await setInputValue(input, value);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSubmission = () => {
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const submitButton = myForm.querySelector(
|
||||
'p-button[ng-reflect-type="submit"] button',
|
||||
);
|
||||
|
||||
if (submitButton === null) {
|
||||
throw new Error("Unable to find submission button");
|
||||
}
|
||||
|
||||
if (submitButton.disabled) {
|
||||
throw new Error(
|
||||
"Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
|
||||
);
|
||||
}
|
||||
|
||||
submitButton.click();
|
||||
};
|
||||
|
||||
setProtocolValue();
|
||||
await setHostname();
|
||||
await setCoderFormFieldValues();
|
||||
triggerSubmission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up logic for auto-populating the form data when the form appears on
|
||||
* screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function setupFormDetection() {
|
||||
/** @type {HTMLFormElement | null} */
|
||||
let formValueFromLastMutation = null;
|
||||
|
||||
/** @returns {void} */
|
||||
const onDynamicTabMutation = () => {
|
||||
/** @type {HTMLFormElement | null} */
|
||||
const latestForm = document.querySelector("web-client-form > form");
|
||||
|
||||
// Only try to auto-fill if we went from having no form on screen to
|
||||
// having a form on screen. That way, we don't accidentally override the
|
||||
// form if the user is trying to customize values, and this essentially
|
||||
// makes the script values function as default values
|
||||
const mounted = formValueFromLastMutation === null && latestForm !== null;
|
||||
if (mounted) {
|
||||
autoSubmitForm(latestForm);
|
||||
}
|
||||
|
||||
formValueFromLastMutation = latestForm;
|
||||
};
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollingId = undefined;
|
||||
|
||||
/** @returns {void} */
|
||||
const checkScreenForDynamicTab = () => {
|
||||
const dynamicTab = document.querySelector("web-client-dynamic-tab");
|
||||
|
||||
// Keep polling until the main content container is on screen
|
||||
if (dynamicTab === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(pollingId);
|
||||
|
||||
// Call the mutation callback manually, to ensure it runs at least once
|
||||
onDynamicTabMutation();
|
||||
|
||||
// Having the mutation observer is kind of an extra safety net that isn't
|
||||
// really expected to run that often. Most of the content in the dynamic
|
||||
// tab is being rendered through Canvas, which won't trigger any mutations
|
||||
// that the observer can detect
|
||||
const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
|
||||
dynamicTabObserver.observe(dynamicTab, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
};
|
||||
|
||||
pollingId = window.setInterval(
|
||||
checkScreenForDynamicTab,
|
||||
SCREEN_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up custom styles for hiding default Devolutions elements that Coder
|
||||
* users shouldn't need to care about.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function setupAlwaysOnStyles() {
|
||||
const styleId = "coder-patch--styles-always-on";
|
||||
const existingContainer = document.querySelector("#" + styleId);
|
||||
if (existingContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleContainer = document.createElement("style");
|
||||
styleContainer.id = styleId;
|
||||
styleContainer.innerHTML = `
|
||||
/* app-menu corresponds to the sidebar of the default view. */
|
||||
app-menu {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styleContainer);
|
||||
}
|
||||
|
||||
function hideFormForInitialSubmission() {
|
||||
const styleId = "coder-patch--styles-initial-submission";
|
||||
const cssOpacityVariableName = "--coder-opacity-multiplier";
|
||||
|
||||
/** @type {HTMLStyleElement | null} */
|
||||
let styleContainer = document.querySelector("#" + styleId);
|
||||
if (!styleContainer) {
|
||||
styleContainer = document.createElement("style");
|
||||
styleContainer.id = styleId;
|
||||
styleContainer.innerHTML = `
|
||||
/*
|
||||
Have to use opacity instead of visibility, because the element still
|
||||
needs to be interactive via the script so that it can be auto-filled.
|
||||
*/
|
||||
:root {
|
||||
/*
|
||||
Can be 0 or 1. Start off invisible to avoid risks of UI flickering,
|
||||
but the rest of the function should be in charge of making the form
|
||||
container visible again if something goes wrong during setup.
|
||||
|
||||
Double dollar sign needed to avoid Terraform script false positives
|
||||
*/
|
||||
$${cssOpacityVariableName}: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
web-client-form is the container for the main session form, while
|
||||
the div is for the dropdown that is used for selecting the protocol.
|
||||
The dropdown is not inside of the form for CSS styling reasons, so we
|
||||
need to select both.
|
||||
*/
|
||||
web-client-form,
|
||||
body > div.p-overlay {
|
||||
/*
|
||||
Double dollar sign needed to avoid Terraform script false positives
|
||||
*/
|
||||
opacity: calc(100% * var($${cssOpacityVariableName})) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styleContainer);
|
||||
}
|
||||
|
||||
// The root node being undefined should be physically impossible (if it's
|
||||
// undefined, the browser itself is busted), but we need to do a type check
|
||||
// here so that the rest of the function doesn't need to do type checks over
|
||||
// and over.
|
||||
const rootNode = document.querySelector(":root");
|
||||
if (!(rootNode instanceof HTMLHtmlElement)) {
|
||||
// Remove the container entirely because if the browser is busted, who knows
|
||||
// if the CSS variables can be applied correctly. Better to have something
|
||||
// be a bit more ugly/painful to use, than have it be impossible to use
|
||||
styleContainer.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// It's safe to make the form visible preemptively because Devolutions
|
||||
// outputs the Windows view through an HTML canvas that it overlays on top
|
||||
// of the rest of the app. Even if the form isn't hidden at the style level,
|
||||
// it will still be covered up.
|
||||
const restoreOpacity = () => {
|
||||
rootNode.style.setProperty(cssOpacityVariableName, "1");
|
||||
};
|
||||
|
||||
// If this file gets more complicated, it might make sense to set up the
|
||||
// timeout and event listener so that if one triggers, it cancels the other,
|
||||
// but having restoreOpacity run more than once is a no-op for right now.
|
||||
// Not a big deal if these don't get cleaned up.
|
||||
|
||||
// Have the form automatically reappear no matter what, so that if something
|
||||
// does break, the user isn't left out to dry
|
||||
window.setTimeout(restoreOpacity, 5_000);
|
||||
|
||||
/** @type {HTMLFormElement | null} */
|
||||
const form = document.querySelector("web-client-form > form");
|
||||
form?.addEventListener(
|
||||
"submit",
|
||||
() => {
|
||||
// Not restoring opacity right away just to give the HTML canvas a little
|
||||
// bit of time to get spun up and cover up the main form
|
||||
window.setTimeout(restoreOpacity, 1_000);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Always safe to call these immediately because even if the Angular app isn't
|
||||
// loaded by the time the function gets called, the CSS will always be globally
|
||||
// available for when Angular is finally ready
|
||||
setupAlwaysOnStyles();
|
||||
hideFormForInitialSubmission();
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupFormDetection);
|
||||
} else {
|
||||
setupFormDetection();
|
||||
}
|
||||
130
windows-rdp/main.test.ts
Normal file
130
windows-rdp/main.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
TerraformState,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "../test";
|
||||
|
||||
type TestVariables = Readonly<{
|
||||
agent_id: string;
|
||||
resource_id: string;
|
||||
admin_username?: string;
|
||||
admin_password?: string;
|
||||
}>;
|
||||
|
||||
function findWindowsRdpScript(state: TerraformState): string | null {
|
||||
for (const resource of state.resources) {
|
||||
const isRdpScriptResource =
|
||||
resource.type === "coder_script" && resource.name === "windows-rdp";
|
||||
|
||||
if (!isRdpScriptResource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const instance of resource.instances) {
|
||||
if (instance.attributes.display_name === "windows-rdp") {
|
||||
return instance.attributes.script;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo It would be nice if we had a way to verify that the Devolutions root
|
||||
* HTML file is modified to include the import for the patched Coder script,
|
||||
* but the current test setup doesn't really make that viable
|
||||
*/
|
||||
describe("Web RDP", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
resource_id: "bar",
|
||||
});
|
||||
|
||||
it("Has the PowerShell script install Devolutions Gateway", async () => {
|
||||
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
resource_id: "bar",
|
||||
});
|
||||
|
||||
const lines = findWindowsRdpScript(state)
|
||||
?.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.trim());
|
||||
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining<string>([
|
||||
'$moduleName = "DevolutionsGateway"',
|
||||
// Devolutions does versioning in the format year.minor.patch
|
||||
expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/),
|
||||
"Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Injects Terraform's username and password into the JS patch file", async () => {
|
||||
/**
|
||||
* Using a regex as a quick-and-dirty way to get at the username and
|
||||
* password values.
|
||||
*
|
||||
* Tried going through the trouble of extracting out the form entries
|
||||
* variable from the main output, converting it from Prettier/JS-based JSON
|
||||
* text to universal JSON text, and exposing it as a parsed JSON value. That
|
||||
* got to be a bit too much, though.
|
||||
*
|
||||
* Regex is a little bit more verbose and pedantic than normal. Want to
|
||||
* have some basic safety nets for validating the structure of the form
|
||||
* entries variable after the JS file has had values injected. Even with all
|
||||
* the wildcard classes set to lazy mode, we want to make sure that they
|
||||
* don't overshoot and grab too much content.
|
||||
*
|
||||
* Written and tested via Regex101
|
||||
* @see {@link https://regex101.com/r/UMgQpv/2}
|
||||
*/
|
||||
const formEntryValuesRe =
|
||||
/^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?<username>.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?<password>.+?)",$.*?^};$/ms;
|
||||
|
||||
// Test that things work with the default username/password
|
||||
const defaultState = await runTerraformApply<TestVariables>(
|
||||
import.meta.dir,
|
||||
{
|
||||
agent_id: "foo",
|
||||
resource_id: "bar",
|
||||
},
|
||||
);
|
||||
|
||||
const defaultRdpScript = findWindowsRdpScript(defaultState);
|
||||
expect(defaultRdpScript).toBeString();
|
||||
|
||||
const { username: defaultUsername, password: defaultPassword } =
|
||||
formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {};
|
||||
|
||||
expect(defaultUsername).toBe("Administrator");
|
||||
expect(defaultPassword).toBe("coderRDP!");
|
||||
|
||||
// Test that custom usernames/passwords are also forwarded correctly
|
||||
const customAdminUsername = "crouton";
|
||||
const customAdminPassword = "VeryVeryVeryVeryVerySecurePassword97!";
|
||||
const customizedState = await runTerraformApply<TestVariables>(
|
||||
import.meta.dir,
|
||||
{
|
||||
agent_id: "foo",
|
||||
resource_id: "bar",
|
||||
admin_username: customAdminUsername,
|
||||
admin_password: customAdminPassword,
|
||||
},
|
||||
);
|
||||
|
||||
const customRdpScript = findWindowsRdpScript(customizedState);
|
||||
expect(customRdpScript).toBeString();
|
||||
|
||||
const { username: customUsername, password: customPassword } =
|
||||
formEntryValuesRe.exec(customRdpScript)?.groups ?? {};
|
||||
|
||||
expect(customUsername).toBe(customAdminUsername);
|
||||
expect(customPassword).toBe(customAdminPassword);
|
||||
});
|
||||
});
|
||||
76
windows-rdp/main.tf
Normal file
76
windows-rdp/main.tf
Normal file
@@ -0,0 +1,76 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "resource_id" {
|
||||
type = string
|
||||
description = "The ID of the primary Coder resource (e.g. VM)."
|
||||
}
|
||||
|
||||
variable "admin_username" {
|
||||
type = string
|
||||
default = "Administrator"
|
||||
}
|
||||
|
||||
variable "admin_password" {
|
||||
type = string
|
||||
default = "coderRDP!"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "coder_script" "windows-rdp" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "windows-rdp"
|
||||
icon = "/icon/desktop.svg"
|
||||
|
||||
script = templatefile("${path.module}/powershell-installation-script.tftpl", {
|
||||
admin_username = var.admin_username
|
||||
admin_password = var.admin_password
|
||||
|
||||
# Wanted to have this be in the powershell template file, but Terraform
|
||||
# doesn't allow recursive calls to the templatefile function. Have to feed
|
||||
# results of the JS template replace into the powershell template
|
||||
patch_file_contents = templatefile("${path.module}/devolutions-patch.js", {
|
||||
CODER_USERNAME = var.admin_username
|
||||
CODER_PASSWORD = var.admin_password
|
||||
})
|
||||
})
|
||||
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
resource "coder_app" "windows-rdp" {
|
||||
agent_id = var.agent_id
|
||||
slug = "web-rdp"
|
||||
display_name = "Web RDP"
|
||||
url = "http://localhost:7171"
|
||||
icon = "/icon/desktop.svg"
|
||||
subdomain = true
|
||||
|
||||
healthcheck {
|
||||
url = "http://localhost:7171"
|
||||
interval = 5
|
||||
threshold = 15
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_app" "rdp-docs" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Local RDP"
|
||||
slug = "rdp-docs"
|
||||
icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg"
|
||||
url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop"
|
||||
external = true
|
||||
}
|
||||
85
windows-rdp/powershell-installation-script.tftpl
Normal file
85
windows-rdp/powershell-installation-script.tftpl
Normal file
@@ -0,0 +1,85 @@
|
||||
function Set-AdminPassword {
|
||||
param (
|
||||
[string]$adminPassword
|
||||
)
|
||||
# Set admin password
|
||||
Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force)
|
||||
# Enable admin user
|
||||
Get-LocalUser -Name "${admin_username}" | Enable-LocalUser
|
||||
}
|
||||
|
||||
function Configure-RDP {
|
||||
# Enable RDP
|
||||
New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -PropertyType DWORD -Force
|
||||
# Disable NLA
|
||||
New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -PropertyType DWORD -Force
|
||||
New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force
|
||||
# Enable RDP through Windows Firewall
|
||||
Enable-NetFirewallRule -DisplayGroup "Remote Desktop"
|
||||
}
|
||||
|
||||
function Install-DevolutionsGateway {
|
||||
# Define the module name and version
|
||||
$moduleName = "DevolutionsGateway"
|
||||
$moduleVersion = "2024.1.5"
|
||||
|
||||
# Install the module with the specified version for all users
|
||||
# This requires administrator privileges
|
||||
try {
|
||||
# Install-PackageProvider is required for AWS. Need to set command to
|
||||
# terminate on failure so that try/catch actually triggers
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop
|
||||
Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
|
||||
}
|
||||
catch {
|
||||
# If the first command failed, assume that we're on GCP and run
|
||||
# Install-Module only
|
||||
Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
|
||||
}
|
||||
|
||||
# Construct the module path for system-wide installation
|
||||
$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion"
|
||||
$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1"
|
||||
|
||||
# Import the module using the full path
|
||||
Import-Module $modulePath
|
||||
Install-DGatewayPackage
|
||||
|
||||
# Configure Devolutions Gateway
|
||||
$Hostname = "localhost"
|
||||
$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171'
|
||||
$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None
|
||||
$ConfigParams = @{
|
||||
Hostname = $Hostname
|
||||
Listeners = @($HttpListener)
|
||||
WebApp = $WebApp
|
||||
}
|
||||
Set-DGatewayConfig @ConfigParams
|
||||
New-DGatewayProvisionerKeyPair -Force
|
||||
|
||||
# Configure and start the Windows service
|
||||
Set-Service 'DevolutionsGateway' -StartupType 'Automatic'
|
||||
Start-Service 'DevolutionsGateway'
|
||||
}
|
||||
|
||||
function Patch-Devolutions-HTML {
|
||||
$root = "C:\Program Files\Devolutions\Gateway\webapp\client"
|
||||
$devolutionsHtml = "$root\index.html"
|
||||
$patch = '<script defer id="coder-patch" src="coder.js"></script>'
|
||||
|
||||
# Always copy the file in case we change it.
|
||||
@'
|
||||
${patch_file_contents}
|
||||
'@ | Set-Content "$root\coder.js"
|
||||
|
||||
# Only inject the src if we have not before.
|
||||
$isPatched = Select-String -Path "$devolutionsHtml" -Pattern "$patch" -SimpleMatch
|
||||
if ($isPatched -eq $null) {
|
||||
(Get-Content $devolutionsHtml).Replace('</app-root>', "</app-root>$patch") | Set-Content $devolutionsHtml
|
||||
}
|
||||
}
|
||||
|
||||
Set-AdminPassword -adminPassword "${admin_password}"
|
||||
Configure-RDP
|
||||
Install-DevolutionsGateway
|
||||
Patch-Devolutions-HTML
|
||||
Reference in New Issue
Block a user