Compare commits

...

68 Commits

Author SHA1 Message Date
Muhammad Atif Ali
271c0bf6c1 Bump JetBrains Gateway module version to 1.0.24 2024-11-09 00:17:33 +05:00
Muhammad Atif Ali
734f4a7009 Improve JetBrains IDE parameter naming 2024-11-09 00:05:40 +05:00
Muhammad Atif Ali
b57d7f170c Refine display logic for JetBrains IDE keys 2024-11-08 23:22:53 +05:00
Muhammad Atif Ali
630a92966c Refactor JetBrains output to use local variables 2024-11-08 23:15:47 +05:00
Muhammad Atif Ali
309c7eb24b Improve slug format in JetBrains Gateway module 2024-11-08 23:13:16 +05:00
Muhammad Atif Ali
ef10dcfc67 Support multiple default IDEs in JetBrains Gateway
Refactor the `default` variable to accept a list, enabling the
specification of multiple default IDEs. This change allows users
to manage configurations for multiple IDEs more efficiently.

- Updated `default` type from string to list(string)
- Iterated over default IDEs to generate corresponding resources
- Ensured backward compatibility where single IDE configs were used
2024-11-08 23:11:20 +05:00
Tao Chen
f5ab7995d1 feat(filebrowser): check if already installed (#334)
Co-authored-by: Muhammad Atif Ali <atif@coder.com>
2024-10-30 18:10:26 +05:00
djarbz
528a8a9fea fix(kasmvnc): optimize KasmVNC deployment script (#329)
Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2024-10-30 10:25:41 +00:00
Kerwin Bryant
87854707bc feat(jetbrains-gateway): add releases_base_link/download_base_link variables (#333) 2024-10-30 14:51:03 +05:00
Roger Chao
b53554b4e4 fix(jupyterlab): update command -v from jupyterlab to jupyter-lab (#328)
Update `command -v` from `jupyterlab` to `jupyter-lab` to check to if
jupyterlab binary is installed.
2024-10-23 13:51:25 -07:00
Steven Masley
ce5a5b383a feat(vscode-web): support hosting on a subpath with subdomain=false (#288)
Co-authored-by: Muhammad Atif Ali <atif@coder.com>
Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2024-10-21 13:46:19 +05:00
framctr
1b147ae90d feat(jupyterlab): add support for subdomain=false (#316)
Co-authored-by: Muhammad Atif Ali <atif@coder.com>
Co-authored-by: Asher <ash@coder.com>
2024-10-21 12:06:10 +05:00
djarbz
7992d9d265 fix(kasmVNC): fix debian installation and improve logging (#326) 2024-10-21 08:04:59 +05:00
Yves ANDOLFATTO
20d97a25dd fix(filebrowser): support custom base_url in case of custom db path (#320)
Co-authored-by: Muhammad Atif Ali <atif@coder.com>
Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2024-10-18 17:21:36 +05:00
Muhammad Atif Ali
8e0dfcd534 feat(jetbrains-gateway): add slug variable (#322) 2024-10-17 14:25:03 +00:00
Muhammad Atif Ali
9752bf89a6 chore(kasmvnc): refactor download logic to support multiple tools (#323) 2024-10-17 19:17:38 +05:00
Muhammad Atif Ali
48c81c9ff4 kasm VNC (#250)
Co-authored-by: Michael Smith <throwawayclover@gmail.com>
2024-10-17 02:03:00 +00:00
Muhammad Atif Ali
acd5edffe7 fix(vault-jwt): fix vault CLI installation (#311) 2024-10-16 02:04:28 +05:00
Muhammad Atif Ali
4dcab99cb0 fix(vscode-web): remove exit if extension installation fails (#318) 2024-10-15 22:53:36 +05:00
Muhammad Atif Ali
50a946df0f chore: explicitly setup terraform (#319) 2024-10-15 18:48:15 +05:00
Asher
8a0ac3435c Add owner to Gateway link (#310)
Without this, it is not possible to reliably connect to another user's
workspace (for admins, mainly) when duplicate workspace names are
involved.
2024-10-07 21:16:01 -08:00
Michael Smith
438c904567 chore: cleanup all test files (#293)
## Changes made
- Removed all unused imports, and made sure type imports were labeled
correctly
- Updated all comparisons to be more strict
- Simplified loops to remove unneeded closure functions
- Removed all explicit `any` types
- Updated how strings were defined to follow general TypeScript best
practices

## Notes
- We definitely want some kind of linting setup for this repo. I'm going
to bring this up when Blueberry has its next team meeting next week
2024-09-27 15:35:47 -04:00
Muhammad Atif Ali
bd6747f9bc chore: move update-version to ci (#301) 2024-09-27 18:24:06 +00:00
Muhammad Atif Ali
fb81c8969f feat(vault-jwt): Add Vault JWT/OIDC module (#297)
Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2024-09-27 18:20:57 +00:00
Muhammad Atif Ali
162808760d fix(filebrowser): only require agent_name when not on subdomain (#299) 2024-09-27 11:07:49 -04:00
Brent Souza
ad1189afff feat(jfrog): support multiple repositories (#289)
Co-authored-by: bsouza <BSouza@Acadian-Asset.com>
Co-authored-by: Muhammad Atif Ali <atif@coder.com>
2024-09-27 12:02:57 +05:00
dependabot[bot]
94e126f248 chore(deps): bump oven-sh/setup-bun from 1 to 2 (#305)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 00:16:11 +05:00
Muhammad Atif Ali
04535a9cd7 chore: add dependabot.yml (#302) 2024-09-25 00:12:30 +05:00
Muhammad Atif Ali
7a9f553564 chore(cursor): update display_name to Cursor Desktop (#300) 2024-09-24 23:24:02 +05:00
Muhammad Atif Ali
e11b19d33e feat(jupyter): switch from pip3 to pipx for Jupyter install (#294) 2024-09-23 13:33:08 +05:00
github-actions[bot]
93c4fb3a8d chore: bump version to 1.0.18 in README.md files (#292)
This is an auto-generated PR to update README.md files of all modules
with the new tag 1.0.18

Co-authored-by: matifali <matifali@users.noreply.github.com>
2024-09-20 21:22:43 +05:00
Muhammad Atif Ali
86038f8d37 chore(git-commit-signing): mark the module as official (#291) 2024-09-20 09:18:38 -07:00
Muhammad Atif Ali
120a0e342e feat(cursor): Add Cursor IDE module (#290) 2024-09-20 20:20:20 +05:00
Muhammad Atif Ali
b51932d7ac feat(dotfiles): Add an optional coder_app to update dotfiles on-demand (#280)
Co-authored-by: Chris Golden <551285+cirego@users.noreply.github.com>
Co-authored-by: Michael Smith <throwawayclover@gmail.com>
2024-09-20 17:00:06 +05:00
Sebastian
834ffde032 feat(filebrowser): support subdomain = false (#286)
Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2024-09-20 00:28:16 +05:00
Muhammad Atif Ali
831f64da56 chore: remove package-lock.json and update deps (#281) 2024-09-04 00:10:38 +05:00
megumin
236022f870 feat(git-clone): custom destination folder name (#287) 2024-09-01 16:16:05 +05:00
Michael Brewer
4c45d69994 fix(code-server): handle when the extension folder does not exist yet (#278) 2024-08-14 10:17:39 +03:00
Michael Smith
310d0262bd Merge pull request #273 from coder/mes/readme-update
fix: add missing README information and clean up types
2024-08-03 12:51:10 -04:00
Ben Potter
f446fbd667 fix: typo in web RDP example (#277) 2024-07-29 06:11:06 -07:00
Parkreiner
982c75e86f fix: update incorrect info in docs 2024-07-26 15:07:00 +00:00
Benjamin Peinhardt
523ad9fe23 update windows-rdp to take share variable (#274)
* update windows-rdp to take share variable

* of course I forgot to run fmt

* make 'owner' default for share

* update share var validation
2024-07-24 16:26:45 -05:00
Parkreiner
096cd214ce fix: make type def for TerraformState more specific 2024-07-19 20:18:11 +00:00
Parkreiner
6a87fd18e5 fix: make attributes type more specific 2024-07-19 20:07:59 +00:00
Parkreiner
fa4b84e8d1 docs: add publishing instructions to the contributing README 2024-07-19 19:23:39 +00:00
Michael Smith
7e0eacf1f4 Merge pull request #272 from coder/update-readme-branch
chore: bump version to 1.0.17 in README.md files
2024-07-19 14:56:19 -04:00
Parkreiner
cbe48aa072 chore: bump version to 1.0.17 in README.md files 2024-07-19 18:54:27 +00:00
Parkreiner
89bb023fa5 docs: clean up current contributing guide 2024-07-19 18:14:41 +00:00
Parkreiner
66472b0105 chore: clean up lint file 2024-07-19 18:03:12 +00:00
Parkreiner
cd010baac8 chore: switch codebase to use TS strict mode 2024-07-19 17:59:04 +00:00
Parkreiner
f7fa145855 docs: update some wording for clarity 2024-07-19 17:56:25 +00:00
Stephen Kirby
f7f9c8b7ef Merge pull request #270 from coder/webrdp-link
fix url schema for webrdp example
2024-07-17 14:19:16 -05:00
Stephen Kirby
889186d553 fixed url schema for webrdp example 2024-07-16 19:29:19 +00:00
Michael Smith
352577b833 Merge pull request #266 from coder/render-video
docs(windows-rdp): make sure video renders correctly
2024-07-10 18:56:26 -04:00
Michael Smith
4e59ecc606 Merge pull request #269 from coder/mes/render-video-2
chore: add custom thumbnail for Web RDP demo video
2024-07-10 18:54:00 -04:00
Parkreiner
a40f2b86c3 chore: add custom thumbnail for video 2024-07-10 22:53:09 +00:00
Parkreiner
a2c29ace0a fix: update HTML for video tag 2024-07-10 14:01:42 +00:00
Maxime Brunet
da4a561cb5 fix(code-server): add variable for subdomain option (#267) 2024-07-04 00:25:19 +03:00
Muhammad Atif Ali
d77ad8ac63 fixup! 2024-07-03 22:38:22 +03:00
Muhammad Atif Ali
b1f81afa7f docs(windows-rdp): make sure video renders correctly 2024-07-03 22:34:20 +03:00
github-actions[bot]
883741244b chore: bump version to 1.0.16 in README.md files (#265)
Co-authored-by: matifali <matifali@users.noreply.github.com>
2024-07-02 21:04:31 +03:00
Michael Smith
c3eee866d1 Merge pull request #264 from coder/svg-link-patch
fix: update SVG icon URL for RDP module
2024-07-02 12:15:49 -04:00
Michael Smith
bf175a1247 fix: update SVG icon URL for RDP module
Accidentally missed one of the URL before merging the main module.
2024-07-02 12:10:22 -04:00
Michael Smith
8fd54e0e78 Merge pull request #262 from coder/web-rdp
feat: add module for Web RDP
2024-07-02 12:06:20 -04:00
Parkreiner
e8ee02c044 fix: update URL for RDP icon 2024-07-02 16:02:50 +00:00
Parkreiner
aebdc9b434 fix: update docs link 2024-07-02 15:26:40 +00:00
Parkreiner
d98bfcb20b fix: add versioning to all code snippets 2024-07-02 15:21:45 +00:00
Parkreiner
894e507bb3 fix: add verison number to rdp script 2024-07-02 15:19:16 +00:00
75 changed files with 1955 additions and 735 deletions

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -17,7 +17,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- uses: coder/coder/.github/actions/setup-tf@main
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup
@@ -27,7 +28,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
fetch-depth: 0 # Needed to get tags
- uses: coder/coder/.github/actions/setup-tf@main
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup
@@ -38,3 +42,16 @@ jobs:
uses: crate-ci/typos@v1.17.2
- name: Lint
run: bun lint
- name: Check version
shell: bash
run: |
# check for version changes
./update-version.sh
# Check if any changes were made in README.md files
if [[ -n "$(git status --porcelain -- '**/README.md')" ]]; then
echo "Version mismatch detected. Please run ./update-version.sh and commit the updated README.md files."
git diff -- '**/README.md'
exit 1
else
echo "No version mismatch detected. All versions are up to date."
fi

View File

@@ -1,42 +0,0 @@
name: Update README on Tag
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
update-readme:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get the latest tag
id: get-latest-tag
run: echo "TAG=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT
- name: Run update script
run: ./update-version.sh
- name: Create Pull Request
id: create-pr
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: bump version to ${{ env.TAG }} in README.md files'
title: 'chore: bump version to ${{ env.TAG }} in README.md files'
body: 'This is an auto-generated PR to update README.md files of all modules with the new tag ${{ env.TAG }}'
branch: 'update-readme-branch'
base: 'main'
env:
TAG: ${{ steps.get-latest-tag.outputs.TAG }}
- name: Auto-approve
uses: hmarr/auto-approve-action@v4
if: github.ref == 'refs/heads/update-readme-branch'

1
.icons/cursor.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -1,30 +1,75 @@
# Contributing
To create a new module, clone this repository and run:
## Getting started
This repo uses the [Bun runtime](https://bun.sh/) to to run all code and tests. To install Bun, you can run this command on Linux/MacOS:
```shell
./new.sh MODULE_NAME
curl -fsSL https://bun.sh/install | bash
```
Or this command on Windows:
```shell
powershell -c "irm bun.sh/install.ps1 | iex"
```
Follow the instructions to ensure that Bun is available globally. Once Bun has been installed, clone this repository. From there, run this script to create a new module:
```shell
./new.sh NAME_OF_NEW_MODULE
```
## Testing a Module
> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
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.
The testing suite must be able to run docker containers with the `--network=host` flag. This 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.
Reference the existing `*.test.ts` files to get an idea for how to set up tests.
You can run all tests in a specific file with this command:
```shell
# Run tests for a specific module!
$ bun test -t '<module>'
```
Or run all tests by running this command:
```shell
$ bun test
```
You can test a module locally by updating the source as follows
```tf
module "example" {
source = "git::https://github.com/<USERNAME>/<REPO>.git//<MODULE-NAME>?ref=<BRANCH-NAME>"
# You may need to remove the 'version' field, it is incompatible with some sources.
}
```
> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR.
## Releases
> [!WARNING]
> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers.
Much of our release process is automated. To cut a new release:
1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases)
2. Click "Draft a new release"
3. Click the "Choose a tag" button and type a new release number in the format `v<major>.<minor>.<patch>` (e.g., `v1.18.0`). Then click "Create new tag".
4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies).
5. Once everything looks good, click the "Publish release" button.
Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch.
Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/):
1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest)
2. Publishing new data to the [Coder Registry](https://registry.coder.com)
> [!NOTE]
> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate.

View File

@@ -3,14 +3,14 @@
Modules
</h1>
[Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[Module Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![license](https://img.shields.io/github/license/coder/modules)](./LICENSE)
</div>
Modules extend Templates to create reusable components for your development environment.
Modules extend Coder Templates to create reusable components for your development environment.
e.g.

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

BIN
bun.lockb

Binary file not shown.

View File

@@ -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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
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.15"
version = "1.0.18"
agent_id = coder_agent.example.id
offline = true
}

View File

@@ -113,6 +113,15 @@ variable "auto_install_extensions" {
default = false
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = false
}
resource "coder_script" "code-server" {
agent_id = var.agent_id
display_name = "code-server"
@@ -154,7 +163,7 @@ resource "coder_app" "code-server" {
display_name = var.display_name
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
icon = "/icon/code.svg"
subdomain = false
subdomain = var.subdomain
share = var.share
order = var.order

View File

@@ -10,6 +10,7 @@ CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
mkdir -p "${EXTENSIONS_DIR}"
fi
function run_code_server() {

View File

@@ -1,10 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
describe("coder-login", async () => {
await runTerraformInit(import.meta.dir);

35
cursor/README.md Normal file
View File

@@ -0,0 +1,35 @@
---
display_name: Cursor IDE
description: Add a one-click button to launch Cursor IDE
icon: ../.icons/cursor.svg
maintainer_github: coder
verified: true
tags: [ide, cursor, helper]
---
# Cursor IDE
Add a button to open any workspace with a single click in Cursor IDE.
Uses the [Coder Remote VS Code Extension](https://github.com/coder/cursor-coder).
```tf
module "cursor" {
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
}
```
## Examples
### Open in a specific directory
```tf
module "cursor" {
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```

88
cursor/main.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
describe("cursor", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("default output", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.outputs.cursor_url.value).toBe(
"cursor://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "cursor",
);
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.cursor_url.value).toBe(
"cursor://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.cursor_url.value).toBe(
"cursor://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.cursor_url.value).toBe(
"cursor://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.cursor_url.value).toBe(
"cursor://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
);
});
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
order: "22",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "cursor",
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});

62
cursor/main.tf Normal file
View File

@@ -0,0 +1,62 @@
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 "folder" {
type = string
description = "The folder to open in Cursor IDE."
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)."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "cursor" {
agent_id = var.agent_id
external = true
icon = "/icon/cursor.svg"
slug = "cursor"
display_name = "Cursor Desktop"
order = var.order
url = join("", [
"cursor://coder.coder-remote/open",
"?owner=",
data.coder_workspace_owner.me.name,
"&workspace=",
data.coder_workspace.me.name,
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=",
data.coder_workspace.me.access_url,
"&token=$SESSION_TOKEN",
])
}
output "cursor_url" {
value = coder_app.cursor.url
description = "Cursor IDE Desktop URL."
}

View File

@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
```tf
module "dotfiles" {
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.15"
version = "1.0.18"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "dotfiles" {
```tf
module "dotfiles" {
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.15"
version = "1.0.18"
agent_id = coder_agent.example.id
}
```
@@ -40,7 +40,7 @@ module "dotfiles" {
```tf
module "dotfiles" {
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.15"
version = "1.0.18"
agent_id = coder_agent.example.id
user = "root"
}
@@ -51,13 +51,13 @@ module "dotfiles" {
```tf
module "dotfiles" {
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.15"
version = "1.0.18"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.15"
version = "1.0.18"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -71,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.15"
version = "1.0.18"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}

View File

@@ -39,9 +39,14 @@ variable "coder_parameter_order" {
default = null
}
variable "manual_update" {
type = bool
description = "If true, this adds a button to workspace page to refresh dotfiles on demand."
default = false
}
data "coder_parameter" "dotfiles_uri" {
count = var.dotfiles_uri == null ? 1 : 0
type = "string"
name = "dotfiles_uri"
display_name = "Dotfiles URL"
@@ -68,6 +73,18 @@ resource "coder_script" "dotfiles" {
run_on_start = true
}
resource "coder_app" "dotfiles" {
count = var.manual_update ? 1 : 0
agent_id = var.agent_id
display_name = "Refresh Dotfiles"
slug = "dotfiles"
icon = "/icon/dotfiles.svg"
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
})
}
output "dotfiles_uri" {
description = "Dotfiles URI"
value = local.dotfiles_uri

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

View File

@@ -14,7 +14,7 @@ A file browser for your workspace.
```tf
module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.8"
version = "1.0.23"
agent_id = coder_agent.example.id
}
```
@@ -28,7 +28,7 @@ module "filebrowser" {
```tf
module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.8"
version = "1.0.23"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -39,8 +39,19 @@ module "filebrowser" {
```tf
module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.8"
version = "1.0.23"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
```
### Serve from the same domain (no subdomain)
```tf
module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
}
```

View File

@@ -88,4 +88,27 @@ describe("filebrowser", async () => {
"📝 Logs at /tmp/filebrowser.log",
]);
});
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
agent_name: "main",
subdomain: false,
});
const output = await executeScriptInContainer(state, "alpine");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"\u001B[0;1mInstalling filebrowser ",
"",
"🥳 Installation complete! ",
"",
"👷 Starting filebrowser in background... ",
"",
"📂 Serving /root at http://localhost:13339 ",
"",
"Running 'filebrowser --noauth --root /root --port 13339' ",
"",
"📝 Logs at /tmp/filebrowser.log",
]);
});
});

View File

@@ -14,6 +14,16 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "database_path" {
type = string
description = "The path to the filebrowser database."
@@ -58,27 +68,56 @@ variable "order" {
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "filebrowser"
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
resource "coder_script" "filebrowser" {
agent_id = var.agent_id
display_name = "File Browser"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
icon = "/icon/filebrowser.svg"
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port,
FOLDER : var.folder,
LOG_PATH : var.log_path,
DB_PATH : var.database_path
DB_PATH : var.database_path,
SUBDOMAIN : var.subdomain,
SERVER_BASE_PATH : local.server_base_path
})
run_on_start = true
}
resource "coder_app" "filebrowser" {
agent_id = var.agent_id
slug = "filebrowser"
slug = var.slug
display_name = "File Browser"
url = "http://localhost:${var.port}"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
subdomain = true
url = local.url
icon = "/icon/filebrowser.svg"
subdomain = var.subdomain
share = var.share
order = var.order
healthcheck {
url = local.healthcheck_url
interval = 5
threshold = 6
}
}
locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health"
}

View File

@@ -1,9 +1,13 @@
#!/usr/bin/env bash
BOLD='\033[0;1m'
printf "$${BOLD}Installing filebrowser \n\n"
# Check if filebrowser is installed
if ! command -v filebrowser &> /dev/null; then
curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
fi
printf "🥳 Installation complete! \n\n"
@@ -17,6 +21,9 @@ if [ "${DB_PATH}" != "filebrowser.db" ]; then
DB_FLAG=" -d ${DB_PATH}"
fi
# set baseurl to be able to run if sudomain=false; if subdomain=true the SERVER_BASE_PATH value will be ""
filebrowser config set --baseurl "${SERVER_BASE_PATH}"$${DB_FLAG} > ${LOG_PATH} 2>&1
printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"

View File

@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -27,7 +27,7 @@ module "git-clone" {
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -41,7 +41,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -66,7 +66,7 @@ data "coder_parameter" "git_repo" {
# Clone the repository for branch `feat/example`
module "git_clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}
@@ -74,7 +74,7 @@ module "git_clone" {
# Create a code-server instance for the cloned repository
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
order = 1
folder = "/home/${local.username}/${module.git_clone.folder_name}"
@@ -98,7 +98,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -116,7 +116,7 @@ To GitLab clone with a specific branch like `feat/example`
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@@ -127,7 +127,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -147,9 +147,26 @@ For example, to clone the `feat/example` branch:
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.12"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
}
```
## Git clone with different destination folder
By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else.
For example, this will clone into the `~/projects/coder/coder-dev` folder:
```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
base_dir = "~/projects/coder"
}
```

View File

@@ -79,6 +79,22 @@ describe("git-clone", async () => {
expect(state.outputs.branch_name.value).toEqual("");
});
it("repo_dir should match base_dir/folder_name", async () => {
const url = "git@github.com:coder/coder.git";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
folder_name: "foo",
url,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/foo");
expect(state.outputs.folder_name.value).toEqual("foo");
expect(state.outputs.clone_url.value).toEqual(url);
const https_url = "https://github.com/coder/coder.git";
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("");
});
it("branch_name should not include query string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",

View File

@@ -50,6 +50,12 @@ variable "branch_name" {
default = ""
}
variable "folder_name" {
description = "The destination folder to clone the repository into."
type = string
default = ""
}
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -64,7 +70,7 @@ locals {
# Extract the branch name from the URL
branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
# Extract the folder name from the URL
folder_name = replace(basename(local.clone_url), ".git", "")
folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name
# Construct the path to clone the repository
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
# Construct the web URL

View File

@@ -2,8 +2,8 @@
display_name: Git commit signing
description: Configures Git to sign commits using your Coder SSH key
icon: ../.icons/git.svg
maintainer_github: phorcys420
verified: false
maintainer_github: coder
verified: true
tags: [helper, git]
---

View File

@@ -1,3 +1,4 @@
import { type Server, serve } from "bun";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
@@ -9,7 +10,6 @@ import {
testRequiredVariables,
writeCoder,
} from "../test";
import { Server, serve } from "bun";
describe("github-upload-public-key", async () => {
await runTerraformInit(import.meta.dir);
@@ -21,10 +21,12 @@ describe("github-upload-public-key", async () => {
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, [
const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@@ -42,10 +44,12 @@ describe("github-upload-public-key", 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, [
const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@@ -95,7 +99,7 @@ const setupServer = async (): Promise<Server> => {
}
// case: key already exists
if (req.headers.get("Authorization") == "Bearer findkey") {
if (req.headers.get("Authorization") === "Bearer findkey") {
return createJSONResponse([
{
key: "foo",

View File

@@ -14,12 +14,12 @@ 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.13"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
default = "GO"
default = ["GO"]
}
```
@@ -32,12 +32,12 @@ module "jetbrains_gateway" {
```tf
module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.13"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
default = ["GO"]
}
```
@@ -46,12 +46,12 @@ module "jetbrains_gateway" {
```tf
module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.13"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
default = ["GO"]
latest = true
}
```
@@ -61,17 +61,49 @@ module "jetbrains_gateway" {
```tf
module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.13"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
default = ["GO"]
latest = true
channel = "eap"
}
```
### Add Multiple IDEs with the default set to GoLand
```tf
module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS", "RD", "PY"]
default = ["GO", "PY"]
}
```
### Custom release download link
Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`.
```tf
module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.24"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
releases_base_link = "https://releases.internal.site/"
download_base_link = "https://download.internal.site/"
default = ["GO"]
}
```
## Supported IDEs
This module and JetBrains Gateway support the following JetBrains IDEs:

View File

@@ -14,6 +14,26 @@ describe("jetbrains-gateway", async () => {
folder: "/home/foo",
});
it("should create a link with the default values", async () => {
const state = await runTerraformApply(import.meta.dir, {
// These are all required.
agent_id: "foo",
agent_name: "foo",
folder: "/home/coder",
});
expect(state.outputs.url.value).toBe(
"jetbrains-gateway://connect#type=coder&workspace=default&owner=default&agent=foo&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=241.14494.240&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.1.tar.gz",
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "gateway",
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("default to first ide", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",

View File

@@ -18,6 +18,12 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
variable "slug" {
type = string
description = "The slug for the coder_app"
default = "gateway"
}
variable "agent_name" {
type = string
description = "Agent name."
@@ -33,9 +39,9 @@ variable "folder" {
}
variable "default" {
default = ""
type = string
description = "Default IDE"
default = []
type = list(string)
description = "Default IDEs to be added to the Workspace page."
}
variable "order" {
@@ -140,9 +146,35 @@ variable "jetbrains_ides" {
}
}
variable "releases_base_link" {
type = string
description = ""
default = "https://data.services.jetbrains.com"
validation {
condition = can(regex("^https?://.+$", var.releases_base_link))
error_message = "The releases_base_link must be a valid HTTP/S address."
}
}
variable "download_base_link" {
type = string
description = ""
default = "https://download.jetbrains.com"
validation {
condition = can(regex("^https?://.+$", var.download_base_link))
error_message = "The download_base_link must be a valid HTTP/S address."
}
}
variable "provide_options" {
type = bool
description = "Whether to provide coder_parameter options."
default = true
}
data "http" "jetbrains_ide_versions" {
for_each = var.latest ? toset(var.jetbrains_ides) : toset([])
url = "https://data.services.jetbrains.com/products/releases?code=${each.key}&latest=true&type=${var.channel}"
url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}"
}
locals {
@@ -152,7 +184,7 @@ locals {
name = "GoLand",
identifier = "GO",
build_number = var.jetbrains_ide_versions["GO"].build_number,
download_link = "https://download.jetbrains.com/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz"
download_link = "${var.download_base_link}/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz"
version = var.jetbrains_ide_versions["GO"].version
},
"WS" = {
@@ -160,7 +192,7 @@ locals {
name = "WebStorm",
identifier = "WS",
build_number = var.jetbrains_ide_versions["WS"].build_number,
download_link = "https://download.jetbrains.com/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz"
download_link = "${var.download_base_link}/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz"
version = var.jetbrains_ide_versions["WS"].version
},
"IU" = {
@@ -168,7 +200,7 @@ locals {
name = "IntelliJ IDEA Ultimate",
identifier = "IU",
build_number = var.jetbrains_ide_versions["IU"].build_number,
download_link = "https://download.jetbrains.com/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz"
download_link = "${var.download_base_link}/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz"
version = var.jetbrains_ide_versions["IU"].version
},
"PY" = {
@@ -176,7 +208,7 @@ locals {
name = "PyCharm Professional",
identifier = "PY",
build_number = var.jetbrains_ide_versions["PY"].build_number,
download_link = "https://download.jetbrains.com/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz"
download_link = "${var.download_base_link}/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz"
version = var.jetbrains_ide_versions["PY"].version
},
"CL" = {
@@ -184,7 +216,7 @@ locals {
name = "CLion",
identifier = "CL",
build_number = var.jetbrains_ide_versions["CL"].build_number,
download_link = "https://download.jetbrains.com/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz"
download_link = "${var.download_base_link}/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz"
version = var.jetbrains_ide_versions["CL"].version
},
"PS" = {
@@ -192,7 +224,7 @@ locals {
name = "PhpStorm",
identifier = "PS",
build_number = var.jetbrains_ide_versions["PS"].build_number,
download_link = "https://download.jetbrains.com/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz"
download_link = "${var.download_base_link}/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz"
version = var.jetbrains_ide_versions["PS"].version
},
"RM" = {
@@ -200,7 +232,7 @@ locals {
name = "RubyMine",
identifier = "RM",
build_number = var.jetbrains_ide_versions["RM"].build_number,
download_link = "https://download.jetbrains.com/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz"
download_link = "${var.download_base_link}/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz"
version = var.jetbrains_ide_versions["RM"].version
}
"RD" = {
@@ -208,28 +240,24 @@ locals {
name = "Rider",
identifier = "RD",
build_number = var.jetbrains_ide_versions["RD"].build_number,
download_link = "https://download.jetbrains.com/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz"
download_link = "${var.download_base_link}/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz"
version = var.jetbrains_ide_versions["RD"].version
}
}
icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon
json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {}
key = var.latest ? keys(local.json_data)[0] : ""
display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name
identifier = data.coder_parameter.jetbrains_ide.value
download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link
build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number
version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version
default_ide_map = {
for ide in var.default : ide => local.jetbrains_ides[ide]
}
}
data "coder_parameter" "jetbrains_ide" {
for_each = local.default_ide_map
type = "string"
name = "jetbrains_ide"
display_name = "JetBrains IDE"
name = "jetbrains_ide_${each.key}"
display_name = "JetBrains IDE ${each.key}"
icon = "/icon/gateway.svg"
mutable = true
default = var.default == "" ? var.jetbrains_ides[0] : var.default
default = each.key
order = var.coder_parameter_order
dynamic "option" {
@@ -243,17 +271,21 @@ data "coder_parameter" "jetbrains_ide" {
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "gateway" {
for_each = local.default_ide_map
agent_id = var.agent_id
slug = "gateway"
display_name = local.display_name
icon = local.icon
slug = "${var.slug}-${lower(each.key)}"
display_name = each.value.name
icon = each.value.icon
external = true
order = var.order
url = join("", [
"jetbrains-gateway://connect#type=coder&workspace=",
data.coder_workspace.me.name,
"&owner=",
data.coder_workspace_owner.me.name,
"&agent=",
var.agent_name,
"&folder=",
@@ -263,38 +295,23 @@ resource "coder_app" "gateway" {
"&token=",
"$SESSION_TOKEN",
"&ide_product_code=",
data.coder_parameter.jetbrains_ide.value,
each.key,
"&ide_build_number=",
local.build_number,
each.value.build_number,
"&ide_download_link=",
local.download_link,
each.value.download_link,
])
}
output "identifier" {
value = local.identifier
output "coder_apps" {
value = {
for key, app in coder_app.gateway : key => {
identifier = key
display_name = app.display_name
icon = local.jetbrains_ides[key].icon
download_link = local.jetbrains_ides[key].download_link
build_number = local.jetbrains_ides[key].build_number
version = local.jetbrains_ides[key].version
}
output "display_name" {
value = local.display_name
}
output "icon" {
value = local.icon
}
output "download_link" {
value = local.download_link
}
output "build_number" {
value = local.build_number
}
output "version" {
value = local.version
}
output "url" {
value = coder_app.gateway.url
}

5
jfrog-oauth/.npmrc.tftpl Normal file
View File

@@ -0,0 +1,5 @@
email=${ARTIFACTORY_EMAIL}
%{ for REPO in REPOS ~}
${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME}
//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}
%{ endfor ~}

View File

@@ -17,15 +17,16 @@ 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.15"
version = "1.0.19"
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"
package_managers = {
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
npm = ["npm", "@scoped:npm-scoped"]
go = ["go", "another-go-repo"]
pypi = ["pypi", "extra-index-pypi"]
docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"]
}
}
```
@@ -44,13 +45,13 @@ 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.15"
version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://example.jfrog.io"
username_field = "email"
package_managers = {
"pypi" : "pypi"
pypi = ["pypi"]
}
}
```
@@ -72,15 +73,15 @@ 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.15"
version = "1.0.19"
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"
configure_code_server = true # Add JFrog extension configuration for code-server
package_managers = {
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
npm = ["npm"]
go = ["go"]
pypi = ["pypi"]
}
}
```

View File

@@ -1,19 +1,129 @@
import { serve } from "bun";
import { describe } from "bun:test";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
findResourceInstance,
runTerraformInit,
runTerraformApply,
testRequiredVariables,
} from "../test";
describe("jfrog-oauth", async () => {
type TestVariables = {
agent_id: string;
jfrog_url: string;
package_managers: string;
username_field?: string;
jfrog_server_id?: string;
external_auth_id?: string;
configure_code_server?: boolean;
};
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
const fakeFrogApi = "localhost:8081/artifactory/api";
const fakeFrogUrl = "http://localhost:8081";
const user = "default";
it("can run apply with required variables", async () => {
testRequiredVariables<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: "http://localhost:8081",
jfrog_url: fakeFrogUrl,
package_managers: "{}",
});
});
//TODO add more tests
it("generates an npmrc with scoped repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
package_managers: JSON.stringify({
npm: ["global", "@foo:foo", "@bar:bar"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const npmrcStanza = `cat << EOF > ~/.npmrc
email=${user}@example.com
registry=http://${fakeFrogApi}/npm/global
//${fakeFrogApi}/npm/global/:_authToken=
@foo:registry=http://${fakeFrogApi}/npm/foo
//${fakeFrogApi}/npm/foo/:_authToken=
@bar:registry=http://${fakeFrogApi}/npm/bar
//${fakeFrogApi}/npm/bar/:_authToken=
EOF`;
expect(coderScript.script).toContain(npmrcStanza);
expect(coderScript.script).toContain(
'jf npmc --global --repo-resolve "global"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured npm',
);
});
it("generates a pip config with extra-indexes", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
package_managers: JSON.stringify({
pypi: ["global", "foo", "bar"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const pipStanza = `cat << EOF > ~/.pip/pip.conf
[global]
index-url = https://${user}:@${fakeFrogApi}/pypi/global/simple
extra-index-url =
https://${user}:@${fakeFrogApi}/pypi/foo/simple
https://${user}:@${fakeFrogApi}/pypi/bar/simple
EOF`;
expect(coderScript.script).toContain(pipStanza);
expect(coderScript.script).toContain(
'jf pipc --global --repo-resolve "global"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured pypi',
);
});
it("registers multiple docker repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
package_managers: JSON.stringify({
docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const dockerStanza = ["foo", "bar", "baz"]
.map((r) => `register_docker "${r}.jfrog.io"`)
.join("\n");
expect(coderScript.script).toContain(dockerStanza);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured docker',
);
});
it("sets goproxy with multiple repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
package_managers: JSON.stringify({
go: ["foo", "bar", "baz"],
}),
});
const proxyEnv = findResourceInstance(state, "coder_env", "goproxy");
const proxies = ["foo", "bar", "baz"]
.map((r) => `https://${user}:@${fakeFrogApi}/go/${r}`)
.join(",");
expect(proxyEnv.value).toEqual(proxies);
const coderScript = findResourceInstance(state, "coder_script");
expect(coderScript.script).toContain(
'jf goc --global --repo-resolve "foo"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured go',
);
});
});

View File

@@ -53,15 +53,20 @@ variable "configure_code_server" {
}
variable "package_managers" {
type = map(string)
description = <<EOF
A map of package manager names to their respective artifactory repositories.
type = object({
npm = optional(list(string), [])
go = optional(list(string), [])
pypi = optional(list(string), [])
docker = optional(list(string), [])
})
description = <<-EOF
A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted.
For example:
{
"npm": "YOUR_NPM_REPO_KEY",
"go": "YOUR_GO_REPO_KEY",
"pypi": "YOUR_PYPI_REPO_KEY",
"docker": "YOUR_DOCKER_REPO_KEY"
npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"]
go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"]
pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"]
docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"]
}
EOF
}
@@ -69,7 +74,30 @@ EOF
locals {
# The username field to use for artifactory
username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name
jfrog_host = replace(var.jfrog_url, "https://", "")
jfrog_host = split("://", var.jfrog_url)[1]
common_values = {
JFROG_URL = var.jfrog_url
JFROG_HOST = local.jfrog_host
JFROG_SERVER_ID = var.jfrog_server_id
ARTIFACTORY_USERNAME = local.username
ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email
ARTIFACTORY_ACCESS_TOKEN = data.coder_external_auth.jfrog.access_token
}
npmrc = templatefile(
"${path.module}/.npmrc.tftpl",
merge(
local.common_values,
{
REPOS = [
for r in var.package_managers.npm :
strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r }
]
}
)
)
pip_conf = templatefile(
"${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi })
)
}
data "coder_workspace" "me" {}
@@ -83,19 +111,22 @@ resource "coder_script" "jfrog" {
agent_id = var.agent_id
display_name = "jfrog"
icon = "/icon/jfrog.svg"
script = templatefile("${path.module}/run.sh", {
JFROG_URL : var.jfrog_url,
JFROG_HOST : local.jfrog_host,
JFROG_SERVER_ID : var.jfrog_server_id,
ARTIFACTORY_USERNAME : local.username,
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", ""),
REPOSITORY_GO : lookup(var.package_managers, "go", ""),
REPOSITORY_PYPI : lookup(var.package_managers, "pypi", ""),
REPOSITORY_DOCKER : lookup(var.package_managers, "docker", ""),
})
script = templatefile("${path.module}/run.sh", merge(
local.common_values,
{
CONFIGURE_CODE_SERVER = var.configure_code_server
HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES"
NPMRC = local.npmrc
REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "")
HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES"
REPOSITORY_GO = try(element(var.package_managers.go, 0), "")
HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES"
PIP_CONF = local.pip_conf
REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "")
HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES"
REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker))
}
))
run_on_start = true
}
@@ -121,10 +152,13 @@ resource "coder_env" "jfrog_ide_store_connection" {
}
resource "coder_env" "goproxy" {
count = lookup(var.package_managers, "go", "") == "" ? 0 : 1
count = length(var.package_managers.go) == 0 ? 0 : 1
agent_id = var.agent_id
name = "GOPROXY"
value = "https://${local.username}:${data.coder_external_auth.jfrog.access_token}@${local.jfrog_host}/artifactory/api/go/${lookup(var.package_managers, "go", "")}"
value = join(",", [
for repo in var.package_managers.go :
"https://${local.username}:${data.coder_external_auth.jfrog.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}"
])
}
output "access_token" {

View File

@@ -0,0 +1,6 @@
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple
extra-index-url =
%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~}
https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple
%{ endfor ~}

View File

@@ -2,6 +2,21 @@
BOLD='\033[0;1m'
not_configured() {
type=$1
echo "🤔 no $type repository is set, skipping $type configuration."
echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input."
}
config_complete() {
echo "🥳 Configuration complete!"
}
register_docker() {
repo=$1
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin
}
# check if JFrog CLI is already installed
if command -v jf > /dev/null 2>&1; then
echo "✅ JFrog CLI is already installed, skipping installation."
@@ -20,52 +35,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
if [ -z "${REPOSITORY_NPM}" ]; then
echo "🤔 no npm repository is set, skipping npm configuration."
echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
if [ -z "${HAS_NPM}" ]; then
not_configured npm
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
email=${ARTIFACTORY_EMAIL}
registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
${NPMRC}
EOF
echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
config_complete
fi
# Configure the `pip` to use the Artifactory "python" repository.
if [ -z "${REPOSITORY_PYPI}" ]; then
echo "🤔 no pypi repository is set, skipping pip configuration."
echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
if [ -z "${HAS_PYPI}" ]; then
not_configured pypi
else
echo "📦 Configuring pip..."
echo "🐍 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
${PIP_CONF}
EOF
config_complete
fi
# Configure Artifactory "go" repository.
if [ -z "${REPOSITORY_GO}" ]; then
echo "🤔 no go repository is set, skipping go configuration."
echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
if [ -z "${HAS_GO}" ]; then
not_configured go
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
config_complete
fi
echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
if [ -z "${REPOSITORY_DOCKER}" ]; then
echo "🤔 no docker repository is set, skipping docker configuration."
echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
if [ -z "${HAS_DOCKER}" ]; then
not_configured docker
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
${REGISTER_DOCKER}
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@@ -96,20 +106,19 @@ echo "📦 Configuring JFrog CLI completion..."
SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
# Generate the completion script
jf completion $SHELLNAME --install
begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)"
# Add the completion script to the user's shell profile
if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
echo "" >> ~/.bashrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.bashrc
if ! grep -q "$begin_stanza" ~/.bashrc; then
printf "%s\n" "$begin_stanza" >> ~/.bashrc
echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
echo "# END: jf CLI shell completion" >> ~/.bashrc
else
echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
fi
elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
echo "" >> ~/.zshrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.zshrc
if ! grep -q "$begin_stanza" ~/.zshrc; then
printf "\n%s\n" "$begin_stanza" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc

5
jfrog-token/.npmrc.tftpl Normal file
View File

@@ -0,0 +1,5 @@
email=${ARTIFACTORY_EMAIL}
%{ for REPO in REPOS ~}
${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME}
//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}
%{ endfor ~}

View File

@@ -15,14 +15,15 @@ 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.15"
version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
package_managers = {
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
npm = ["npm", "@scoped:npm-scoped"]
go = ["go", "another-go-repo"]
pypi = ["pypi", "extra-index-pypi"]
docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"]
}
}
```
@@ -41,14 +42,14 @@ 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.15"
version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://YYYY.jfrog.io"
artifactory_access_token = var.artifactory_access_token # An admin access token
package_managers = {
"npm" : "npm-local",
"go" : "go-local",
"pypi" : "pypi-local"
npm = ["npm-local"]
go = ["go-local"]
pypi = ["pypi-local"]
}
}
```
@@ -74,15 +75,15 @@ 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.15"
version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
configure_code_server = true # Add JFrog extension configuration for code-server
package_managers = {
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
npm = ["npm"]
go = ["go"]
pypi = ["pypi"]
}
}
```
@@ -94,15 +95,13 @@ data "coder_workspace" "me" {}
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
version = "1.0.15"
version = "1.0.19"
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_owner.me.name}/${data.coder_workspace.me.name}"
package_managers = {
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
npm = ["npm"]
}
}
```

View File

@@ -1,12 +1,29 @@
import { serve } from "bun";
import { describe } from "bun:test";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
findResourceInstance,
runTerraformInit,
runTerraformApply,
testRequiredVariables,
} from "../test";
describe("jfrog-token", async () => {
type TestVariables = {
agent_id: string;
jfrog_url: string;
artifactory_access_token: string;
package_managers: string;
token_description?: string;
check_license?: boolean;
refreshable?: boolean;
expires_in?: number;
username_field?: string;
jfrog_server_id?: string;
configure_code_server?: boolean;
};
await runTerraformInit(import.meta.dir);
// Run a fake JFrog server so the provider can initialize
@@ -32,10 +49,116 @@ describe("jfrog-token", async () => {
port: 0,
});
testRequiredVariables(import.meta.dir, {
const fakeFrogApi = `${fakeFrogHost.hostname}:${fakeFrogHost.port}/artifactory/api`;
const fakeFrogUrl = `http://${fakeFrogHost.hostname}:${fakeFrogHost.port}`;
const user = "default";
const token = "xxx";
it("can run apply with required variables", async () => {
testRequiredVariables<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: "http://" + fakeFrogHost.hostname + ":" + fakeFrogHost.port,
jfrog_url: fakeFrogUrl,
artifactory_access_token: "XXXX",
package_managers: "{}",
});
});
it("generates an npmrc with scoped repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
artifactory_access_token: "XXXX",
package_managers: JSON.stringify({
npm: ["global", "@foo:foo", "@bar:bar"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const npmrcStanza = `cat << EOF > ~/.npmrc
email=${user}@example.com
registry=http://${fakeFrogApi}/npm/global
//${fakeFrogApi}/npm/global/:_authToken=xxx
@foo:registry=http://${fakeFrogApi}/npm/foo
//${fakeFrogApi}/npm/foo/:_authToken=xxx
@bar:registry=http://${fakeFrogApi}/npm/bar
//${fakeFrogApi}/npm/bar/:_authToken=xxx
EOF`;
expect(coderScript.script).toContain(npmrcStanza);
expect(coderScript.script).toContain(
'jf npmc --global --repo-resolve "global"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured npm',
);
});
it("generates a pip config with extra-indexes", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
artifactory_access_token: "XXXX",
package_managers: JSON.stringify({
pypi: ["global", "foo", "bar"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const pipStanza = `cat << EOF > ~/.pip/pip.conf
[global]
index-url = https://${user}:${token}@${fakeFrogApi}/pypi/global/simple
extra-index-url =
https://${user}:${token}@${fakeFrogApi}/pypi/foo/simple
https://${user}:${token}@${fakeFrogApi}/pypi/bar/simple
EOF`;
expect(coderScript.script).toContain(pipStanza);
expect(coderScript.script).toContain(
'jf pipc --global --repo-resolve "global"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured pypi',
);
});
it("registers multiple docker repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
artifactory_access_token: "XXXX",
package_managers: JSON.stringify({
docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"],
}),
});
const coderScript = findResourceInstance(state, "coder_script");
const dockerStanza = ["foo", "bar", "baz"]
.map((r) => `register_docker "${r}.jfrog.io"`)
.join("\n");
expect(coderScript.script).toContain(dockerStanza);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured docker',
);
});
it("sets goproxy with multiple repos", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: fakeFrogUrl,
artifactory_access_token: "XXXX",
package_managers: JSON.stringify({
go: ["foo", "bar", "baz"],
}),
});
const proxyEnv = findResourceInstance(state, "coder_env", "goproxy");
const proxies = ["foo", "bar", "baz"]
.map((r) => `https://${user}:${token}@${fakeFrogApi}/go/${r}`)
.join(",");
expect(proxyEnv.value).toEqual(proxies);
const coderScript = findResourceInstance(state, "coder_script");
expect(coderScript.script).toContain(
'jf goc --global --repo-resolve "foo"',
);
expect(coderScript.script).toContain(
'if [ -z "YES" ]; then\n not_configured go',
);
});
});

View File

@@ -80,15 +80,20 @@ variable "configure_code_server" {
}
variable "package_managers" {
type = map(string)
description = <<EOF
A map of package manager names to their respective artifactory repositories.
type = object({
npm = optional(list(string), [])
go = optional(list(string), [])
pypi = optional(list(string), [])
docker = optional(list(string), [])
})
description = <<-EOF
A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted.
For example:
{
"npm": "YOUR_NPM_REPO_KEY",
"go": "YOUR_GO_REPO_KEY",
"pypi": "YOUR_PYPI_REPO_KEY",
"docker": "YOUR_DOCKER_REPO_KEY"
npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"]
go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"]
pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"]
docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"]
}
EOF
}
@@ -96,7 +101,30 @@ EOF
locals {
# The username field to use for artifactory
username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name
jfrog_host = replace(var.jfrog_url, "https://", "")
jfrog_host = split("://", var.jfrog_url)[1]
common_values = {
JFROG_URL = var.jfrog_url
JFROG_HOST = local.jfrog_host
JFROG_SERVER_ID = var.jfrog_server_id
ARTIFACTORY_USERNAME = local.username
ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email
ARTIFACTORY_ACCESS_TOKEN = artifactory_scoped_token.me.access_token
}
npmrc = templatefile(
"${path.module}/.npmrc.tftpl",
merge(
local.common_values,
{
REPOS = [
for r in var.package_managers.npm :
strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r }
]
}
)
)
pip_conf = templatefile(
"${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi })
)
}
# Configure the Artifactory provider
@@ -123,19 +151,22 @@ resource "coder_script" "jfrog" {
agent_id = var.agent_id
display_name = "jfrog"
icon = "/icon/jfrog.svg"
script = templatefile("${path.module}/run.sh", {
JFROG_URL : var.jfrog_url,
JFROG_HOST : local.jfrog_host,
JFROG_SERVER_ID : var.jfrog_server_id,
ARTIFACTORY_USERNAME : local.username,
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", ""),
REPOSITORY_GO : lookup(var.package_managers, "go", ""),
REPOSITORY_PYPI : lookup(var.package_managers, "pypi", ""),
REPOSITORY_DOCKER : lookup(var.package_managers, "docker", ""),
})
script = templatefile("${path.module}/run.sh", merge(
local.common_values,
{
CONFIGURE_CODE_SERVER = var.configure_code_server
HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES"
NPMRC = local.npmrc
REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "")
HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES"
REPOSITORY_GO = try(element(var.package_managers.go, 0), "")
HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES"
PIP_CONF = local.pip_conf
REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "")
HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES"
REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker))
}
))
run_on_start = true
}
@@ -161,10 +192,13 @@ resource "coder_env" "jfrog_ide_store_connection" {
}
resource "coder_env" "goproxy" {
count = lookup(var.package_managers, "go", "") == "" ? 0 : 1
count = length(var.package_managers.go) == 0 ? 0 : 1
agent_id = var.agent_id
name = "GOPROXY"
value = "https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${lookup(var.package_managers, "go", "")}"
value = join(",", [
for repo in var.package_managers.go :
"https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}"
])
}
output "access_token" {

View File

@@ -0,0 +1,6 @@
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple
extra-index-url =
%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~}
https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple
%{ endfor ~}

View File

@@ -2,6 +2,21 @@
BOLD='\033[0;1m'
not_configured() {
type=$1
echo "🤔 no $type repository is set, skipping $type configuration."
echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input."
}
config_complete() {
echo "🥳 Configuration complete!"
}
register_docker() {
repo=$1
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin
}
# check if JFrog CLI is already installed
if command -v jf > /dev/null 2>&1; then
echo "✅ JFrog CLI is already installed, skipping installation."
@@ -11,8 +26,7 @@ else
sudo chmod 755 /usr/local/bin/jf
fi
# The jf CLI checks $CI when determining whether to use interactive
# flows.
# The jf CLI checks $CI when determining whether to use interactive flows.
export CI=true
# Authenticate JFrog CLI with Artifactory.
echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}"
@@ -20,52 +34,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
if [ -z "${REPOSITORY_NPM}" ]; then
echo "🤔 no npm repository is set, skipping npm configuration."
echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
if [ -z "${HAS_NPM}" ]; then
not_configured npm
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
email=${ARTIFACTORY_EMAIL}
registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
${NPMRC}
EOF
echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
config_complete
fi
# Configure the `pip` to use the Artifactory "python" repository.
if [ -z "${REPOSITORY_PYPI}" ]; then
echo "🤔 no pypi repository is set, skipping pip configuration."
echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
if [ -z "${HAS_PYPI}" ]; then
not_configured pypi
else
echo "🐍 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
${PIP_CONF}
EOF
config_complete
fi
# Configure Artifactory "go" repository.
if [ -z "${REPOSITORY_GO}" ]; then
echo "🤔 no go repository is set, skipping go configuration."
echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
if [ -z "${HAS_GO}" ]; then
not_configured go
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
config_complete
fi
echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
if [ -z "${REPOSITORY_DOCKER}" ]; then
echo "🤔 no docker repository is set, skipping docker configuration."
echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
if [ -z "${HAS_DOCKER}" ]; then
not_configured docker
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
${REGISTER_DOCKER}
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@@ -96,20 +105,19 @@ echo "📦 Configuring JFrog CLI completion..."
SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
# Generate the completion script
jf completion $SHELLNAME --install
begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-token)"
# Add the completion script to the user's shell profile
if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
echo "" >> ~/.bashrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.bashrc
if ! grep -q "$begin_stanza" ~/.bashrc; then
printf "%s\n" "$begin_stanza" >> ~/.bashrc
echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
echo "# END: jf CLI shell completion" >> ~/.bashrc
else
echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
fi
elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
echo "" >> ~/.zshrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.zshrc
if ! grep -q "$begin_stanza" ~/.zshrc; then
printf "\n%s\n" "$begin_stanza" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc

View File

@@ -16,7 +16,7 @@ A module that adds Jupyter Notebook in your Coder template.
```tf
module "jupyter-notebook" {
source = "registry.coder.com/modules/jupyter-notebook/coder"
version = "1.0.8"
version = "1.0.19"
agent_id = coder_agent.example.id
}
```

View File

@@ -7,14 +7,14 @@ printf "$${BOLD}Installing jupyter-notebook!\n"
# check if jupyter-notebook is installed
if ! command -v jupyter-notebook > /dev/null 2>&1; then
# install jupyter-notebook
# check if python3 pip is installed
if ! command -v pip3 > /dev/null 2>&1; then
echo "pip3 is not installed"
echo "Please install pip3 in your Dockerfile/VM image before running this script"
# check if pipx is installed
if ! command -v pipx > /dev/null 2>&1; then
echo "pipx is not installed"
echo "Please install pipx in your Dockerfile/VM image before using this module"
exit 1
fi
# install jupyter-notebook
pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyter
# install jupyter notebook
pipx install -q notebook
echo "🥳 jupyter-notebook has been installed\n\n"
else
echo "🥳 jupyter-notebook is already installed\n\n"
@@ -22,4 +22,4 @@ fi
echo "👷 Starting jupyter-notebook in background..."
echo "check logs at ${LOG_PATH}"
$HOME/.local/bin/jupyter notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &

View File

@@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
```tf
module "jupyterlab" {
source = "registry.coder.com/modules/jupyterlab/coder"
version = "1.0.8"
version = "1.0.23"
agent_id = coder_agent.example.id
}
```

View File

@@ -1,20 +1,20 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
executeScriptInContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
findResourceInstance,
runContainer,
TerraformState,
execContainer,
type TerraformState,
} from "../test";
// executes the coder script after installing pip
const executeScriptInContainerWithPip = async (
state: TerraformState,
image: string,
shell: string = "sh",
shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
@@ -22,7 +22,7 @@ const executeScriptInContainerWithPip = async (
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]);
const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]);
const resp = await execContainer(id, [shell, "-c", instance.script]);
const stdout = resp.stdout.trim().split("\n");
const stderr = resp.stderr.trim().split("\n");
@@ -40,7 +40,7 @@ describe("jupyterlab", async () => {
agent_id: "foo",
});
it("fails without pip3", async () => {
it("fails without pipx", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
@@ -48,14 +48,14 @@ describe("jupyterlab", async () => {
expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([
"\u001B[0;1mInstalling jupyterlab!",
"pip3 is not installed",
"Please install pip3 in your Dockerfile/VM image before running this script",
"pipx is not installed",
"Please install pipx in your Dockerfile/VM image before running this script",
]);
});
// TODO: Add faster test to run with pip3.
// TODO: Add faster test to run with pipx.
// currently times out.
// it("runs with pip3", async () => {
// it("runs with pipx", async () => {
// ...
// const output = await executeScriptInContainerWithPip(state, "alpine");
// ...

View File

@@ -9,6 +9,9 @@ terraform {
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# Add required variables for your modules and remove any unneeded variables
variable "agent_id" {
type = string
@@ -36,6 +39,12 @@ variable "share" {
}
}
variable "subdomain" {
type = bool
description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder."
default = true
}
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)."
@@ -49,17 +58,18 @@ resource "coder_script" "jupyterlab" {
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port
BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab"
})
run_on_start = true
}
resource "coder_app" "jupyterlab" {
agent_id = var.agent_id
slug = "jupyterlab"
slug = "jupyterlab" # sync with the usage in URL
display_name = "JupyterLab"
url = "http://localhost:${var.port}"
url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab"
icon = "/icon/jupyter.svg"
subdomain = true
subdomain = var.subdomain
share = var.share
order = var.order
}

View File

@@ -1,25 +1,35 @@
#!/usr/bin/env sh
if [ -n "${BASE_URL}" ]; then
BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}"
fi
BOLD='\033[0;1m'
printf "$${BOLD}Installing jupyterlab!\n"
# check if jupyterlab is installed
if ! command -v jupyterlab > /dev/null 2>&1; then
if ! command -v jupyter-lab > /dev/null 2>&1; then
# install jupyterlab
# check if python3 pip is installed
if ! command -v pip3 > /dev/null 2>&1; then
echo "pip3 is not installed"
echo "Please install pip3 in your Dockerfile/VM image before running this script"
# check if pipx is installed
if ! command -v pipx > /dev/null 2>&1; then
echo "pipx is not installed"
echo "Please install pipx in your Dockerfile/VM image before running this script"
exit 1
fi
# install jupyterlab
pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyterlab
echo "🥳 jupyterlab has been installed\n\n"
pipx install -q jupyterlab
printf "%s\n\n" "🥳 jupyterlab has been installed"
else
echo "🥳 jupyterlab is already installed\n\n"
printf "%s\n\n" "🥳 jupyterlab is already installed"
fi
echo "👷 Starting jupyterlab in background..."
echo "check logs at ${LOG_PATH}"
$HOME/.local/bin/jupyter lab --ServerApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
printf "👷 Starting jupyterlab in background..."
printf "check logs at ${LOG_PATH}"
$HOME/.local/bin/jupyter-lab --no-browser \
"$BASE_URL_FLAG" \
--ServerApp.ip='*' \
--ServerApp.port="${PORT}" \
--ServerApp.token='' \
--ServerApp.password='' \
> "${LOG_PATH}" 2>&1 &

23
kasmvnc/README.md Normal file
View File

@@ -0,0 +1,23 @@
---
display_name: KasmVNC
description: A modern open source VNC server
icon: ../.icons/kasmvnc.svg
maintainer_github: coder
verified: true
tags: [helper, vnc, desktop]
---
# KasmVNC
Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and create an app to access it via the dashboard.
```tf
module "kasmvnc" {
source = "registry.coder.com/modules/kasmvnc/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
}
```
> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image.

37
kasmvnc/main.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const;
type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number];
type TestVariables = Readonly<{
agent_id: string;
desktop_environment: AllowedDesktopEnv;
port?: string;
kasm_version?: string;
}>;
describe("Kasm VNC", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables<TestVariables>(import.meta.dir, {
agent_id: "foo",
desktop_environment: "gnome",
});
it("Successfully installs for all expected Kasm desktop versions", async () => {
for (const v of allowedDesktopEnvs) {
const applyWithEnv = () => {
runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "foo",
desktop_environment: v,
});
};
expect(applyWithEnv).not.toThrow();
}
});
});

63
kasmvnc/main.tf Normal file
View File

@@ -0,0 +1,63 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "port" {
type = number
description = "The port to run KasmVNC on."
default = 6800
}
variable "kasm_version" {
type = string
description = "Version of KasmVNC to install."
default = "1.3.2"
}
variable "desktop_environment" {
type = string
description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace."
validation {
condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment)
error_message = "Invalid desktop environment. Please specify a valid desktop environment."
}
}
resource "coder_script" "kasm_vnc" {
agent_id = var.agent_id
display_name = "KasmVNC"
icon = "/icon/kasmvnc.svg"
script = templatefile("${path.module}/run.sh", {
PORT : var.port,
DESKTOP_ENVIRONMENT : var.desktop_environment,
KASM_VERSION : var.kasm_version
})
run_on_start = true
}
resource "coder_app" "kasm_vnc" {
agent_id = var.agent_id
slug = "kasm-vnc"
display_name = "kasmVNC"
url = "http://localhost:${var.port}"
icon = "/icon/kasmvnc.svg"
subdomain = true
share = "owner"
healthcheck {
url = "http://localhost:${var.port}/app"
interval = 5
threshold = 5
}
}

235
kasmvnc/run.sh Normal file
View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# Exit on error, undefined variables, and pipe failures
set -euo pipefail
# Function to check if vncserver is already installed
check_installed() {
if command -v vncserver &> /dev/null; then
echo "vncserver is already installed."
return 0 # Don't exit, just indicate it's installed
else
return 1 # Indicates not installed
fi
}
# Function to download a file using wget, curl, or busybox as a fallback
download_file() {
local url="$1"
local output="$2"
local download_tool
if command -v curl &> /dev/null; then
# shellcheck disable=SC2034
download_tool=(curl -fsSL)
elif command -v wget &> /dev/null; then
# shellcheck disable=SC2034
download_tool=(wget -q -O-)
elif command -v busybox &> /dev/null; then
# shellcheck disable=SC2034
download_tool=(busybox wget -O-)
else
echo "ERROR: No download tool available (curl, wget, or busybox required)"
exit 1
fi
# shellcheck disable=SC2288
"$${download_tool[@]}" "$url" > "$output" || {
echo "ERROR: Failed to download $url"
exit 1
}
}
# Function to install kasmvncserver for debian-based distros
install_deb() {
local url=$1
local kasmdeb="/tmp/kasmvncserver.deb"
download_file "$url" "$kasmdeb"
CACHE_DIR="/var/lib/apt/lists/partial"
# Check if the directory exists and was modified in the last 60 minutes
if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then
echo "Stale package cache, updating..."
# Update package cache with a 300-second timeout for dpkg lock
sudo apt-get -o DPkg::Lock::Timeout=300 -qq update
fi
DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb"
rm "$kasmdeb"
}
# Function to install kasmvncserver for rpm-based distros
install_rpm() {
local url=$1
local kasmrpm="/tmp/kasmvncserver.rpm"
local package_manager
if command -v dnf &> /dev/null; then
# shellcheck disable=SC2034
package_manager=(dnf localinstall -y)
elif command -v zypper &> /dev/null; then
# shellcheck disable=SC2034
package_manager=(zypper install -y)
elif command -v yum &> /dev/null; then
# shellcheck disable=SC2034
package_manager=(yum localinstall -y)
elif command -v rpm &> /dev/null; then
# Do we need to manually handle missing dependencies?
# shellcheck disable=SC2034
package_manager=(rpm -i)
else
echo "ERROR: No supported package manager available (dnf, zypper, yum, or rpm required)"
exit 1
fi
download_file "$url" "$kasmrpm"
# shellcheck disable=SC2288
sudo "$${package_manager[@]}" "$kasmrpm" || {
echo "ERROR: Failed to install $kasmrpm"
exit 1
}
rm "$kasmrpm"
}
# Function to install kasmvncserver for Alpine Linux
install_alpine() {
local url=$1
local kasmtgz="/tmp/kasmvncserver.tgz"
download_file "$url" "$kasmtgz"
tar -xzf "$kasmtgz" -C /usr/local/bin/
rm "$kasmtgz"
}
# Detect system information
if [[ ! -f /etc/os-release ]]; then
echo "ERROR: Cannot detect OS: /etc/os-release not found"
exit 1
fi
# shellcheck disable=SC1091
source /etc/os-release
distro="$ID"
distro_version="$VERSION_ID"
codename="$VERSION_CODENAME"
arch="$(uname -m)"
if [[ "$ID" == "ol" ]]; then
distro="oracle"
distro_version="$${distro_version%%.*}"
elif [[ "$ID" == "fedora" ]]; then
distro_version="$(grep -oP '\(\K[\w ]+' /etc/fedora-release | tr '[:upper:]' '[:lower:]' | tr -d ' ')"
fi
echo "Detected Distribution: $distro"
echo "Detected Version: $distro_version"
echo "Detected Codename: $codename"
echo "Detected Architecture: $arch"
# Map arch to package arch
case "$arch" in
x86_64)
if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then
arch="amd64"
fi
;;
aarch64)
if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then
arch="arm64"
fi
;;
arm64)
: # This is effectively a noop
;;
*)
echo "ERROR: Unsupported architecture: $arch"
exit 1
;;
esac
# Check if vncserver is installed, and install if not
if ! check_installed; then
# Check for NOPASSWD sudo (required)
if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then
echo "ERROR: sudo NOPASSWD access required!"
exit 1
fi
base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}"
echo "Installing KASM version: ${KASM_VERSION}"
case $distro in
ubuntu | debian | kali)
bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb"
install_deb "$base_url/$bin_name"
;;
oracle | fedora | opensuse)
bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm"
install_rpm "$base_url/$bin_name"
;;
alpine)
bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz"
install_alpine "$base_url/$bin_name"
;;
*)
echo "Unsupported distribution: $distro"
exit 1
;;
esac
else
echo "vncserver already installed. Skipping installation."
fi
if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then
kasm_config_file="/etc/kasmvnc/kasmvnc.yaml"
SUDO=sudo
else
kasm_config_file="$HOME/.vnc/kasmvnc.yaml"
SUDO=
echo "WARNING: Sudo access not available, using user config dir!"
if [[ -f "$kasm_config_file" ]]; then
echo "WARNING: Custom user KasmVNC config exists, not overwriting!"
echo "WARNING: Ensure that you manually configure the appropriate settings."
kasm_config_file="/dev/stderr"
else
echo "WARNING: This may prevent custom user KasmVNC settings from applying!"
mkdir -p "$HOME/.vnc"
fi
fi
echo "Writing KasmVNC config to $kasm_config_file"
$SUDO tee "$kasm_config_file" > /dev/null << EOF
network:
protocol: http
websocket_port: ${PORT}
ssl:
require_ssl: false
pem_certificate:
pem_key:
udp:
public_ip: 127.0.0.1
EOF
# This password is not used since we start the server without auth.
# The server is protected via the Coder session token / tunnel
# and does not listen publicly
echo -e "password\npassword\n" | vncpasswd -wo -u "$USER"
# Start the server
printf "🚀 Starting KasmVNC server...\n"
vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 &
pid=$!
# Wait for server to start
sleep 5
grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10
if ps -p $pid | grep -q "^$pid"; then
echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log"
exit 1
fi
printf "🚀 KasmVNC server started successfully!\n"

15
lint.ts
View File

@@ -5,14 +5,15 @@ import grayMatter from "gray-matter";
const files = await readdir(".", { withFileTypes: true });
const dirs = files.filter(
(f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules"
(f) =>
f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules",
);
let badExit = false;
// error reports an error to the console and sets badExit to true
// so that the process will exit with a non-zero exit code.
const error = (...data: any[]) => {
const error = (...data: unknown[]) => {
console.error(...data);
badExit = true;
};
@@ -22,7 +23,7 @@ const verifyCodeBlocks = (
res = {
codeIsTF: false,
codeIsHCL: false,
}
},
) => {
for (const token of tokens) {
// Check in-depth.
@@ -30,7 +31,12 @@ const verifyCodeBlocks = (
verifyCodeBlocks(token.items, res);
continue;
}
if (token.type === "list_item") {
if (token.tokens === undefined) {
throw new Error("Tokens are missing for type list_item");
}
verifyCodeBlocks(token.tokens, res);
continue;
}
@@ -80,8 +86,9 @@ for (const dir of dirs) {
if (!data.maintainer_github) {
error(dir.name, "missing maintainer_github");
}
try {
await stat(path.join(".", dir.name, data.icon));
await stat(path.join(".", dir.name, data.icon ?? ""));
} catch (ex) {
error(dir.name, "icon does not exist", data.icon);
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "bun:test";
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
describe("nodejs", async () => {

264
package-lock.json generated
View File

@@ -1,264 +0,0 @@
{
"name": "modules",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modules",
"devDependencies": {
"bun-types": "^1.0.18",
"gray-matter": "^4.0.3",
"marked": "^12.0.0",
"prettier": "^3.2.5",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
"typescript": "^5.3.3"
}
},
"node_modules/@types/node": {
"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"
}
},
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
"integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/bun-types": {
"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.12.8",
"@types/ws": "~8.5.10"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dev": true,
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"dev": true,
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"dev": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mvdan-sh": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
"integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
"dev": true
},
"node_modules/prettier": {
"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"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz",
"integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==",
"dev": true,
"dependencies": {
"mvdan-sh": "^0.10.1",
"sh-syntax": "^0.4.1"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
},
"peerDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/prettier-plugin-terraform-formatter": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz",
"integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==",
"dev": true,
"peerDependencies": {
"prettier": ">= 1.16.0"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
}
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"dev": true,
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/sh-syntax": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
"integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
"dev": true,
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"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.5.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
"integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

View File

@@ -8,15 +8,15 @@
"update-version": "./update-version.sh"
},
"devDependencies": {
"bun-types": "^1.0.18",
"bun-types": "^1.1.23",
"gray-matter": "^4.0.3",
"marked": "^12.0.0",
"prettier": "^3.2.5",
"marked": "^12.0.2",
"prettier": "^3.3.3",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
"typescript": "^5.3.3"
"typescript": "^5.5.4"
},
"prettier": {
"plugins": [

View File

@@ -1,13 +1,9 @@
import { readableStreamToText, spawn } from "bun";
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
findResourceInstance,
} from "../test";
describe("personalize", async () => {

View File

@@ -72,7 +72,7 @@ executed`,
it("formats execution with milliseconds", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
format: "$COMMAND took $DURATION",
durationMS: 150,
output: "echo test took 150ms",
});
@@ -81,7 +81,7 @@ executed`,
it("formats execution with seconds", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
format: "$COMMAND took $DURATION",
durationMS: 15000,
output: "echo test took 15.0s",
});
@@ -90,7 +90,7 @@ executed`,
it("formats execution with minutes", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
format: "$COMMAND took $DURATION",
durationMS: 120000,
output: "echo test took 2m 0.0s",
});
@@ -99,7 +99,7 @@ executed`,
it("formats execution with hours", async () => {
await assertSlackMessage({
command: "echo test",
format: `$COMMAND took $DURATION`,
format: "$COMMAND took $DURATION",
durationMS: 60000 * 60,
output: "echo test took 1hr 0m 0.0s",
});
@@ -126,7 +126,10 @@ const assertSlackMessage = async (opts: {
durationMS?: number;
output: string;
}) => {
let url: URL;
// Have to use non-null assertion because TS can't tell when the fetch
// function will run
let url!: URL;
const fakeSlackHost = serve({
fetch: (req) => {
url = new URL(req.url);
@@ -138,15 +141,16 @@ const assertSlackMessage = async (opts: {
},
port: 0,
});
const { instance, id } = await setupContainer(
"alpine/curl",
opts.format && {
slack_message: opts.format,
},
opts.format ? { slack_message: opts.format } : undefined,
);
await writeCoder(id, "echo 'token'");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
exec = await execContainer(id, [
"sh",
"-c",
@@ -154,6 +158,7 @@ const assertSlackMessage = async (opts: {
fakeSlackHost.hostname
}:${fakeSlackHost.port}" slackme ${opts.command}`,
]);
expect(exec.stderr.trim()).toBe("");
expect(url.pathname).toEqual("/api/chat.postMessage");
expect(url.searchParams.get("channel")).toEqual("token");

70
test.ts
View File

@@ -1,6 +1,6 @@
import { readableStreamToText, spawn } from "bun";
import { afterEach, expect, it } from "bun:test";
import { readFile, unlink } from "fs/promises";
import { expect, it } from "bun:test";
import { readFile, unlink } from "node:fs/promises";
export const runContainer = async (
image: string,
@@ -21,7 +21,8 @@ export const runContainer = async (
"-c",
init,
]);
let containerID = await readableStreamToText(proc.stdout);
const containerID = await readableStreamToText(proc.stdout);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(containerID);
@@ -36,7 +37,7 @@ export const runContainer = async (
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
shell: string = "sh",
shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
@@ -90,26 +91,35 @@ type TerraformStateResource = {
type: string;
name: string;
provider: string;
instances: [{ attributes: Record<string, any> }];
instances: [
{
attributes: Record<string, JsonValue>;
},
];
};
type TerraformOutput = {
type: string;
value: JsonValue;
};
export interface TerraformState {
outputs: {
[key: string]: {
type: string;
value: any;
};
};
outputs: Record<string, TerraformOutput>;
resources: [TerraformStateResource, ...TerraformStateResource[]];
}
type TerraformVariables = Record<string, JsonValue>;
export interface CoderScriptAttributes {
script: string;
agent_id: string;
url: string;
}
export type ResourceInstance<T extends string = string> =
T extends "coder_script" ? CoderScriptAttributes : Record<string, 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.
@@ -118,10 +128,7 @@ export const findResourceInstance = <T extends string>(
state: TerraformState,
type: T,
name?: string,
// if type is "coder_script" return CoderScriptAttributes
): T extends "coder_script"
? CoderScriptAttributes
: Record<string, string> => {
): ResourceInstance<T> => {
const resource = state.resources.find(
(resource) =>
resource.type === type && (name ? resource.name === name : true),
@@ -134,34 +141,41 @@ export const findResourceInstance = <T extends string>(
`Resource ${type} has ${resource.instances.length} instances`,
);
}
return resource.instances[0].attributes as any;
return resource.instances[0].attributes as ResourceInstance<T>;
};
/**
* Creates a test-case for each variable provided and ensures that the apply
* fails without it.
*/
export const testRequiredVariables = <TVars extends Record<string, string>>(
export const testRequiredVariables = <TVars extends TerraformVariables>(
dir: string,
vars: TVars,
vars: Readonly<TVars>,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
await runTerraformApply(dir, vars);
});
const varNames = Object.keys(vars);
varNames.forEach((varName) => {
for (const varName of varNames) {
// Ensures that every variable provided is required!
it("missing variable " + varName, async () => {
const localVars = {};
varNames.forEach((otherVarName) => {
it(`missing variable: ${varName}`, async () => {
const localVars: TerraformVariables = {};
for (const otherVarName of varNames) {
if (otherVarName !== varName) {
localVars[otherVarName] = vars[otherVarName];
}
});
}
try {
await runTerraformApply(dir, localVars);
} catch (ex) {
if (!(ex instanceof Error)) {
throw new Error("Unknown error generated");
}
expect(ex.message).toContain(
`input variable \"${varName}\" is not set`,
);
@@ -169,7 +183,7 @@ export const testRequiredVariables = <TVars extends Record<string, string>>(
}
throw new Error(`${varName} is not a required variable!`);
});
});
}
};
/**
@@ -177,11 +191,9 @@ export const testRequiredVariables = <TVars extends Record<string, string>>(
* 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>>,
>(
export const runTerraformApply = async <TVars extends TerraformVariables>(
dir: string,
vars: TVars,
vars: Readonly<TVars>,
env?: Record<string, string>,
): Promise<TerraformState> => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;

View File

@@ -1,9 +1,14 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
// If we were just compiling for the tests, we could safely target ESNext at
// all times, but just because we've been starting to add more runtime logic
// files to some of the modules, erring on the side of caution by having a
// older compilation target
"target": "ES6",
"module": "ESNext",
"strict": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "nodenext",
"moduleResolution": "node",
"types": ["bun-types"]
}
}

View File

@@ -1,20 +1,24 @@
#!/usr/bin/env bash
# This script updates the version number in the README.md files of all modules
# to the latest tag in the repository. It is intended to be run from the root
# This script increments the version number in the README.md files of all modules
# by 1 patch version. It is intended to be run from the root
# of the repository or by using the `bun update-version` command.
set -euo pipefail
current_tag=$(git describe --tags --abbrev=0)
previous_tag=$(git describe --tags --abbrev=0 $current_tag^)
mapfile -t changed_dirs < <(git diff --name-only "$previous_tag"..."$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
LATEST_TAG=$(git describe --abbrev=0 --tags | sed 's/^v//') || exit $?
# Increment the patch version
LATEST_TAG=$(echo "$current_tag" | sed 's/^v//' | awk -F. '{print $1"."$2"."$3+1}') || exit $?
# List directories with changes that are not README.md or test files
mapfile -t changed_dirs < <(git diff --name-only "$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
echo "Directories with changes: ${changed_dirs[*]}"
# Iterate over directories and update version in README.md
for dir in "${changed_dirs[@]}"; do
if [[ -f "$dir/README.md" ]]; then
echo "Bumping version in $dir/README.md"
file="$dir/README.md"
tmpfile=$(mktemp /tmp/tempfile.XXXXXX)
awk -v tag="$LATEST_TAG" '{
@@ -25,5 +29,12 @@ for dir in "${changed_dirs[@]}"; do
print
}
}' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
# Check if the README.md file has changed
if ! git diff --quiet -- "$dir/README.md"; then
echo "Bumping version in $dir/README.md from $current_tag to $LATEST_TAG (incremented)"
else
echo "Version in $dir/README.md is already up to date"
fi
fi
done

77
vault-jwt/README.md Normal file
View File

@@ -0,0 +1,77 @@
---
display_name: Hashicorp Vault Integration (JWT)
description: Authenticates with Vault using a JWT from Coder's OIDC provider
icon: ../.icons/vault.svg
maintainer_github: coder
partner_github: hashicorp
verified: true
tags: [helper, integration, vault, jwt, oidc]
---
# Hashicorp Vault Integration (JWT)
This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/auth#openid-connect) access token from Coder's OIDC authentication method. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method.
```tf
module "vault" {
source = "registry.coder.com/modules/vault-jwt/coder"
version = "1.0.20"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_role = "coder" # The Vault role to use for authentication
}
```
Then you can use the Vault CLI in your workspaces to fetch secrets from Vault:
```shell
vault kv get -namespace=coder -mount=secrets coder
```
or using the Vault API:
```shell
curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder"
```
## Examples
### Configure Vault integration with a non standard auth path (default is "jwt")
```tf
module "vault" {
source = "registry.coder.com/modules/vault-jwt/coder"
version = "1.0.20"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_auth_path = "oidc"
vault_jwt_role = "coder" # The Vault role to use for authentication
}
```
### Map workspace owner's group to a Vault role
```tf
data "coder_workspace_owner" "me" {}
module "vault" {
source = "registry.coder.com/modules/vault-jwt/coder"
version = "1.0.20"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_role = data.coder_workspace_owner.me.groups[0]
}
```
### Install a specific version of the Vault CLI
```tf
module "vault" {
source = "registry.coder.com/modules/vault-jwt/coder"
version = "1.0.20"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_role = "coder" # The Vault role to use for authentication
vault_cli_version = "1.17.5"
}
```

12
vault-jwt/main.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
describe("vault-jwt", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
vault_addr: "foo",
vault_jwt_role: "foo",
});
});

64
vault-jwt/main.tf Normal file
View File

@@ -0,0 +1,64 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12.4"
}
}
}
# Add required variables for your modules and remove any unneeded variables
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "vault_addr" {
type = string
description = "The address of the Vault server."
}
variable "vault_jwt_auth_path" {
type = string
description = "The path to the Vault JWT auth method."
default = "jwt"
}
variable "vault_jwt_role" {
type = string
description = "The name of the Vault role to use for authentication."
}
variable "vault_cli_version" {
type = string
description = "The version of Vault to install."
default = "latest"
validation {
condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version))
error_message = "Vault version must be in the format 0.0.0 or latest"
}
}
resource "coder_script" "vault" {
agent_id = var.agent_id
display_name = "Vault (GitHub)"
icon = "/icon/vault.svg"
script = templatefile("${path.module}/run.sh", {
CODER_OIDC_ACCESS_TOKEN : data.coder_workspace_owner.me.oidc_access_token,
VAULT_JWT_AUTH_PATH : var.vault_jwt_auth_path,
VAULT_JWT_ROLE : var.vault_jwt_role,
VAULT_CLI_VERSION : var.vault_cli_version,
})
run_on_start = true
start_blocks_login = true
}
resource "coder_env" "vault_addr" {
agent_id = var.agent_id
name = "VAULT_ADDR"
value = var.vault_addr
}
data "coder_workspace_owner" "me" {}

112
vault-jwt/run.sh Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Convert all templated variables to shell variables
VAULT_CLI_VERSION=${VAULT_CLI_VERSION}
VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH}
VAULT_JWT_ROLE=${VAULT_JWT_ROLE}
CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN}
fetch() {
dest="$1"
url="$2"
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$${url}" -o "$${dest}"
elif command -v wget > /dev/null 2>&1; then
wget -O "$${dest}" "$${url}"
elif command -v busybox > /dev/null 2>&1; then
busybox wget -O "$${dest}" "$${url}"
else
printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
exit 1
fi
}
unzip_safe() {
if command -v unzip > /dev/null 2>&1; then
command unzip "$@"
elif command -v busybox > /dev/null 2>&1; then
busybox unzip "$@"
else
printf "unzip or busybox is not installed. Please install unzip in your image.\n"
exit 1
fi
}
install() {
# Get the architecture of the system
ARCH=$(uname -m)
if [ "$${ARCH}" = "x86_64" ]; then
ARCH="amd64"
elif [ "$${ARCH}" = "aarch64" ]; then
ARCH="arm64"
else
printf "Unsupported architecture: $${ARCH}\n"
return 1
fi
# Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest'
if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then
LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1)
printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}"
if [ -z "$${LATEST_VERSION}" ]; then
printf "Failed to determine the latest Vault version.\n"
return 1
fi
VAULT_CLI_VERSION=$${LATEST_VERSION}
fi
# Check if the vault CLI is installed and has the correct version
installation_needed=1
if command -v vault > /dev/null 2>&1; then
CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then
printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
installation_needed=0
fi
fi
if [ $${installation_needed} -eq 1 ]; then
# Download and install Vault
if [ -z "$${CURRENT_VERSION}" ]; then
printf "Installing Vault CLI ...\n\n"
else
printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}"
fi
fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip"
if [ $? -ne 0 ]; then
printf "Failed to download Vault.\n"
return 1
fi
if ! unzip_safe vault.zip; then
printf "Failed to unzip Vault.\n"
return 1
fi
rm vault.zip
if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
printf "Vault installed successfully!\n\n"
else
mkdir -p ~/.local/bin
if ! mv vault ~/.local/bin/vault; then
printf "Failed to move Vault to local bin.\n"
return 1
fi
printf "Please add ~/.local/bin to your PATH to use vault CLI.\n"
fi
fi
return 0
}
TMP=$(mktemp -d)
if ! (
cd "$TMP"
install
); then
echo "Failed to install Vault CLI."
exit 1
fi
rm -rf "$TMP"
# Authenticate with Vault
printf "🔑 Authenticating with Vault ...\n\n"
echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=-
printf "🥳 Vault authentication complete!\n\n"
printf "You can now use Vault CLI to access secrets.\n"

View File

@@ -22,11 +22,12 @@ describe("vscode-desktop", async () => {
);
const coder_app = state.resources.find(
(res) => res.type == "coder_app" && res.name == "vscode",
(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();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
@@ -78,10 +79,11 @@ describe("vscode-desktop", async () => {
});
const coder_app = state.resources.find(
(res) => res.type == "coder_app" && res.name == "vscode",
(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);
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});

View File

@@ -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.14"
version = "1.0.22"
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.14"
version = "1.0.22"
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.14"
version = "1.0.22"
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.14"
version = "1.0.22"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {

View File

@@ -121,6 +121,18 @@ variable "auto_install_extensions" {
default = false
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@@ -138,6 +150,7 @@ resource "coder_script" "vscode-web" {
EXTENSIONS_DIR : var.extensions_dir,
FOLDER : var.folder,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
})
run_on_start = true
@@ -158,15 +171,21 @@ resource "coder_app" "vscode-web" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = var.folder == "" ? "http://localhost:${var.port}" : "http://localhost:${var.port}?folder=${var.folder}"
url = local.url
icon = "/icon/code.svg"
subdomain = true
subdomain = var.subdomain
share = var.share
order = var.order
healthcheck {
url = "http://localhost:${var.port}/healthz"
url = local.healthcheck_url
interval = 5
threshold = 6
}
}
locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug)
url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}"
healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz"
}

View File

@@ -10,10 +10,16 @@ if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set extension directory
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
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 "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_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 &
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_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...
@@ -72,16 +78,13 @@ for extension in "$${EXTENSIONLIST[@]}"; do
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
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
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
@@ -95,5 +98,6 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
done
fi
fi
fi
run_vscode_web

View File

@@ -14,8 +14,9 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
```tf
# AWS example. See below for examples of using this module with other providers
module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
count = data.coder_workspace.me.start_count
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -23,7 +24,7 @@ module "windows_rdp" {
## Video
https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02
[![Video](./video-thumbnails/video-thumbnail.png)](https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02)
## Examples
@@ -31,8 +32,9 @@ https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a
```tf
module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
count = data.coder_workspace.me.start_count
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -42,8 +44,9 @@ module "windows_rdp" {
```tf
module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
count = data.coder_workspace.me.start_count
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
TerraformState,
type TerraformState,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -9,6 +9,7 @@ import {
type TestVariables = Readonly<{
agent_id: string;
resource_id: string;
share?: string;
admin_username?: string;
admin_password?: string;
}>;
@@ -23,7 +24,10 @@ function findWindowsRdpScript(state: TerraformState): string | null {
}
for (const instance of resource.instances) {
if (instance.attributes.display_name === "windows-rdp") {
if (
instance.attributes.display_name === "windows-rdp" &&
typeof instance.attributes.script === "string"
) {
return instance.attributes.script;
}
}
@@ -99,11 +103,11 @@ describe("Web RDP", async () => {
const defaultRdpScript = findWindowsRdpScript(defaultState);
expect(defaultRdpScript).toBeString();
const { username: defaultUsername, password: defaultPassword } =
formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {};
const defaultResultsGroup =
formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {};
expect(defaultUsername).toBe("Administrator");
expect(defaultPassword).toBe("coderRDP!");
expect(defaultResultsGroup.username).toBe("Administrator");
expect(defaultResultsGroup.password).toBe("coderRDP!");
// Test that custom usernames/passwords are also forwarded correctly
const customAdminUsername = "crouton";
@@ -121,10 +125,10 @@ describe("Web RDP", async () => {
const customRdpScript = findWindowsRdpScript(customizedState);
expect(customRdpScript).toBeString();
const { username: customUsername, password: customPassword } =
formEntryValuesRe.exec(customRdpScript)?.groups ?? {};
const customResultsGroup =
formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {};
expect(customUsername).toBe(customAdminUsername);
expect(customPassword).toBe(customAdminPassword);
expect(customResultsGroup.username).toBe(customAdminUsername);
expect(customResultsGroup.password).toBe(customAdminPassword);
});
});

View File

@@ -9,6 +9,15 @@ terraform {
}
}
variable "share" {
type = string
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -33,7 +42,7 @@ variable "admin_password" {
resource "coder_script" "windows-rdp" {
agent_id = var.agent_id
display_name = "windows-rdp"
icon = "https://svgur.com/i/158F.svg" # TODO: add to Coder icons
icon = "/icon/desktop.svg"
script = templatefile("${path.module}/powershell-installation-script.tftpl", {
admin_username = var.admin_username
@@ -53,10 +62,11 @@ resource "coder_script" "windows-rdp" {
resource "coder_app" "windows-rdp" {
agent_id = var.agent_id
share = var.share
slug = "web-rdp"
display_name = "Web RDP"
url = "http://localhost:7171"
icon = "https://svgur.com/i/158F.svg"
icon = "/icon/desktop.svg"
subdomain = true
healthcheck {
@@ -71,6 +81,6 @@ resource "coder_app" "rdp-docs" {
display_name = "Local RDP"
slug = "rdp-docs"
icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg"
url = "https://coder.com/docs/v2/latest/ides/remote-desktops#rdp-desktop"
url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop"
external = true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB