Compare commits

..

17 Commits

Author SHA1 Message Date
Stephen Kirby d65eea8132 fix: fix workspace actions options (#13572) (#14071)
* fix: fix workspace actions options (#13572)

(cherry picked from commit 07cd9acb2c)

* fix: change time format string from 15:40 to 15:04 (#14033)

* Change string format to constant value

(cherry picked from commit eacdfb9f9c)

---------

Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
Co-authored-by: Charlie Voiselle <464492+angrycub@users.noreply.github.com>
2024-08-01 13:35:28 -05:00
Steven Masley 2f0c2d77dc chore: keep active users active in scim (#13955) (#13974)
* chore: scim should keep active users active
 * chore: add a unit test to excercise dormancy bug

  (cherry picked from commit 03c5d42233)
2024-07-22 16:24:56 -05:00
Stephen Kirby 82ed9e4dc7 chore: patch 2.12.4 (#13925)
* fix(site): enable dormant workspace to be deleted (#13850)

(cherry picked from commit 01b30eaa32)

* chore: add SVG desktop icon (#13765)

* chore: add SVG desktop icon

* fix: add desktop icon to to icons.json

(cherry picked from commit 21a923a7a0)

* fix: update import order for Storybook

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
Co-authored-by: Michael Smith <throwawayclover@gmail.com>
2024-07-17 20:36:52 -04:00
Steven Masley 534d4ea752 chore: external auth validate response "Forbidden" should return invalid, not an error (#13446)
* chore: add unit test to delete workspace from suspended user
* chore: account for forbidden as well as unauthorized response codes

(cherry picked from commit 27f26910b6)
2024-06-24 17:38:32 +00:00
Stephen Kirby 8ce8700424 fixed changelog script release channel flag (#13649)
(cherry picked from commit b9d83c75de)
2024-06-24 17:38:12 +00:00
Stephen Kirby b9779af5b2 fixed script ref (#13647)
(cherry picked from commit 3d6c9799e3)
2024-06-24 17:38:02 +00:00
Mathias Fredriksson e54ff57a9a chore(scripts): fix release promote stable to set latest tag (#13471)
(cherry picked from commit 9a757f8e74)
2024-06-21 19:37:12 +00:00
Mathias Fredriksson ae220f52e7 chore(scripts): fix dry run for autoversion in release.sh (#13470)
(cherry picked from commit 3b7f9534fb)
2024-06-21 19:37:06 +00:00
Kyle Carberry 90f82da311 fix: write server config to telemetry (#13590)
* fix: add external auth configs to telemetry

* Refactor telemetry to send the entire config

* gen

* Fix linting

(cherry picked from commit 3a1fa04590)
2024-06-21 19:36:53 +00:00
Kyle Carberry 201cb1cbed fix: display trial errors in the dashboard (#13601)
* fix: display trial errors in the dashboard

The error was essentially being ignored before!

* Remove day mention in product of trial

* fmt

(cherry picked from commit 7049d7a881)
2024-06-21 19:36:37 +00:00
Kyle Carberry b701620a01 feat: add cross-origin reporting for telemetry in the dashboard (#13612)
* feat: add cross-origin reporting for telemetry in the dashboard

* Respect the telemetry flag

* Fix embedded metadata

* Fix compilation error

* Fix linting

(cherry picked from commit 0793a4b35b)
2024-06-21 19:36:31 +00:00
Kyle Carberry 0703fc6888 fix: track login page correctly (#13618)
(cherry picked from commit 495eea452f)
2024-06-21 19:36:25 +00:00
Kyle Carberry a9e5648557 fix: remove connected button (#13625)
It didn't make a lot of sense in current form. It will when we improve autostop.

(cherry picked from commit 3ef12ac284)
2024-06-21 19:36:19 +00:00
Jon Ayers 3fbfb534d0 fix: only render tooltip when require_active_version enabled (#13484)
(cherry picked from commit 7995d7c3d6)
2024-06-06 02:53:47 +00:00
Colin Adler 5e69a9d18b fix(site): show workspace start button when require active version is enabled (#13482)
(cherry picked from commit f1b42a15fa)
2024-06-06 02:35:33 +00:00
Mathias Fredriksson ba0bf43de4 chore(scripts): fix unbound variable in tag_version.sh (#13428)
(cherry picked from commit a51076a4cd)
2024-06-04 16:15:21 +00:00
Colin Adler 40af6206cc chore: upgrade terraform to v1.8.5 (#13429)
(cherry picked from commit b723da9e91)
2024-06-04 16:14:45 +00:00
1040 changed files with 24071 additions and 62916 deletions
+2 -2
View File
@@ -4,12 +4,12 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.22.5"
default: "1.22.3"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@v5
uses: buildjet/setup-go@v5
with:
go-version: ${{ inputs.version }}
+3 -3
View File
@@ -13,11 +13,11 @@ runs:
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9.6
version: 8
- name: Setup Node
uses: actions/setup-node@v4.0.3
uses: buildjet/setup-node@v4.0.1
with:
node-version: 20.16.0
node-version: 18.19.0
# See https://github.com/actions/setup-node#caching-global-packages-data
cache: "pnpm"
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
+1 -1
View File
@@ -7,5 +7,5 @@ runs:
- name: Install Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.2
terraform_version: 1.8.4
terraform_wrapper: false
+30 -38
View File
@@ -39,10 +39,6 @@ updates:
prefix: "chore"
labels: []
open-pull-requests-limit: 15
groups:
x:
patterns:
- "golang.org/x/*"
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
@@ -65,9 +61,7 @@ updates:
- dependency-name: "terraform"
- package-ecosystem: "npm"
directories:
- "/site"
- "/offlinedocs"
directory: "/site/"
schedule:
interval: "monthly"
time: "06:00"
@@ -77,32 +71,6 @@ updates:
commit-message:
prefix: "chore"
labels: []
groups:
xterm:
patterns:
- "@xterm*"
mui:
patterns:
- "@mui*"
react:
patterns:
- "react*"
- "@types/react*"
emotion:
patterns:
- "@emotion*"
eslint:
patterns:
- "eslint*"
- "@typescript-eslint*"
jest:
patterns:
- "jest*"
- "@types/jest"
vite:
patterns:
- "vite*"
- "@vitejs/plugin-react"
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
@@ -113,10 +81,34 @@ updates:
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
# Ignore @storybook updates, run `pnpm dlx storybook@latest upgrade` to upgrade manually
- dependency-name: "*storybook*" # matches @storybook/* and storybook*
open-pull-requests-limit: 15
groups:
site:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/offlinedocs/"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
reviewers:
- "coder/ts"
commit-message:
prefix: "chore"
labels: []
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
# Ignore major updates to Node.js types, because they need to
# correspond to the Node.js engine version
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
- version-update:semver-minor
- version-update:semver-patch
open-pull-requests-limit: 15
groups:
offlinedocs:
patterns:
- "*"
+67 -85
View File
@@ -120,14 +120,12 @@ jobs:
update-flake:
needs: changes
if: needs.changes.outputs.gomod == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
- name: Setup Go
uses: ./.github/actions/setup-go
@@ -135,25 +133,13 @@ jobs:
- name: Update Nix Flake SRI Hash
run: ./scripts/update-flake.sh
# auto update flake for dependabot
- uses: stefanzweifel/git-auto-commit-action@v5
if: github.actor == 'dependabot[bot]'
with:
# Allows dependabot to still rebase!
commit_message: "[dependabot skip] Update Nix Flake SRI Hash"
commit_user_name: "dependabot[bot]"
commit_user_email: "49699333+dependabot[bot]@users.noreply.github.com>"
commit_author: "dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>"
# require everyone else to update it themselves
- name: Ensure No Changes
if: github.actor != 'dependabot[bot]'
run: git diff --exit-code
lint:
needs: changes
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -174,7 +160,7 @@ jobs:
echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV
- name: golangci-lint cache
uses: actions/cache@v4
uses: buildjet/cache@v4
with:
path: |
${{ env.LINT_CACHE_DIR }}
@@ -184,7 +170,7 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@v1.23.5
uses: crate-ci/typos@v1.21.0
with:
config: .github/workflows/typos.toml
@@ -205,15 +191,9 @@ jobs:
run: |
make --output-sync=line -j lint
- name: Check workflow files
run: |
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.6.22
./actionlint -color -shellcheck= -ignore "set-output"
shell: bash
gen:
timeout-minutes: 8
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.docs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
steps:
@@ -263,7 +243,7 @@ jobs:
fmt:
needs: changes
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
timeout-minutes: 7
steps:
- name: Checkout
@@ -274,9 +254,12 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node
# Use default Go version
- name: Setup Go
uses: ./.github/actions/setup-go
uses: buildjet/setup-go@v5
with:
# This doesn't need caching. It's super fast anyways!
cache: false
go-version: 1.21.9
- name: Install shfmt
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
@@ -290,7 +273,7 @@ jobs:
run: ./scripts/check_unstaged.sh
test-go:
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'buildjet-4vcpu-ubuntu-2204' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
timeout-minutes: 20
@@ -346,7 +329,7 @@ jobs:
api-key: ${{ secrets.DATADOG_API_KEY }}
test-go-pg:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs:
- changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
@@ -368,50 +351,8 @@ jobs:
uses: ./.github/actions/setup-tf
- name: Test with PostgreSQL Database
env:
POSTGRES_VERSION: "13"
TS_DEBUG_DISCO: "true"
run: |
make test-postgres
- name: Upload test stats to Datadog
timeout-minutes: 1
continue-on-error: true
uses: ./.github/actions/upload-datadog
if: success() || failure()
with:
api-key: ${{ secrets.DATADOG_API_KEY }}
# NOTE: this could instead be defined as a matrix strategy, but we want to
# only block merging if tests on postgres 13 fail. Using a matrix strategy
# here makes the check in the above `required` job rather complicated.
test-go-pg-16:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
needs:
- changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Test with PostgreSQL Database
env:
POSTGRES_VERSION: "16"
TS_DEBUG_DISCO: "true"
run: |
export TS_DEBUG_DISCO=true
make test-postgres
- name: Upload test stats to Datadog
@@ -423,7 +364,7 @@ jobs:
api-key: ${{ secrets.DATADOG_API_KEY }}
test-go-race:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
timeout-minutes: 25
@@ -458,7 +399,7 @@ jobs:
# These tests are skipped in the main go test jobs because they require root
# and mess with networking.
test-go-tailnet-integration:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
# Unnecessary to run on main for now
if: needs.changes.outputs.tailnet-integration == 'true' || needs.changes.outputs.ci == 'true'
@@ -480,7 +421,7 @@ jobs:
run: make test-tailnet-integration
test-js:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
timeout-minutes: 20
@@ -497,7 +438,7 @@ jobs:
working-directory: site
test-e2e:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
timeout-minutes: 20
@@ -641,7 +582,7 @@ jobs:
offlinedocs:
name: offlinedocs
needs: changes
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.docs == 'true'
steps:
@@ -709,6 +650,7 @@ jobs:
- test-e2e
- offlinedocs
- sqlc-vet
- dependency-license-review
# Allow this job to run even if the needed jobs fail, are skipped or
# cancelled.
if: always()
@@ -725,6 +667,7 @@ jobs:
echo "- test-js: ${{ needs.test-js.result }}"
echo "- test-e2e: ${{ needs.test-e2e.result }}"
echo "- offlinedocs: ${{ needs.offlinedocs.result }}"
echo "- dependency-license-review: ${{ needs.dependency-license-review.result }}"
echo
# We allow skipped jobs to pass, but not failed or cancelled jobs.
@@ -737,10 +680,11 @@ jobs:
build:
# This builds and publishes ghcr.io/coder/coder-preview:main for each commit
# to main branch.
# to main branch. We are only building this for amd64 platform. (>95% pulls
# are for amd64)
needs: changes
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
outputs:
@@ -800,15 +744,13 @@ jobs:
echo "tag=$tag" >> $GITHUB_OUTPUT
# build images for each architecture
# note: omitting the -j argument to avoid race conditions when pushing
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# only push if we are on main branch
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
# build and push multi-arch manifest, this depends on the other images
# being pushed so will automatically push them
# note: omitting the -j argument to avoid race conditions when pushing
make push/build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
make -j push/build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# Define specific tags
tags=("$tag" "main" "latest")
@@ -948,7 +890,7 @@ jobs:
# runs sqlc-vet to ensure all queries are valid. This catches any mistakes
# in migrations or sqlc queries that makes a query unable to be prepared.
sqlc-vet:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
steps:
@@ -966,3 +908,43 @@ jobs:
- name: Setup and run sqlc vet
run: |
make sqlc-vet
# dependency-license-review checks that no license-incompatible dependencies have been introduced.
# This action is not intended to do a vulnerability check since that is handled by a separate action.
dependency-license-review:
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main' && github.actor != 'dependabot[bot]'
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Dependency Review"
id: review
uses: actions/dependency-review-action@v4.3.2
with:
allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0
allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0"
license-check: true
vulnerability-check: false
- name: "Report"
# make sure this step runs even if the previous failed
if: always()
shell: bash
env:
VULNERABLE_CHANGES: ${{ steps.review.outputs.invalid-license-changes }}
run: |
fields=( "unlicensed" "unresolved" "forbidden" )
# This is unfortunate that we have to do this but the action does not support failing on
# an unknown license. The unknown dependency could easily have a GPL license which
# would be problematic for us.
# Track https://github.com/actions/dependency-review-action/issues/672 for when
# we can remove this brittle workaround.
for field in "${fields[@]}"; do
# Use jq to check if the array is not empty
if [[ $(echo "$VULNERABLE_CHANGES" | jq ".${field} | length") -ne 0 ]]; then
echo "Invalid or unknown licenses detected, contact @sreya to ensure your added dependency falls under one of our allowed licenses."
echo "$VULNERABLE_CHANGES" | jq
exit 1
fi
done
echo "No incompatible licenses detected"
-1
View File
@@ -19,7 +19,6 @@ on:
jobs:
build_image:
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
runs-on: ubuntu-latest
steps:
- name: Checkout
-46
View File
@@ -1,46 +0,0 @@
# Workflow for serving the webapp locally & running Meticulous tests against it.
name: Meticulous
on:
push:
branches:
- main
paths:
- "site/**"
pull_request:
paths:
- "site/**"
# Meticulous needs the workflow to be triggered on workflow_dispatch events,
# so that Meticulous can run the workflow on the base commit to compare
# against if an existing workflow hasn't run.
workflow_dispatch:
permissions:
actions: write
contents: read
issues: write
pull-requests: write
statuses: read
jobs:
meticulous:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Build
working-directory: ./site
run: pnpm build
- name: Serve
working-directory: ./site
run: |
pnpm vite preview &
sleep 5
- name: Run Meticulous tests
uses: alwaysmeticulous/report-diffs-action/cloud-compute@v1
with:
api-token: ${{ secrets.METICULOUS_API_TOKEN }}
app-url: "http://127.0.0.1:4173/"
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
# While GitHub's toaster runners are likelier to flake, we want consistency
# between this environment and the regular test environment for DataDog
# statistics and to only show real workflow threats.
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: "buildjet-8vcpu-ubuntu-2204"
# This runner costs 0.016 USD per minute,
# so 0.016 * 240 = 3.84 USD per run.
timeout-minutes: 240
@@ -40,7 +40,7 @@ jobs:
go-timing:
# We run these tests with p=1 so we don't need a lot of compute.
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04' || 'ubuntu-latest' }}
runs-on: "buildjet-2vcpu-ubuntu-2204"
timeout-minutes: 10
steps:
- name: Checkout
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Assign author
uses: toshimaru/auto-author-assign@v2.1.1
uses: toshimaru/auto-author-assign@v2.1.0
+3 -3
View File
@@ -101,7 +101,7 @@ jobs:
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
@@ -189,7 +189,7 @@ jobs:
needs: get_info
# Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag
if: needs.get_info.outputs.BUILD == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages.
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }}
@@ -253,7 +253,7 @@ jobs:
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
+6 -6
View File
@@ -39,7 +39,7 @@ env:
jobs:
release:
name: Build and publish
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
env:
# Necessary for Docker manifest
DOCKER_CLI_EXPERIMENTAL: "enabled"
@@ -180,7 +180,7 @@ jobs:
- name: Test migrations from current ref to main
run: |
POSTGRES_VERSION=13 make test-migrations
make test-migrations
# Setup GCloud for signing Windows binaries.
- name: Authenticate to Google Cloud
@@ -297,7 +297,7 @@ jobs:
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# we can't build multi-arch if the images aren't pushed, so quit now
# if dry-running
@@ -308,7 +308,7 @@ jobs:
# build and push multi-arch manifest, this depends on the other images
# being pushed so will automatically push them.
make push/build/coder_"$version"_linux.tag
make -j push/build/coder_"$version"_linux.tag
# if the current version is equal to the highest (according to semver)
# version in the repo, also create a multi-arch image as ":latest" and
@@ -396,14 +396,14 @@ jobs:
./build/*.rpm
retention-days: 7
- name: Send repository-dispatch event
- name: Start Packer builds
if: ${{ !inputs.dry_run }}
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
repository: coder/packages
event-type: coder-release
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}'
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
publish-homebrew:
name: Publish to Homebrew tap
+3 -3
View File
@@ -23,7 +23,7 @@ concurrency:
jobs:
codeql:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -56,7 +56,7 @@ jobs:
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
trivy:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -114,7 +114,7 @@ jobs:
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8
uses: aquasecurity/trivy-action@fd25fed6972e341ff0007ddb61f77e88103953c2
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
+2 -8
View File
@@ -14,14 +14,8 @@ darcula = "darcula"
Hashi = "Hashi"
trialer = "trialer"
encrypter = "encrypter"
# as in helsinki
hel = "hel"
# this is used as proto node
pn = "pn"
# typos doesn't like the EDE in TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
EDE = "EDE"
# HELO is an SMTP command
HELO = "HELO"
hel = "hel" # as in helsinki
pn = "pn" # this is used as proto node
[files]
extend-exclude = [
-3
View File
@@ -68,6 +68,3 @@ result
# Filebrowser.db
**/filebrowser.db
# pnpm
.pnpm-store/
-5
View File
@@ -195,11 +195,6 @@ linters-settings:
- name: var-naming
- name: waitgroup-by-value
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
govet:
disable:
- loopclosure
issues:
# Rules listed here: https://github.com/securego/gosec#available-rules
exclude-rules:
-3
View File
@@ -71,9 +71,6 @@ result
# Filebrowser.db
**/filebrowser.db
# pnpm
.pnpm-store/
# .prettierignore.include:
# Helm templates contain variables that are invalid YAML and can't be formatted
# by Prettier.
+7 -18
View File
@@ -36,7 +36,6 @@ GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
VERSION := $(shell ./scripts/version.sh)
POSTGRES_VERSION ?= 16
# Use the highest ZSTD compression level in CI.
ifdef CI
@@ -448,7 +447,8 @@ lint/ts:
lint/go:
./scripts/check_enterprise_imports.sh
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/Dockerfile | cut -d '=' -f 2)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver
golangci-lint run
.PHONY: lint/go
lint/examples:
@@ -487,7 +487,6 @@ gen: \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
site/src/api/rbacresources_gen.ts \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
@@ -518,8 +517,6 @@ gen/mark-fresh:
$(DB_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
site/src/api/rbacresources_gen.ts \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
@@ -618,16 +615,12 @@ site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
go run ./scripts/examplegen/main.go > examples/examples.gen.json
coderd/rbac/object_gen.go: scripts/rbacgen/rbacobject.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go
codersdk/rbacresources_gen.go: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
codersdk/rbacresources_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go
site/src/api/rbacresources_gen.ts: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
go run scripts/rbacgen/main.go typescript > site/src/api/rbacresources_gen.ts
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
go run scripts/metricsdocgen/main.go
./scripts/pnpm_install.sh
@@ -821,7 +814,7 @@ test-migrations: test-postgres-docker
# NOTE: we set --memory to the same size as a GitHub runner.
test-postgres-docker:
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
docker rm -f test-postgres-docker || true
docker run \
--env POSTGRES_PASSWORD=postgres \
--env POSTGRES_USER=postgres \
@@ -829,11 +822,11 @@ test-postgres-docker:
--env PGDATA=/tmp \
--tmpfs /tmp \
--publish 5432:5432 \
--name test-postgres-docker-${POSTGRES_VERSION} \
--name test-postgres-docker \
--restart no \
--detach \
--memory 16GB \
gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \
gcr.io/coder-dev-1/postgres:13 \
-c shared_buffers=1GB \
-c work_mem=1GB \
-c effective_cache_size=1GB \
@@ -872,7 +865,3 @@ test-tailnet-integration:
test-clean:
go clean -testcache
.PHONY: test-clean
.PHONY: test-e2e
test-e2e:
cd ./site && DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
+16 -16
View File
@@ -20,17 +20,17 @@
<br>
<br>
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/enterprise)
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![release](https://img.shields.io/github/v/release/coder/coder)](https://github.com/coder/coder/releases/latest)
[![godoc](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder/v2)](https://goreportcard.com/report/github.com/coder/coder/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder)](https://goreportcard.com/report/github.com/coder/coder)
[![license](https://img.shields.io/github/license/coder/coder)](./LICENSE)
</div>
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and automatically shut down when not used to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads most beneficial to them.
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
- Define cloud development environments in Terraform
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
@@ -53,7 +53,7 @@ curl -L https://coder.com/install.sh | sh
coder server
# Navigate to http://localhost:3000 to create your initial user,
# create a Docker template and provision a workspace
# create a Docker template, and provision a workspace
```
## Install
@@ -69,7 +69,7 @@ curl -L https://coder.com/install.sh | sh
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
> See [install](https://coder.com/docs/install) for additional methods.
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
Once installed, you can start a production deployment with a single command:
@@ -81,27 +81,27 @@ coder server
coder server --postgres-url <url> --access-url <url>
```
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/install) for a complete walkthrough.
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
## Documentation
Browse our docs [here](https://coder.com/docs) or visit a specific section below:
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
- [**Templates**](https://coder.com/docs/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/admin): Learn how to operate Coder
- [**Enterprise**](https://coder.com/docs/enterprise): Learn about our paid features built for large teams
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
## Support
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features and chat with the community using Coder!
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
## Integrations
We are always working on new integrations. Please feel free to open an issue and ask for an integration. Contributions are welcome in any official or community repositories.
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
### Official
@@ -120,9 +120,9 @@ We are always working on new integrations. Please feel free to open an issue and
## Contributing
We are always happy to see new contributors to Coder. If you are new to the Coder codebase, we have
[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your
[a guide on how to get started](https://coder.com/docs/v2/latest/CONTRIBUTING). We'd love to see your
contributions!
## Hiring
Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team.
Apply [here](https://cdr.co/github-apply) if you're interested in joining our team.
-4
View File
@@ -91,7 +91,6 @@ type Options struct {
ModifiedProcesses chan []*agentproc.Process
// ProcessManagementTick is used for testing process priority management.
ProcessManagementTick <-chan time.Time
BlockFileTransfer bool
}
type Client interface {
@@ -185,7 +184,6 @@ func New(options Options) Agent {
modifiedProcs: options.ModifiedProcesses,
processManagementTick: options.ProcessManagementTick,
logSender: agentsdk.NewLogSender(options.Logger),
blockFileTransfer: options.BlockFileTransfer,
prometheusRegistry: prometheusRegistry,
metrics: newAgentMetrics(prometheusRegistry),
@@ -241,7 +239,6 @@ type agent struct {
sessionToken atomic.Pointer[string]
sshServer *agentssh.Server
sshMaxTimeout time.Duration
blockFileTransfer bool
lifecycleUpdate chan struct{}
lifecycleReported chan codersdk.WorkspaceAgentLifecycle
@@ -280,7 +277,6 @@ func (a *agent) init() {
AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() },
UpdateEnv: a.updateCommandEnv,
WorkingDirectory: func() string { return a.manifest.Load().Directory },
BlockFileTransfer: a.blockFileTransfer,
})
if err != nil {
panic(err)
-93
View File
@@ -970,99 +970,6 @@ func TestAgent_SCP(t *testing.T) {
require.NoError(t, err)
}
func TestAgent_FileTransferBlocked(t *testing.T) {
t.Parallel()
assertFileTransferBlocked := func(t *testing.T, errorMessage string) {
// NOTE: Checking content of the error message is flaky. Most likely there is a race condition, which results
// in stopping the client in different phases, and returning different errors:
// - client read the full error message: File transfer has been disabled.
// - client's stream was terminated before reading the error message: EOF
// - client just read the error code (Windows): Process exited with status 65
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
strings.Contains(errorMessage, "EOF") ||
strings.Contains(errorMessage, "Process exited with status 65")
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
}
t.Run("SFTP", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()
_, err = sftp.NewClient(sshClient)
require.Error(t, err)
assertFileTransferBlocked(t, err.Error())
})
t.Run("SCP with go-scp package", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()
scpClient, err := scp.NewClientBySSH(sshClient)
require.NoError(t, err)
defer scpClient.Close()
tempFile := filepath.Join(t.TempDir(), "scp")
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
require.Error(t, err)
assertFileTransferBlocked(t, err.Error())
})
t.Run("Forbidden commands", func(t *testing.T) {
t.Parallel()
for _, c := range agentssh.BlockedFileTransferCommands {
t.Run(c, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()
session, err := sshClient.NewSession()
require.NoError(t, err)
defer session.Close()
stdout, err := session.StdoutPipe()
require.NoError(t, err)
//nolint:govet // we don't need `c := c` in Go 1.22
err = session.Start(c)
require.NoError(t, err)
defer session.Close()
msg, err := io.ReadAll(stdout)
require.NoError(t, err)
assertFileTransferBlocked(t, string(msg))
})
}
})
}
func TestAgent_EnvironmentVariables(t *testing.T) {
t.Parallel()
key := "EXAMPLE"
+1 -1
View File
@@ -349,7 +349,7 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
"This usually means a child process was started with references to stdout or stderr. As a result, this " +
"process may now have been terminated. Consider redirecting the output or using a separate " +
"\"coder_script\" for the process, see " +
"https://coder.com/docs/templates/troubleshooting#startup-script-issues for more information.",
"https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-issues for more information.",
)
// Inform the user by propagating the message via log writers.
_, _ = fmt.Fprintf(cmd.Stderr, "WARNING: %s. %s\n", message, details)
-53
View File
@@ -52,16 +52,8 @@ const (
// MagicProcessCmdlineJetBrains is a string in a process's command line that
// uniquely identifies it as JetBrains software.
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
// the file transfer.
BlockedFileTransferErrorCode = 65 // Error code: host not allowed to connect
BlockedFileTransferErrorMessage = "File transfer has been disabled."
)
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
// Config sets configuration parameters for the agent SSH server.
type Config struct {
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
@@ -82,8 +74,6 @@ type Config struct {
// X11SocketDir is the directory where X11 sockets are created. Default is
// /tmp/.X11-unix.
X11SocketDir string
// BlockFileTransfer restricts use of file transfer applications.
BlockFileTransfer bool
}
type Server struct {
@@ -282,18 +272,6 @@ func (s *Server) sessionHandler(session ssh.Session) {
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
}
if s.fileTransferBlocked(session) {
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
// Response format: <status_code><message body>\n
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
_, _ = session.Write([]byte(errorMessage))
}
_ = session.Exit(BlockedFileTransferErrorCode)
return
}
switch ss := session.Subsystem(); ss {
case "":
case "sftp":
@@ -344,37 +322,6 @@ func (s *Server) sessionHandler(session ssh.Session) {
_ = session.Exit(0)
}
// fileTransferBlocked method checks if the file transfer commands should be blocked.
//
// Warning: consider this mechanism as "Do not trespass" sign, as a violator can still ssh to the host,
// smuggle the `scp` binary, or just manually send files outside with `curl` or `ftp`.
// If a user needs a more sophisticated and battle-proof solution, consider full endpoint security.
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
if !s.config.BlockFileTransfer {
return false // file transfers are permitted
}
// File transfers are restricted.
if session.Subsystem() == "sftp" {
return true
}
cmd := session.Command()
if len(cmd) == 0 {
return false // no command?
}
c := cmd[0]
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp
for _, cmd := range BlockedFileTransferCommands {
if cmd == c {
return true
}
}
return false
}
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
ctx := session.Context()
env := append(session.Environ(), extraEnv...)
+6 -19
View File
@@ -210,12 +210,7 @@ func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateSt
f.logger.Debug(ctx, "update stats called", slog.F("req", req))
// empty request is sent to get the interval; but our tests don't want empty stats requests
if req.Stats != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.statsCh <- req.Stats:
// OK!
}
f.statsCh <- req.Stats
}
return &agentproto.UpdateStatsResponse{ReportInterval: durationpb.New(statsInterval)}, nil
}
@@ -238,25 +233,17 @@ func (f *FakeAgentAPI) UpdateLifecycle(_ context.Context, req *agentproto.Update
func (f *FakeAgentAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
f.logger.Debug(ctx, "batch update app health", slog.F("req", req))
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.appHealthCh <- req:
return &agentproto.BatchUpdateAppHealthResponse{}, nil
}
f.appHealthCh <- req
return &agentproto.BatchUpdateAppHealthResponse{}, nil
}
func (f *FakeAgentAPI) AppHealthCh() <-chan *agentproto.BatchUpdateAppHealthRequest {
return f.appHealthCh
}
func (f *FakeAgentAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.startupCh <- req.GetStartup():
return req.GetStartup(), nil
}
func (f *FakeAgentAPI) UpdateStartup(_ context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
f.startupCh <- req.GetStartup()
return req.GetStartup(), nil
}
func (f *FakeAgentAPI) GetMetadata() map[string]agentsdk.Metadata {
+60 -54
View File
@@ -12,9 +12,12 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
"github.com/coder/retry"
)
// WorkspaceAgentApps fetches the workspace apps.
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
// PostWorkspaceAgentAppHealth updates the workspace app health.
type PostWorkspaceAgentAppHealth func(context.Context, agentsdk.PostAppHealthsRequest) error
@@ -23,26 +26,15 @@ type WorkspaceAppHealthReporter func(ctx context.Context)
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.WorkspaceApp, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
return NewAppHealthReporterWithClock(logger, apps, postWorkspaceAgentAppHealth, quartz.NewReal())
}
// NewAppHealthReporterWithClock is only called directly by test code. Product code should call
// NewAppHealthReporter.
func NewAppHealthReporterWithClock(
logger slog.Logger,
apps []codersdk.WorkspaceApp,
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth,
clk quartz.Clock,
) WorkspaceAppHealthReporter {
logger = logger.Named("apphealth")
return func(ctx context.Context) {
runHealthcheckLoop := func(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// no need to run this loop if no apps for this workspace.
if len(apps) == 0 {
return
return nil
}
hasHealthchecksEnabled := false
@@ -57,7 +49,7 @@ func NewAppHealthReporterWithClock(
// no need to run this loop if no health checks are configured.
if !hasHealthchecksEnabled {
return
return nil
}
// run a ticker for each app health check.
@@ -69,29 +61,25 @@ func NewAppHealthReporterWithClock(
}
app := nextApp
go func() {
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
// We time out at the healthcheck interval to prevent getting too backed up, but
// set it 1ms early so that it's not simultaneous with the next tick in testing,
// which makes the test easier to understand.
//
// It would be idiomatic to use the http.Client.Timeout or a context.WithTimeout,
// but we are passing this off to the native http library, which is not aware
// of the clock library we are using. That means in testing, with a mock clock
// it will compare mocked times with real times, and we will get strange results.
// So, we just implement the timeout as a context we cancel with an AfterFunc
reqCtx, reqCancel := context.WithCancel(ctx)
timeout := clk.AfterFunc(
time.Duration(app.Healthcheck.Interval)*time.Second-time.Millisecond,
reqCancel,
"timeout", app.Slug)
defer timeout.Stop()
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
// we set the http timeout to the healthcheck interval to prevent getting too backed up.
client := &http.Client{
Timeout: time.Duration(app.Healthcheck.Interval) * time.Second,
}
err := func() error {
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, app.Healthcheck.URL, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
res, err := client.Do(req)
if err != nil {
return err
}
@@ -130,36 +118,54 @@ func NewAppHealthReporterWithClock(
mu.Unlock()
logger.Debug(ctx, "workspace app healthy", slog.F("id", app.ID.String()), slog.F("slug", app.Slug))
}
return nil
}, "healthcheck", app.Slug)
t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second)
}
}()
}
mu.Lock()
lastHealth := copyHealth(health)
mu.Unlock()
reportTicker := clk.TickerFunc(ctx, time.Second, func() error {
mu.RLock()
changed := healthChanged(lastHealth, health)
mu.RUnlock()
if !changed {
reportTicker := time.NewTicker(time.Second)
defer reportTicker.Stop()
// every second we check if the health values of the apps have changed
// and if there is a change we will report the new values.
for {
select {
case <-ctx.Done():
return nil
}
case <-reportTicker.C:
mu.RLock()
changed := healthChanged(lastHealth, health)
mu.RUnlock()
if !changed {
continue
}
mu.Lock()
lastHealth = copyHealth(health)
mu.Unlock()
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
} else {
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
mu.Lock()
lastHealth = copyHealth(health)
mu.Unlock()
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
} else {
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
}
}
return nil
}, "report")
_ = reportTicker.Wait() // only possible error is context done
}
}
return func(ctx context.Context) {
for r := retry.New(time.Second, 30*time.Second); r.Wait(ctx); {
err := runHealthcheckLoop(ctx)
if err == nil || xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
return
}
logger.Error(ctx, "failed running workspace app reporter", slog.Error(err))
}
}
}
+111 -152
View File
@@ -4,12 +4,14 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
@@ -21,22 +23,19 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestAppHealth_Healthy(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{1},
Slug: "app1",
Healthcheck: codersdk.Healthcheck{},
Health: codersdk.WorkspaceAppHealthDisabled,
},
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -47,7 +46,6 @@ func TestAppHealth_Healthy(t *testing.T) {
Health: codersdk.WorkspaceAppHealthInitializing,
},
{
ID: uuid.UUID{3},
Slug: "app3",
Healthcheck: codersdk.Healthcheck{
Interval: 2,
@@ -56,71 +54,36 @@ func TestAppHealth_Healthy(t *testing.T) {
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
checks2 := 0
checks3 := 0
handlers := []http.Handler{
nil,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checks2++
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checks3++
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
}
mClock := quartz.NewMock(t)
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthchecksStarted := make([]string, 2)
for i := 0; i < 2; i++ {
c := healthcheckTrap.MustWait(ctx)
c.Release()
healthchecksStarted[i] = c.Tags[1]
}
slices.Sort(healthchecksStarted)
require.Equal(t, []string{"app2", "app3"}, healthchecksStarted)
apps, err := getApps(ctx)
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health)
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Advance(time.Millisecond).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app2 is now healthy
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 2)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, apps[2].Health)
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app3 is now healthy
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
update = testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 2)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[2].Health)
// ensure we aren't spamming
require.Equal(t, 2, checks2)
require.Equal(t, 1, checks3)
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy && apps[2].Health == codersdk.WorkspaceAppHealthHealthy
}, testutil.WaitLong, testutil.IntervalSlow)
}
func TestAppHealth_500(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -136,40 +99,59 @@ func TestAppHealth_500(t *testing.T) {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
}),
}
mClock := quartz.NewMock(t)
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthcheckTrap.MustWait(ctx).Release()
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Advance(time.Millisecond).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // check gets triggered
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered, but unsent since we are at the threshold
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // 2nd check, crosses threshold
mClock.Advance(time.Millisecond).MustWait(ctx) // 2nd report, sends update
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 1)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
}, testutil.WaitLong, testutil.IntervalSlow)
}
func TestAppHealth_Timeout(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
// create a httptest server for us and set it for us.
Interval: 1,
Threshold: 1,
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
handlers := []http.Handler{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// sleep longer than the interval to cause the health check to time out
time.Sleep(2 * time.Second)
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
}
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
}, testutil.WaitLong, testutil.IntervalSlow)
}
func TestAppHealth_NotSpamming(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -181,65 +163,27 @@ func TestAppHealth_Timeout(t *testing.T) {
},
}
counter := new(int32)
handlers := []http.Handler{
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
// allow the request to time out
<-r.Context().Done()
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(counter, 1)
}),
}
mClock := quartz.NewMock(t)
start := mClock.Now()
// for this test, it's easier to think in the number of milliseconds elapsed
// since start.
ms := func(n int) time.Time {
return start.Add(time.Duration(n) * time.Millisecond)
}
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
timeoutTrap := mClock.Trap().AfterFunc("timeout")
defer timeoutTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, apps, handlers, mClock)
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthcheckTrap.MustWait(ctx).Release()
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Set(ms(1)).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
w := mClock.Set(ms(1000)) // 1st check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(1001)).MustWait(ctx) // report tick, no change
mClock.Set(ms(1999)) // timeout pops
w.MustWait(ctx) // 1st check finished
w = mClock.Set(ms(2000)) // 2nd check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(2001)).MustWait(ctx) // report tick, no change
mClock.Set(ms(2999)) // timeout pops
w.MustWait(ctx) // 2nd check finished
// app is now unhealthy after 2 timeouts
mClock.Set(ms(3000)) // 3rd check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(3001)).MustWait(ctx) // report tick, sends changes
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 1)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
// Ensure we haven't made more than 2 (expected 1 + 1 for buffer) requests in the last second.
// if there is a bug where we are spamming the healthcheck route this will catch it.
time.Sleep(time.Second)
require.LessOrEqual(t, atomic.LoadInt32(counter), int32(2))
}
func setupAppReporter(
ctx context.Context, t *testing.T,
apps []codersdk.WorkspaceApp,
handlers []http.Handler,
clk quartz.Clock,
) (*agenttest.FakeAgentAPI, func()) {
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
closers := []func(){}
for _, app := range apps {
require.NotEqual(t, uuid.Nil, app.ID, "all apps must have ID set")
for i, app := range apps {
if app.ID == uuid.Nil {
app.ID = uuid.New()
apps[i] = app
}
}
for i, handler := range handlers {
if handler == nil {
@@ -252,6 +196,14 @@ func setupAppReporter(
closers = append(closers, ts.Close)
}
var mu sync.Mutex
workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) {
mu.Lock()
defer mu.Unlock()
var newApps []codersdk.WorkspaceApp
return append(newApps, apps...), nil
}
// We don't care about manifest or stats in this test since it's not using
// a full agent and these RPCs won't get called.
//
@@ -260,31 +212,38 @@ func setupAppReporter(
// post function.
fakeAAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
go agent.NewAppHealthReporterWithClock(
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
apps, agentsdk.AppHealthPoster(fakeAAPI), clk,
)(ctx)
// Process events from the channel and update the health of the apps.
go func() {
appHealthCh := fakeAAPI.AppHealthCh()
for {
select {
case <-ctx.Done():
return
case req := <-appHealthCh:
mu.Lock()
for _, update := range req.Updates {
updateID, err := uuid.FromBytes(update.Id)
assert.NoError(t, err)
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
return fakeAAPI, func() {
for i, app := range apps {
if app.ID != updateID {
continue
}
app.Health = updateHealth
apps[i] = app
}
}
mu.Unlock()
}
}
}()
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, agentsdk.AppHealthPoster(fakeAAPI))(ctx)
return workspaceAgentApps, func() {
for _, closeFn := range closers {
closeFn()
}
}
}
func applyUpdate(t *testing.T, apps []codersdk.WorkspaceApp, req *proto.BatchUpdateAppHealthRequest) {
t.Helper()
for _, update := range req.Updates {
updateID, err := uuid.FromBytes(update.Id)
require.NoError(t, err)
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
for i, app := range apps {
if app.ID != updateID {
continue
}
app.Health = updateHealth
apps[i] = app
}
}
}
-38
View File
@@ -1,38 +0,0 @@
package proto
import (
"context"
"storj.io/drpc"
)
// DRPCAgentClient20 is the Agent API at v2.0. Notably, it is missing GetAnnouncementBanners, but
// is useful when you want to be maximally compatible with Coderd Release Versions from 2.9+
type DRPCAgentClient20 interface {
DRPCConn() drpc.Conn
GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error)
GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error)
UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error)
UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error)
BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
}
// DRPCAgentClient21 is the Agent API at v2.1. It is useful if you want to be maximally compatible
// with Coderd Release Versions from 2.12+
type DRPCAgentClient21 interface {
DRPCConn() drpc.Conn
GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error)
GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error)
UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error)
UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error)
BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
}
+1 -1
View File
@@ -26,7 +26,7 @@ type APIVersion struct {
}
func (v *APIVersion) WithBackwardCompat(majs ...int) *APIVersion {
v.additionalMajors = append(v.additionalMajors, majs...)
v.additionalMajors = append(v.additionalMajors, majs[:]...)
return v
}
-11
View File
@@ -27,7 +27,6 @@ import (
"cdr.dev/slog/sloggers/slogstackdriver"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/reaper"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/codersdk"
@@ -49,7 +48,6 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
slogHumanPath string
slogJSONPath string
slogStackdriverPath string
blockFileTransfer bool
)
cmd := &serpent.Command{
Use: "agent",
@@ -316,8 +314,6 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
// Intentionally set this to nil. It's mainly used
// for testing.
ModifiedProcesses: nil,
BlockFileTransfer: blockFileTransfer,
})
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
@@ -421,13 +417,6 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
Default: "",
Value: serpent.StringOf(&slogStackdriverPath),
},
{
Flag: "block-file-transfer",
Default: "false",
Env: "CODER_AGENT_BLOCK_FILE_TRANSFER",
Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")),
Value: serpent.BoolOf(&blockFileTransfer),
},
}
return cmd
+1 -1
View File
@@ -24,7 +24,7 @@ func TestAutoUpdate(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.Equal(t, codersdk.AutomaticUpdatesNever, workspace.AutomaticUpdates)
+1 -1
View File
@@ -195,7 +195,7 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
req.Name = "test-template"
})
workspace := coderdtest.CreateWorkspace(t, rootClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.Name = "test-workspace"
})
workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, rootClient, workspace.LatestBuild.ID)
+6 -15
View File
@@ -116,7 +116,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
if agent.Status == codersdk.WorkspaceAgentTimeout {
now := time.Now()
sw.Log(now, codersdk.LogLevelInfo, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.")
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, "https://coder.com/docs/templates#agent-connection-issues"))
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#agent-connection-issues"))
for agent.Status == codersdk.WorkspaceAgentTimeout {
if agent, err = fetch(); err != nil {
return xerrors.Errorf("fetch: %w", err)
@@ -132,14 +132,11 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
}
stage := "Running workspace agent startup scripts"
follow := opts.Wait && agent.LifecycleState.Starting()
follow := opts.Wait
if !follow {
stage += " (non-blocking)"
}
sw.Start(stage)
if follow {
sw.Log(time.Time{}, codersdk.LogLevelInfo, "==> ︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.")
}
err = func() error { // Use func because of defer in for loop.
logStream, logsCloser, err := opts.FetchLogs(ctx, agent.ID, 0, follow)
@@ -209,25 +206,19 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
case codersdk.WorkspaceAgentLifecycleReady:
sw.Complete(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
case codersdk.WorkspaceAgentLifecycleStartTimeout:
// Backwards compatibility: Avoid printing warning if
// coderd is old and doesn't set ReadyAt for timeouts.
if agent.ReadyAt == nil {
sw.Fail(stage, 0)
} else {
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
}
sw.Fail(stage, 0)
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script timed out and your workspace may be incomplete.")
case codersdk.WorkspaceAgentLifecycleStartError:
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
// Use zero time (omitted) to separate these from the startup logs.
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.")
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#startup-script-exited-with-an-error"))
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-exited-with-an-error"))
default:
switch {
case agent.LifecycleState.Starting():
// Use zero time (omitted) to separate these from the startup logs.
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.")
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#your-workspace-may-be-incomplete"))
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#your-workspace-may-be-incomplete"))
// Note: We don't complete or fail the stage here, it's
// intentionally left open to indicate this stage didn't
// complete.
@@ -249,7 +240,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
stage := "The workspace agent lost connection"
sw.Start(stage)
sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.")
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#agent-connection-issues"))
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#agent-connection-issues"))
disconnectedAt := agent.DisconnectedAt
for agent.Status == codersdk.WorkspaceAgentDisconnected {
+2 -7
View File
@@ -95,8 +95,6 @@ func TestAgent(t *testing.T) {
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
agent.Status = codersdk.WorkspaceAgentConnecting
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting
agent.StartedAt = ptr.Ref(time.Now())
return nil
},
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
@@ -106,7 +104,6 @@ func TestAgent(t *testing.T) {
agent.Status = codersdk.WorkspaceAgentConnected
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStartTimeout
agent.FirstConnectedAt = ptr.Ref(time.Now())
agent.ReadyAt = ptr.Ref(time.Now())
return nil
},
},
@@ -229,7 +226,6 @@ func TestAgent(t *testing.T) {
},
want: []string{
"⧗ Running workspace agent startup scripts",
"︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.",
"testing: Hello world",
"Bye now",
"✔ Running workspace agent startup scripts",
@@ -258,9 +254,9 @@ func TestAgent(t *testing.T) {
},
},
want: []string{
"⧗ Running workspace agent startup scripts (non-blocking)",
"⧗ Running workspace agent startup scripts",
"Hello world",
"✘ Running workspace agent startup scripts (non-blocking)",
"✘ Running workspace agent startup scripts",
"Warning: A startup script exited with an error and your workspace may be incomplete.",
"For more information and troubleshooting, see",
},
@@ -310,7 +306,6 @@ func TestAgent(t *testing.T) {
},
want: []string{
"⧗ Running workspace agent startup scripts",
"︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.",
"Hello world",
"✔ Running workspace agent startup scripts",
},
+1 -6
View File
@@ -7,7 +7,6 @@ import (
"reflect"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors"
"github.com/coder/serpent"
@@ -144,11 +143,7 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
// Format implements OutputFormat.
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
headers := make(table.Row, len(f.allColumns))
for i, header := range f.allColumns {
headers[i] = header
}
return renderTable(data, f.sort, headers, f.columns)
return DisplayTable(data, f.sort, f.columns)
}
type jsonFormat struct{}
+1 -4
View File
@@ -43,10 +43,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
return "", err
}
values, err := MultiSelect(inv, MultiSelectOptions{
Options: options,
Defaults: options,
})
values, err := MultiSelect(inv, options)
if err == nil {
v, err := json.Marshal(&values)
if err != nil {
+42 -13
View File
@@ -14,11 +14,48 @@ import (
"github.com/coder/serpent"
)
func init() {
survey.SelectQuestionTemplate = `
{{- define "option"}}
{{- " " }}{{- if eq .SelectedIndex .CurrentIndex }}{{color "green" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
{{- .CurrentOpt.Value}}
{{- color "reset"}}
{{end}}
{{- if not .ShowAnswer }}
{{- if .Config.Icons.Help.Text }}
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
{{- else }}
{{- color "black+h"}}{{- "Type to search" }}{{color "reset"}}
{{- end }}
{{- "\n" }}
{{- end }}
{{- "\n" }}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end }}`
survey.MultiSelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
{{- color "reset"}}
{{- " "}}{{- .CurrentOpt.Value}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- if not .ShowAnswer }}
{{- "\n"}}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`
}
type SelectOptions struct {
Options []string
// Default will be highlighted first if it's a valid option.
Default string
Message string
Size int
HideSearch bool
}
@@ -85,7 +122,6 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
Options: opts.Options,
Default: defaultOption,
PageSize: opts.Size,
Message: opts.Message,
}, &value, survey.WithIcons(func(is *survey.IconSet) {
is.Help.Text = "Type to search"
if opts.HideSearch {
@@ -102,22 +138,15 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
return value, err
}
type MultiSelectOptions struct {
Message string
Options []string
Defaults []string
}
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
// Similar hack is applied to Select()
if flag.Lookup("test.v") != nil {
return opts.Defaults, nil
return items, nil
}
prompt := &survey.MultiSelect{
Options: opts.Options,
Default: opts.Defaults,
Message: opts.Message,
Options: items,
Default: items,
}
var values []string
+1 -4
View File
@@ -107,10 +107,7 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
var values []string
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Options: items,
Defaults: items,
})
selectedItems, err := cliui.MultiSelect(inv, items)
if err == nil {
values = selectedItems
}
+18 -74
View File
@@ -22,13 +22,6 @@ func Table() table.Writer {
return tableWriter
}
// This type can be supplied as part of a slice to DisplayTable
// or to a `TableFormat` `Format` call to render a separator.
// Leading separators are not supported and trailing separators
// are ignored by the table formatter.
// e.g. `[]any{someRow, TableSeparator, someRow}`
type TableSeparator struct{}
// filterTableColumns returns configurations to hide columns
// that are not provided in the array. If the array is empty,
// no filtering will occur!
@@ -54,12 +47,8 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig
return columnConfigs
}
// DisplayTable renders a table as a string. The input argument can be:
// - a struct slice.
// - an interface slice, where the first element is a struct,
// and all other elements are of the same type, or a TableSeparator.
//
// At least one field in the struct must have a `table:""` tag
// DisplayTable renders a table as a string. The input argument must be a slice
// of structs. At least one field in the struct must have a `table:""` tag
// containing the name of the column in the outputted table.
//
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
@@ -77,20 +66,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
v := reflect.Indirect(reflect.ValueOf(out))
if v.Kind() != reflect.Slice {
return "", xerrors.New("DisplayTable called with a non-slice type")
}
var tableType reflect.Type
if v.Type().Elem().Kind() == reflect.Interface {
if v.Len() == 0 {
return "", xerrors.New("DisplayTable called with empty interface slice")
}
tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type()
} else {
tableType = v.Type().Elem()
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
}
// Get the list of table column headers.
headersRaw, defaultSort, err := typeToTableHeaders(tableType, true)
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true)
if err != nil {
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
}
@@ -102,8 +82,9 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
}
headers := make(table.Row, len(headersRaw))
for i, header := range headersRaw {
headers[i] = strings.ReplaceAll(header, "_", " ")
headers[i] = header
}
// Verify that the given sort column and filter columns are valid.
if sort != "" || len(filterColumns) != 0 {
headersMap := make(map[string]string, len(headersRaw))
@@ -149,11 +130,6 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
}
}
return renderTable(out, sort, headers, filterColumns)
}
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
v := reflect.Indirect(reflect.ValueOf(out))
// Setup the table formatter.
tw := Table()
@@ -167,22 +143,15 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
// Write each struct to the table.
for i := 0; i < v.Len(); i++ {
cur := v.Index(i).Interface()
_, ok := cur.(TableSeparator)
if ok {
tw.AppendSeparator()
continue
}
// Format the row as a slice.
// ValueToTableMap does what `reflect.Indirect` does
rowMap, err := valueToTableMap(reflect.ValueOf(cur))
rowMap, err := valueToTableMap(v.Index(i))
if err != nil {
return "", xerrors.Errorf("get table row map %v: %w", i, err)
}
rowSlice := make([]any, len(headers))
for i, h := range headers {
v, ok := rowMap[h.(string)]
for i, h := range headersRaw {
v, ok := rowMap[h]
if !ok {
v = nil
}
@@ -205,24 +174,6 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
}
}
// Guard against nil dereferences
if v != nil {
rt := reflect.TypeOf(v)
switch rt.Kind() {
case reflect.Slice:
// By default, the behavior is '%v', which just returns a string like
// '[a b c]'. This will add commas in between each value.
strs := make([]string, 0)
vt := reflect.ValueOf(v)
for i := 0; i < vt.Len(); i++ {
strs = append(strs, fmt.Sprintf("%v", vt.Index(i).Interface()))
}
v = "[" + strings.Join(strs, ", ") + "]"
default:
// Leave it as it is
}
}
rowSlice[i] = v
}
@@ -237,28 +188,25 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
// returned. If the table tag is malformed, an error is returned.
//
// The returned name is transformed from "snake_case" to "normal text".
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) {
tags, err := structtag.Parse(string(field.Tag))
if err != nil {
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
}
tag, err := tags.Get("table")
if err != nil || tag.Name == "-" {
// tags.Get only returns an error if the tag is not found.
return "", false, false, false, false, nil
return "", false, false, false, nil
}
defaultSortOpt := false
noSortOpt = false
recursiveOpt := false
skipParentNameOpt := false
for _, opt := range tag.Options {
switch opt {
case "default_sort":
defaultSortOpt = true
case "nosort":
noSortOpt = true
case "recursive":
recursiveOpt = true
case "recursive_inline":
@@ -268,11 +216,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, n
recursiveOpt = true
skipParentNameOpt = true
default:
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
}
}
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil
}
func isStructOrStructPointer(t reflect.Type) bool {
@@ -296,16 +244,12 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
headers := []string{}
defaultSortName := ""
noSortOpt := false
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
name, defaultSort, recursive, skip, err := parseTableStructTag(field)
if err != nil {
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
}
if requireDefault && noSort {
noSortOpt = true
}
if name == "" && (recursive && skip) {
return nil, "", xerrors.Errorf("a name is required for the field %q. "+
@@ -348,8 +292,8 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
headers = append(headers, name)
}
if defaultSortName == "" && requireDefault && !noSortOpt {
return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String())
if defaultSortName == "" && requireDefault {
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
}
return headers, defaultSortName, nil
@@ -376,7 +320,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
fieldVal := val.Field(i)
name, _, _, recursive, skip, err := parseTableStructTag(field)
name, _, recursive, skip, err := parseTableStructTag(field)
if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
}
+16 -44
View File
@@ -138,10 +138,10 @@ func Test_DisplayTable(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
`
// Test with non-pointer values.
@@ -165,10 +165,10 @@ foo 10 [a, b, c] foo1 11 foo2 12 foo3
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
`
out, err := cliui.DisplayTable(in, "age", nil)
@@ -218,42 +218,6 @@ Alice 25
compareTables(t, expected, out)
})
// This test ensures we can display dynamically typed slices
t.Run("Interfaces", func(t *testing.T) {
t.Parallel()
in := []any{tableTest1{}}
out, err := cliui.DisplayTable(in, "", nil)
t.Log("rendered table:\n" + out)
require.NoError(t, err)
other := []tableTest1{{}}
expected, err := cliui.DisplayTable(other, "", nil)
require.NoError(t, err)
compareTables(t, expected, out)
})
t.Run("WithSeparator", func(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
---------------------------------------------------------------------------------------------------------------------------------------------------------------
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
---------------------------------------------------------------------------------------------------------------------------------------------------------------
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
`
var inlineIn []any
for _, v := range in {
inlineIn = append(inlineIn, v)
inlineIn = append(inlineIn, cliui.TableSeparator{})
}
out, err := cliui.DisplayTable(inlineIn, "", nil)
t.Log("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
})
// This test ensures that safeties against invalid use of `table` tags
// causes errors (even without data).
t.Run("Errors", func(t *testing.T) {
@@ -291,6 +255,14 @@ foo 10 [a, b, c] foo1 11 foo2 12 foo3
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []any{tableTest1{}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
t.Run("NotStruct", func(t *testing.T) {
+34 -37
View File
@@ -54,7 +54,6 @@ type sshConfigOptions struct {
disableAutostart bool
header []string
headerCommand string
removedKeys map[string]bool
}
// addOptions expects options in the form of "option=value" or "option value".
@@ -75,20 +74,30 @@ func (o *sshConfigOptions) addOption(option string) error {
if err != nil {
return err
}
lowerKey := strings.ToLower(key)
if o.removedKeys != nil && o.removedKeys[lowerKey] {
// Key marked as removed, skip.
return nil
for i, existing := range o.sshOptions {
// Override existing option if they share the same key.
// This is case-insensitive. Parsing each time might be a little slow,
// but it is ok.
existingKey, _, err := codersdk.ParseSSHConfigOption(existing)
if err != nil {
// Don't mess with original values if there is an error.
// This could have come from the user's manual edits.
continue
}
if strings.EqualFold(existingKey, key) {
if value == "" {
// Delete existing option.
o.sshOptions = append(o.sshOptions[:i], o.sshOptions[i+1:]...)
} else {
// Override existing option.
o.sshOptions[i] = option
}
return nil
}
}
// Only append the option if it is not empty
// (we interpret empty as removal).
// Only append the option if it is not empty.
if value != "" {
o.sshOptions = append(o.sshOptions, option)
} else {
if o.removedKeys == nil {
o.removedKeys = make(map[string]bool)
}
o.removedKeys[lowerKey] = true
}
return nil
}
@@ -236,8 +245,6 @@ func (r *RootCmd) configSSH() *serpent.Command {
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
if sshConfigOpts.waitEnum != "auto" && skipProxyCommand {
// The wait option is applied to the ProxyCommand. If the user
// specifies skip-proxy-command, then wait cannot be applied.
@@ -246,14 +253,7 @@ func (r *RootCmd) configSSH() *serpent.Command {
sshConfigOpts.header = r.header
sshConfigOpts.headerCommand = r.headerCommand
// Talk to the API early to prevent the version mismatch
// warning from being printed in the middle of a prompt.
// This is needed because the asynchronous requests issued
// by sshPrepareWorkspaceConfigs may otherwise trigger the
// warning at any time.
_, _ = client.BuildInfo(ctx)
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(ctx, client)
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(inv.Context(), client)
out := inv.Stdout
if dryRun {
@@ -375,7 +375,7 @@ func (r *RootCmd) configSSH() *serpent.Command {
return xerrors.Errorf("fetch workspace configs failed: %w", err)
}
coderdConfig, err := client.SSHConfiguration(ctx)
coderdConfig, err := client.SSHConfiguration(inv.Context())
if err != nil {
// If the error is 404, this deployment does not support
// this endpoint yet. Do not error, just assume defaults.
@@ -440,17 +440,13 @@ func (r *RootCmd) configSSH() *serpent.Command {
configOptions := sshConfigOpts
configOptions.sshOptions = nil
// User options first (SSH only uses the first
// option unless it can be given multiple times)
for _, opt := range sshConfigOpts.sshOptions {
err := configOptions.addOptions(opt)
if err != nil {
return xerrors.Errorf("add flag config option %q: %w", opt, err)
}
// Add standard options.
err := configOptions.addOptions(defaultOptions...)
if err != nil {
return err
}
// Deployment options second, allow them to
// override standard options.
// Override with deployment options
for k, v := range coderdConfig.SSHConfigOptions {
opt := fmt.Sprintf("%s %s", k, v)
err := configOptions.addOptions(opt)
@@ -458,11 +454,12 @@ func (r *RootCmd) configSSH() *serpent.Command {
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
}
}
// Finally, add the standard options.
err := configOptions.addOptions(defaultOptions...)
if err != nil {
return err
// Override with flag options
for _, opt := range sshConfigOpts.sshOptions {
err := configOptions.addOptions(opt)
if err != nil {
return xerrors.Errorf("add flag config option %q: %w", opt, err)
}
}
hostBlock := []string{
+4 -4
View File
@@ -272,25 +272,24 @@ func Test_sshConfigOptions_addOption(t *testing.T) {
},
},
{
Name: "AddTwo",
Name: "Replace",
Start: []string{
"foo bar",
},
Add: []string{"Foo baz"},
Expect: []string{
"foo bar",
"Foo baz",
},
},
{
Name: "AddAndRemove",
Name: "AddAndReplace",
Start: []string{
"a b",
"foo bar",
"buzz bazz",
},
Add: []string{
"b c",
"a ", // Empty value, means remove all following entries that start with "a", i.e. next line.
"A hello",
"hello world",
},
@@ -298,6 +297,7 @@ func Test_sshConfigOptions_addOption(t *testing.T) {
"foo bar",
"buzz bazz",
"b c",
"A hello",
"hello world",
},
},
+1 -14
View File
@@ -65,7 +65,7 @@ func TestConfigSSH(t *testing.T) {
const hostname = "test-coder."
const expectedKey = "ConnectionAttempts"
const removeKey = "ConnectTimeout"
const removeKey = "ConnectionTimeout"
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ConfigSSH: codersdk.SSHConfigResponse{
HostnamePrefix: hostname,
@@ -620,19 +620,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2='v2'" ssh`,
},
},
{
name: "Multiple remote forwards",
args: []string{
"--yes",
"--ssh-option", "RemoteForward 2222 192.168.11.1:2222",
"--ssh-option", "RemoteForward 2223 192.168.11.1:2223",
},
wantErr: false,
hasAgent: true,
wantConfig: wantConfig{
regexMatch: "RemoteForward 2222 192.168.11.1:2222.*\n.*RemoteForward 2223 192.168.11.1:2223",
},
},
}
for _, tt := range tests {
tt := tt
+9 -77
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
@@ -30,9 +29,6 @@ func (r *RootCmd) create() *serpent.Command {
parameterFlags workspaceParameterFlags
autoUpdates string
copyParametersFrom string
// Organization context is only required if more than 1 template
// shares the same name across multiple organizations.
orgContext = NewOrganizationContext()
)
client := new(codersdk.Client)
cmd := &serpent.Command{
@@ -47,7 +43,11 @@ func (r *RootCmd) create() *serpent.Command {
),
Middleware: serpent.Chain(r.InitClient(client)),
Handler: func(inv *serpent.Invocation) error {
var err error
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
workspaceOwner := codersdk.Me
if len(inv.Args) >= 1 {
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
@@ -98,7 +98,7 @@ func (r *RootCmd) create() *serpent.Command {
if templateName == "" {
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
if err != nil {
return err
}
@@ -110,28 +110,13 @@ func (r *RootCmd) create() *serpent.Command {
templateNames := make([]string, 0, len(templates))
templateByName := make(map[string]codersdk.Template, len(templates))
// If more than 1 organization exists in the list of templates,
// then include the organization name in the select options.
uniqueOrganizations := make(map[uuid.UUID]bool)
for _, template := range templates {
uniqueOrganizations[template.OrganizationID] = true
}
for _, template := range templates {
templateName := template.Name
if len(uniqueOrganizations) > 1 {
templateName += cliui.Placeholder(
fmt.Sprintf(
" (%s)",
template.OrganizationName,
),
)
}
if template.ActiveUserCount > 0 {
templateName += cliui.Placeholder(
fmt.Sprintf(
" used by %s",
" (used by %s)",
formatActiveDevelopers(template.ActiveUserCount),
),
)
@@ -159,65 +144,13 @@ func (r *RootCmd) create() *serpent.Command {
}
templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
} else {
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
ExactName: templateName,
})
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}
if len(templates) == 0 {
return xerrors.Errorf("no template found with the name %q", templateName)
}
if len(templates) > 1 {
templateOrgs := []string{}
for _, tpl := range templates {
templateOrgs = append(templateOrgs, tpl.OrganizationName)
}
selectedOrg, err := orgContext.Selected(inv, client)
if err != nil {
return xerrors.Errorf("multiple templates found with the name %q, use `--org=<organization_name>` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", "))
}
index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
return i.OrganizationID == selectedOrg.ID
})
if index == -1 {
return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org=<organization_name> to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", "))
}
// remake the list with the only template selected
templates = []codersdk.Template{templates[index]}
}
template = templates[0]
templateVersionID = template.ActiveVersionID
}
// If the user specified an organization via a flag or env var, the template **must**
// be in that organization. Otherwise, we should throw an error.
orgValue, orgValueSource := orgContext.ValueSource(inv)
if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) {
selectedOrg, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
if template.OrganizationID != selectedOrg.ID {
orgNameFormat := "'--org=%q'"
if orgValueSource == serpent.ValueSourceEnv {
orgNameFormat = "CODER_ORGANIZATION=%q"
}
return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
template.OrganizationName,
fmt.Sprintf(orgNameFormat, selectedOrg.Name),
fmt.Sprintf(orgNameFormat, template.OrganizationName),
)
}
}
var schedSpec *string
if startAt != "" {
sched, err := parseCLISchedule(startAt)
@@ -273,7 +206,7 @@ func (r *RootCmd) create() *serpent.Command {
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
}
workspace, err := client.CreateWorkspace(inv.Context(), template.OrganizationID, workspaceOwner, codersdk.CreateWorkspaceRequest{
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{
TemplateVersionID: templateVersionID,
Name: workspaceName,
AutostartSchedule: schedSpec,
@@ -336,7 +269,6 @@ func (r *RootCmd) create() *serpent.Command {
)
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...)
orgContext.AttachOptions(cmd)
return cmd
}
+5 -4
View File
@@ -27,7 +27,7 @@ func TestDelete(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
clitest.SetupConfig(t, member, root)
@@ -52,7 +52,7 @@ func TestDelete(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
@@ -86,7 +86,8 @@ func TestDelete(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, template.ID)
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, deleteMeClient, workspace.LatestBuild.ID)
// The API checks if the user has any workspaces, so we cannot delete a user
@@ -127,7 +128,7 @@ func TestDelete(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
+1 -1
View File
@@ -204,7 +204,7 @@ func (r *RootCmd) dotfiles() *serpent.Command {
}
if fi.Mode()&0o111 == 0 {
return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/dotfiles for information on how to resolve the issue.", script)
return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/v2/latest/dotfiles for information on how to resolve the issue.", script)
}
// it is safe to use a variable command here because it's from
-1
View File
@@ -13,7 +13,6 @@ func (r *RootCmd) expCmd() *serpent.Command {
Children: []*serpent.Command{
r.scaletestCmd(),
r.errorExample(),
r.promptExample(),
},
}
return cmd
+27 -32
View File
@@ -6,7 +6,6 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
@@ -23,21 +22,19 @@ type workspaceListRow struct {
codersdk.Workspace `table:"-"`
// For table format:
Favorite bool `json:"-" table:"favorite"`
WorkspaceName string `json:"-" table:"workspace,default_sort"`
OrganizationID uuid.UUID `json:"-" table:"organization id"`
OrganizationName string `json:"-" table:"organization name"`
Template string `json:"-" table:"template"`
Status string `json:"-" table:"status"`
Healthy string `json:"-" table:"healthy"`
LastBuilt string `json:"-" table:"last built"`
CurrentVersion string `json:"-" table:"current version"`
Outdated bool `json:"-" table:"outdated"`
StartsAt string `json:"-" table:"starts at"`
StartsNext string `json:"-" table:"starts next"`
StopsAfter string `json:"-" table:"stops after"`
StopsNext string `json:"-" table:"stops next"`
DailyCost string `json:"-" table:"daily cost"`
Favorite bool `json:"-" table:"favorite"`
WorkspaceName string `json:"-" table:"workspace,default_sort"`
Template string `json:"-" table:"template"`
Status string `json:"-" table:"status"`
Healthy string `json:"-" table:"healthy"`
LastBuilt string `json:"-" table:"last built"`
CurrentVersion string `json:"-" table:"current version"`
Outdated bool `json:"-" table:"outdated"`
StartsAt string `json:"-" table:"starts at"`
StartsNext string `json:"-" table:"starts next"`
StopsAfter string `json:"-" table:"stops after"`
StopsNext string `json:"-" table:"stops next"`
DailyCost string `json:"-" table:"daily cost"`
}
func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow {
@@ -56,22 +53,20 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace)
}
workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name
return workspaceListRow{
Favorite: workspace.Favorite,
Workspace: workspace,
WorkspaceName: workspaceName,
OrganizationID: workspace.OrganizationID,
OrganizationName: workspace.OrganizationName,
Template: workspace.TemplateName,
Status: status,
Healthy: healthy,
LastBuilt: durationDisplay(lastBuilt),
CurrentVersion: workspace.LatestBuild.TemplateVersionName,
Outdated: workspace.Outdated,
StartsAt: schedRow.StartsAt,
StartsNext: schedRow.StartsNext,
StopsAfter: schedRow.StopsAfter,
StopsNext: schedRow.StopsNext,
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
Favorite: workspace.Favorite,
Workspace: workspace,
WorkspaceName: workspaceName,
Template: workspace.TemplateName,
Status: status,
Healthy: healthy,
LastBuilt: durationDisplay(lastBuilt),
CurrentVersion: workspace.LatestBuild.TemplateVersionName,
Outdated: workspace.Outdated,
StartsAt: schedRow.StartsAt,
StartsNext: schedRow.StartsNext,
StopsAfter: schedRow.StopsAfter,
StopsNext: schedRow.StopsNext,
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
}
}
-28
View File
@@ -58,21 +58,6 @@ func promptFirstUsername(inv *serpent.Invocation) (string, error) {
return username, nil
}
func promptFirstName(inv *serpent.Invocation) (string, error) {
name, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "(Optional) What " + pretty.Sprint(cliui.DefaultStyles.Field, "name") + " would you like?",
Default: "",
})
if err != nil {
if errors.Is(err, cliui.Canceled) {
return "", nil
}
return "", err
}
return name, nil
}
func promptFirstPassword(inv *serpent.Invocation) (string, error) {
retry:
password, err := cliui.Prompt(inv, cliui.PromptOptions{
@@ -145,7 +130,6 @@ func (r *RootCmd) login() *serpent.Command {
var (
email string
username string
name string
password string
trial bool
useTokenForSession bool
@@ -207,7 +191,6 @@ func (r *RootCmd) login() *serpent.Command {
_, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL)
// nolint: nestif
if !hasFirstUser {
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
@@ -229,10 +212,6 @@ func (r *RootCmd) login() *serpent.Command {
if err != nil {
return err
}
name, err = promptFirstName(inv)
if err != nil {
return err
}
}
if email == "" {
@@ -270,7 +249,6 @@ func (r *RootCmd) login() *serpent.Command {
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: email,
Username: username,
Name: name,
Password: password,
Trial: trial,
})
@@ -375,12 +353,6 @@ func (r *RootCmd) login() *serpent.Command {
Description: "Specifies a username to use if creating the first user for the deployment.",
Value: serpent.StringOf(&username),
},
{
Flag: "first-user-full-name",
Env: "CODER_FIRST_USER_FULL_NAME",
Description: "Specifies a human-readable name for the first user of the deployment.",
Value: serpent.StringOf(&name),
},
{
Flag: "first-user-password",
Env: "CODER_FIRST_USER_PASSWORD",
+16 -154
View File
@@ -18,7 +18,6 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestLogin(t *testing.T) {
@@ -90,11 +89,10 @@ func TestLogin(t *testing.T) {
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
"name", coderdtest.FirstUserParams.Name,
"email", coderdtest.FirstUserParams.Email,
"password", coderdtest.FirstUserParams.Password,
"password", coderdtest.FirstUserParams.Password, // confirm
"username", "testuser",
"email", "user@coder.com",
"password", "SomeSecurePassword!",
"password", "SomeSecurePassword!", // Confirm.
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
@@ -105,64 +103,6 @@ func TestLogin(t *testing.T) {
}
pty.ExpectMatch("Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
})
t.Run("InitialUserTTYNameOptional", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.Run()
assert.NoError(t, err)
}()
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
"name", "",
"email", coderdtest.FirstUserParams.Email,
"password", coderdtest.FirstUserParams.Password,
"password", coderdtest.FirstUserParams.Password, // confirm
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
assert.Empty(t, me.Name)
})
t.Run("InitialUserTTYFlag", func(t *testing.T) {
@@ -179,11 +119,10 @@ func TestLogin(t *testing.T) {
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
"name", coderdtest.FirstUserParams.Name,
"email", coderdtest.FirstUserParams.Email,
"password", coderdtest.FirstUserParams.Password,
"password", coderdtest.FirstUserParams.Password, // confirm
"username", "testuser",
"email", "user@coder.com",
"password", "SomeSecurePassword!",
"password", "SomeSecurePassword!", // Confirm.
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
@@ -193,18 +132,6 @@ func TestLogin(t *testing.T) {
pty.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
})
t.Run("InitialUserFlags", func(t *testing.T) {
@@ -212,56 +139,13 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
"--first-user-username", "testuser", "--first-user-email", "user@coder.com",
"--first-user-password", "SomeSecurePassword!", "--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
})
t.Run("InitialUserFlagsNameOptional", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
assert.Empty(t, me.Name)
})
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
@@ -283,11 +167,10 @@ func TestLogin(t *testing.T) {
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
"name", coderdtest.FirstUserParams.Name,
"email", coderdtest.FirstUserParams.Email,
"password", coderdtest.FirstUserParams.Password,
"password", "something completely different",
"username", "testuser",
"email", "user@coder.com",
"password", "MyFirstSecurePassword!",
"password", "MyNonMatchingSecurePassword!", // Confirm.
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
@@ -300,9 +183,9 @@ func TestLogin(t *testing.T) {
pty.ExpectMatch("Passwords do not match")
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.WriteLine("SomeSecurePassword!")
pty.ExpectMatch("Confirm")
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.WriteLine("SomeSecurePassword!")
pty.ExpectMatch("trial")
pty.WriteLine("yes")
pty.ExpectMatch("Welcome to Coder")
@@ -421,25 +304,4 @@ func TestLogin(t *testing.T) {
// This **should not be equal** to the token we passed in.
require.NotEqual(t, client.SessionToken(), sessionFile)
})
t.Run("KeepOrganizationContext", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
err := cfg.Organization().Write(first.OrganizationID.String())
require.NoError(t, err, "write bad org to config")
err = root.Run()
require.NoError(t, err)
sessionFile, err := cfg.Session().Read()
require.NoError(t, err)
require.NotEqual(t, client.SessionToken(), sessionFile)
// Organization config should be deleted since the org does not exist
selected, err := cfg.Organization().Read()
require.NoError(t, err)
require.Equal(t, selected, first.OrganizationID.String())
})
}
+2 -13
View File
@@ -10,7 +10,6 @@ import (
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
)
@@ -35,21 +34,11 @@ func (r *RootCmd) netcheck() *serpent.Command {
_, _ = fmt.Fprint(inv.Stderr, "Gathering a network report. This may take a few seconds...\n\n")
var derpReport derphealth.Report
derpReport.Run(ctx, &derphealth.ReportOptions{
var report derphealth.Report
report.Run(ctx, &derphealth.ReportOptions{
DERPMap: connInfo.DERPMap,
})
ifReport, err := healthsdk.RunInterfacesReport()
if err != nil {
return xerrors.Errorf("failed to run interfaces report: %w", err)
}
report := healthsdk.ClientNetcheckReport{
DERP: healthsdk.DERPHealthReport(derpReport),
Interfaces: ifReport,
}
raw, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
+5 -5
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
@@ -26,13 +27,12 @@ func TestNetcheck(t *testing.T) {
b := out.Bytes()
t.Log(string(b))
var report healthsdk.ClientNetcheckReport
var report healthsdk.DERPHealthReport
require.NoError(t, json.Unmarshal(b, &report))
// We do not assert that the report is healthy, just that
// it has the expected number of reports per region.
require.Len(t, report.DERP.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
for _, v := range report.DERP.Regions {
assert.True(t, report.Healthy)
require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
for _, v := range report.Regions {
require.Len(t, v.NodeReports, len(v.Region.Nodes))
}
}
-85
View File
@@ -1,85 +0,0 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) notifications() *serpent.Command {
cmd := &serpent.Command{
Use: "notifications",
Short: "Manage Coder notifications",
Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples(
Example{
Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding).",
Command: "coder notifications pause",
},
Example{
Description: "Resume Coder notifications",
Command: "coder notifications resume",
},
),
Aliases: []string{"notification"},
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.pauseNotifications(),
r.resumeNotifications(),
},
}
return cmd
}
func (r *RootCmd) pauseNotifications() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "pause",
Short: "Pause notifications",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
err := client.PutNotificationsSettings(inv.Context(), codersdk.NotificationsSettings{
NotifierPaused: true,
})
if err != nil {
return xerrors.Errorf("unable to pause notifications: %w", err)
}
_, _ = fmt.Fprintln(inv.Stderr, "Notifications are now paused.")
return nil
},
}
return cmd
}
func (r *RootCmd) resumeNotifications() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "resume",
Short: "Resume notifications",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
err := client.PutNotificationsSettings(inv.Context(), codersdk.NotificationsSettings{
NotifierPaused: false,
})
if err != nil {
return xerrors.Errorf("unable to resume notifications: %w", err)
}
_, _ = fmt.Fprintln(inv.Stderr, "Notifications are now resumed.")
return nil
},
}
return cmd
}
-102
View File
@@ -1,102 +0,0 @@
package cli_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestNotifications(t *testing.T) {
t.Parallel()
tests := []struct {
name string
command string
expectPaused bool
}{
{
name: "PauseNotifications",
command: "pause",
expectPaused: true,
},
{
name: "ResumeNotifications",
command: "resume",
expectPaused: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
_ = coderdtest.CreateFirstUser(t, ownerClient)
// when
inv, root := clitest.New(t, "notifications", tt.command)
clitest.SetupConfig(t, ownerClient, root)
var buf bytes.Buffer
inv.Stdout = &buf
err := inv.Run()
require.NoError(t, err)
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
settingsJSON, err := db.GetNotificationsSettings(ctx)
require.NoError(t, err)
var settings codersdk.NotificationsSettings
err = json.Unmarshal([]byte(settingsJSON), &settings)
require.NoError(t, err)
require.Equal(t, tt.expectPaused, settings.NotifierPaused)
})
}
}
func TestPauseNotifications_RegularUser(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, ownerClient)
anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// when
inv, root := clitest.New(t, "notifications", "pause")
clitest.SetupConfig(t, anotherClient, root)
var buf bytes.Buffer
inv.Stdout = &buf
err := inv.Run()
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
assert.Contains(t, sdkError.Message, "Insufficient permissions to update notifications settings.")
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
settingsJSON, err := db.GetNotificationsSettings(ctx)
require.NoError(t, err)
var settings codersdk.NotificationsSettings
err = json.Unmarshal([]byte(settingsJSON), &settings)
require.NoError(t, err)
require.False(t, settings.NotifierPaused) // still running
}
+189 -37
View File
@@ -1,40 +1,213 @@
package cli
import (
"errors"
"fmt"
"os"
"slices"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) organizations() *serpent.Command {
orgContext := NewOrganizationContext()
cmd := &serpent.Command{
Use: "organizations [subcommand]",
Short: "Organization related commands",
Aliases: []string{"organization", "org", "orgs"},
Hidden: true, // Hidden until these commands are complete.
Annotations: workspaceCommand,
Use: "organizations [subcommand]",
Short: "Organization related commands",
Aliases: []string{"organization", "org", "orgs"},
Hidden: true, // Hidden until these commands are complete.
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.showOrganization(orgContext),
r.currentOrganization(),
r.switchOrganization(),
r.createOrganization(),
r.organizationMembers(orgContext),
r.organizationRoles(orgContext),
r.organizationRoles(),
},
}
orgContext.AttachOptions(cmd)
cmd.Options = serpent.OptionSet{}
return cmd
}
func (r *RootCmd) showOrganization(orgContext *OrganizationContext) *serpent.Command {
func (r *RootCmd) switchOrganization() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "set <organization name | ID>",
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + FormatExamples(
Example{
Description: "Remove the current organization and defer to the default.",
Command: "coder organizations set ''",
},
Example{
Description: "Switch to a custom organization.",
Command: "coder organizations set my-org",
},
),
Middleware: serpent.Chain(
r.InitClient(client),
serpent.RequireRangeArgs(0, 1),
),
Options: serpent.OptionSet{},
Handler: func(inv *serpent.Invocation) error {
conf := r.createConfig()
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("failed to get organizations: %w", err)
}
// Keep the list of orgs sorted
slices.SortFunc(orgs, func(a, b codersdk.Organization) int {
return strings.Compare(a.Name, b.Name)
})
var switchToOrg string
if len(inv.Args) == 0 {
// Pull switchToOrg from a prompt selector, rather than command line
// args.
switchToOrg, err = promptUserSelectOrg(inv, conf, orgs)
if err != nil {
return err
}
} else {
switchToOrg = inv.Args[0]
}
// If the user passes an empty string, we want to remove the organization
// from the config file. This will defer to default behavior.
if switchToOrg == "" {
err := conf.Organization().Delete()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return xerrors.Errorf("failed to unset organization: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Organization unset\n")
} else {
// Find the selected org in our list.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == switchToOrg || org.ID.String() == switchToOrg
})
if index < 0 {
// Using this error for better error message formatting
err := &codersdk.Error{
Response: codersdk.Response{
Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg),
Detail: "Ensure the organization argument is correct and you are a member of it.",
},
Helper: fmt.Sprintf("Valid organizations you can switch to: %s", strings.Join(orgNames(orgs), ", ")),
}
return err
}
// Always write the uuid to the config file. Names can change.
err := conf.Organization().Write(orgs[index].ID.String())
if err != nil {
return xerrors.Errorf("failed to write organization to config file: %w", err)
}
}
// Verify it worked.
current, err := CurrentOrganization(r, inv, client)
if err != nil {
// An SDK error could be a permission error. So offer the advice to unset the org
// and reset the context.
var sdkError *codersdk.Error
if errors.As(err, &sdkError) {
if sdkError.Helper == "" && sdkError.StatusCode() != 500 {
sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations set ""'`
}
return sdkError
}
return xerrors.Errorf("failed to get current organization: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Current organization context set to %s (%s)\n", current.Name, current.ID.String())
return nil
},
}
return cmd
}
// promptUserSelectOrg will prompt the user to select an organization from a list
// of their organizations.
func promptUserSelectOrg(inv *serpent.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
// Default choice
var defaultOrg string
// Comes from config file
if conf.Organization().Exists() {
defaultOrg, _ = conf.Organization().Read()
}
// No config? Comes from default org in the list
if defaultOrg == "" {
defIndex := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.IsDefault
})
if defIndex >= 0 {
defaultOrg = orgs[defIndex].Name
}
}
// Defer to first org
if defaultOrg == "" && len(orgs) > 0 {
defaultOrg = orgs[0].Name
}
// Ensure the `defaultOrg` value is an org name, not a uuid.
// If it is a uuid, change it to the org name.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.ID.String() == defaultOrg || org.Name == defaultOrg
})
if index >= 0 {
defaultOrg = orgs[index].Name
}
// deselectOption is the option to delete the organization config file and defer
// to default behavior.
const deselectOption = "[Default]"
if defaultOrg == "" {
defaultOrg = deselectOption
}
// Pull value from a prompt
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to set the current CLI context to:"))
value, err := cliui.Select(inv, cliui.SelectOptions{
Options: append([]string{deselectOption}, orgNames(orgs)...),
Default: defaultOrg,
Size: 10,
HideSearch: false,
})
if err != nil {
return "", err
}
// Deselect is an alias for ""
if value == deselectOption {
value = ""
}
return value, nil
}
// orgNames is a helper function to turn a list of organizations into a list of
// their names as strings.
func orgNames(orgs []codersdk.Organization) []string {
names := make([]string, 0, len(orgs))
for _, org := range orgs {
names = append(names, org.Name)
}
return names
}
func (r *RootCmd) currentOrganization() *serpent.Command {
var (
stringFormat func(orgs []codersdk.Organization) (string, error)
client = new(codersdk.Client)
@@ -53,29 +226,8 @@ func (r *RootCmd) showOrganization(orgContext *OrganizationContext) *serpent.Com
onlyID = false
)
cmd := &serpent.Command{
Use: "show [\"selected\"|\"me\"|uuid|org_name]",
Short: "Show the organization. " +
"Using \"selected\" will show the selected organization from the \"--org\" flag. " +
"Using \"me\" will show all organizations you are a member of.",
Long: FormatExamples(
Example{
Description: "coder org show selected",
Command: "Shows the organizations selected with '--org=<org_name>'. " +
"This organization is the organization used by the cli.",
},
Example{
Description: "coder org show me",
Command: "List of all organizations you are a member of.",
},
Example{
Description: "coder org show developers",
Command: "Show organization with name 'developers'",
},
Example{
Description: "coder org show 90ee1875-3db5-43b3-828e-af3687522e43",
Command: "Show organization with the given ID.",
},
),
Use: "show [current|me|uuid]",
Short: "Show the organization, if no argument is given, the organization currently in use will be shown.",
Middleware: serpent.Chain(
r.InitClient(client),
serpent.RequireRangeArgs(0, 1),
@@ -90,7 +242,7 @@ func (r *RootCmd) showOrganization(orgContext *OrganizationContext) *serpent.Com
},
},
Handler: func(inv *serpent.Invocation) error {
orgArg := "selected"
orgArg := "current"
if len(inv.Args) >= 1 {
orgArg = inv.Args[0]
}
@@ -98,14 +250,14 @@ func (r *RootCmd) showOrganization(orgContext *OrganizationContext) *serpent.Com
var orgs []codersdk.Organization
var err error
switch strings.ToLower(orgArg) {
case "selected":
case "current":
stringFormat = func(orgs []codersdk.Organization) (string, error) {
if len(orgs) != 1 {
return "", xerrors.Errorf("expected 1 organization, got %d", len(orgs))
}
return fmt.Sprintf("Current CLI Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil
}
org, err := orgContext.Selected(inv, client)
org, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
+98 -5
View File
@@ -12,8 +12,11 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestCurrentOrganization(t *testing.T) {
@@ -29,10 +32,8 @@ func TestCurrentOrganization(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]codersdk.Organization{
{
MinimalOrganization: codersdk.MinimalOrganization{
ID: orgID,
Name: "not-default",
},
ID: orgID,
Name: "not-default",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
IsDefault: false,
@@ -42,7 +43,7 @@ func TestCurrentOrganization(t *testing.T) {
defer srv.Close()
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "organizations", "show", "selected")
inv, root := clitest.New(t, "organizations", "show", "current")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
@@ -52,6 +53,98 @@ func TestCurrentOrganization(t *testing.T) {
require.NoError(t, <-errC)
pty.ExpectMatch(orgID.String())
})
t.Run("OnlyID", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, ownerClient)
// Owner is required to make orgs
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
ctx := testutil.Context(t, testutil.WaitMedium)
orgs := []string{"foo", "bar"}
for _, orgName := range orgs {
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: orgName,
})
require.NoError(t, err)
}
inv, root := clitest.New(t, "organizations", "show", "--only-id")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(first.OrganizationID.String())
})
t.Run("UsingFlag", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, ownerClient)
// Owner is required to make orgs
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
ctx := testutil.Context(t, testutil.WaitMedium)
orgs := map[string]codersdk.Organization{
"foo": {},
"bar": {},
}
for orgName := range orgs {
org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: orgName,
})
require.NoError(t, err)
orgs[orgName] = org
}
inv, root := clitest.New(t, "organizations", "show", "current", "--only-id", "-z=bar")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(orgs["bar"].ID.String())
})
}
func TestOrganizationSwitch(t *testing.T) {
t.Parallel()
t.Run("Switch", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, ownerClient)
// Owner is required to make orgs
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
ctx := testutil.Context(t, testutil.WaitMedium)
orgs := []string{"foo", "bar"}
for _, orgName := range orgs {
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: orgName,
})
require.NoError(t, err)
}
exp, err := client.OrganizationByName(ctx, "foo")
require.NoError(t, err)
inv, root := clitest.New(t, "organizations", "set", "foo")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(exp.ID.String())
})
}
func must[V any](v V, err error) V {
-176
View File
@@ -1,176 +0,0 @@
package cli
import (
"fmt"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) organizationMembers(orgContext *OrganizationContext) *serpent.Command {
cmd := &serpent.Command{
Use: "members",
Aliases: []string{"member"},
Short: "Manage organization members",
Children: []*serpent.Command{
r.listOrganizationMembers(orgContext),
r.assignOrganizationRoles(orgContext),
r.addOrganizationMember(orgContext),
r.removeOrganizationMember(orgContext),
},
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
}
return cmd
}
func (r *RootCmd) removeOrganizationMember(orgContext *OrganizationContext) *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "remove <username | user_id>",
Short: "Remove a new member to the current organization",
Middleware: serpent.Chain(
r.InitClient(client),
serpent.RequireNArgs(1),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
organization, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
user := inv.Args[0]
err = client.DeleteOrganizationMember(ctx, organization.ID, user)
if err != nil {
return xerrors.Errorf("could not remove member from organization %q: %w", organization.HumanName(), err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Organization member removed from %q\n", organization.HumanName())
return nil
},
}
return cmd
}
func (r *RootCmd) addOrganizationMember(orgContext *OrganizationContext) *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "add <username | user_id>",
Short: "Add a new member to the current organization",
Middleware: serpent.Chain(
r.InitClient(client),
serpent.RequireNArgs(1),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
organization, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
user := inv.Args[0]
_, err = client.PostOrganizationMember(ctx, organization.ID, user)
if err != nil {
return xerrors.Errorf("could not add member to organization %q: %w", organization.HumanName(), err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Organization member added to %q\n", organization.HumanName())
return nil
},
}
return cmd
}
func (r *RootCmd) assignOrganizationRoles(orgContext *OrganizationContext) *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "edit-roles <username | user_id> [roles...]",
Aliases: []string{"edit-role"},
Short: "Edit organization member's roles",
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
organization, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
if len(inv.Args) < 1 {
return xerrors.Errorf("user_id or username is required as the first argument")
}
userIdentifier := inv.Args[0]
roles := inv.Args[1:]
member, err := client.UpdateOrganizationMemberRoles(ctx, organization.ID, userIdentifier, codersdk.UpdateRoles{
Roles: roles,
})
if err != nil {
return xerrors.Errorf("update member roles: %w", err)
}
updatedTo := make([]string, 0)
for _, role := range member.Roles {
updatedTo = append(updatedTo, role.String())
}
_, _ = fmt.Fprintf(inv.Stdout, "Member roles updated to [%s]\n", strings.Join(updatedTo, ", "))
return nil
},
}
return cmd
}
func (r *RootCmd) listOrganizationMembers(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.TableFormat([]codersdk.OrganizationMemberWithUserData{}, []string{"username", "organization_roles"}),
cliui.JSONFormat(),
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "list",
Short: "List all organization members",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
organization, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
res, err := client.OrganizationMembers(ctx, organization.ID)
if err != nil {
return xerrors.Errorf("fetch members: %w", err)
}
out, err := formatter.Format(inv.Context(), res)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
-82
View File
@@ -1,82 +0,0 @@
package cli_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/testutil"
)
func TestListOrganizationMembers(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, ownerClient)
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "members", "list", "-c", "user_id,username,roles")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, buf.String(), user.Username)
require.Contains(t, buf.String(), owner.UserID.String())
})
}
func TestRemoveOrganizationMembers(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, ownerClient)
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
_, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "members", "remove", "-O", owner.OrganizationID.String(), user.Username)
clitest.SetupConfig(t, orgAdminClient, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
members, err := orgAdminClient.OrganizationMembers(ctx, owner.OrganizationID)
require.NoError(t, err)
require.Len(t, members, 2)
})
t.Run("UserNotExists", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, ownerClient)
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "members", "remove", "-O", owner.OrganizationID.String(), "random_name")
clitest.SetupConfig(t, orgAdminClient, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "must be an existing uuid or username")
})
}
+22 -305
View File
@@ -1,22 +1,18 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"slices"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Command {
func (r *RootCmd) organizationRoles() *serpent.Command {
cmd := &serpent.Command{
Use: "roles",
Short: "Manage organization roles.",
@@ -26,29 +22,34 @@ func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Co
},
Hidden: true,
Children: []*serpent.Command{
r.showOrganizationRoles(orgContext),
r.editOrganizationRole(orgContext),
r.showOrganizationRoles(),
},
}
return cmd
}
func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpent.Command {
func (r *RootCmd) showOrganizationRoles() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}),
func(data any) (any, error) {
inputs, ok := data.([]codersdk.AssignableRoles)
input, ok := data.([]codersdk.AssignableRoles)
if !ok {
return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data)
}
tableRows := make([]roleTableRow, 0)
for _, input := range inputs {
tableRows = append(tableRows, roleToTableView(input.Role))
rows := make([]assignableRolesTableRow, 0, len(input))
for _, role := range input {
rows = append(rows, assignableRolesTableRow{
Name: role.Name,
DisplayName: role.DisplayName,
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)),
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
Assignable: role.Assignable,
BuiltIn: role.BuiltIn,
})
}
return tableRows, nil
return rows, nil
},
),
cliui.JSONFormat(),
@@ -63,7 +64,7 @@ func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpen
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
org, err := orgContext.Selected(inv, client)
org, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -100,297 +101,13 @@ func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpen
return cmd
}
func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
func(data any) (any, error) {
typed, _ := data.(codersdk.Role)
return []roleTableRow{roleToTableView(typed)}, nil
},
),
cliui.JSONFormat(),
)
var (
dryRun bool
jsonInput bool
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "edit <role_name>",
Short: "Edit an organization custom role",
Long: FormatExamples(
Example{
Description: "Run with an input.json file",
Command: "coder roles edit --stdin < role.json",
},
),
Options: []serpent.Option{
cliui.SkipPromptOption(),
{
Name: "dry-run",
Description: "Does all the work, but does not submit the final updated role.",
Flag: "dry-run",
Value: serpent.BoolOf(&dryRun),
},
{
Name: "stdin",
Description: "Reads stdin for the json role definition to upload.",
Flag: "stdin",
Value: serpent.BoolOf(&jsonInput),
},
},
Middleware: serpent.Chain(
serpent.RequireRangeArgs(0, 1),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
org, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
var customRole codersdk.Role
if jsonInput {
// JSON Upload mode
bytes, err := io.ReadAll(inv.Stdin)
if err != nil {
return xerrors.Errorf("reading stdin: %w", err)
}
err = json.Unmarshal(bytes, &customRole)
if err != nil {
return xerrors.Errorf("parsing stdin json: %w", err)
}
if customRole.Name == "" {
arr := make([]json.RawMessage, 0)
err = json.Unmarshal(bytes, &arr)
if err == nil && len(arr) > 0 {
return xerrors.Errorf("the input appears to be an array, only 1 role can be sent at a time")
}
return xerrors.Errorf("json input does not appear to be a valid role")
}
} else {
if len(inv.Args) == 0 {
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
if err != nil {
return xerrors.Errorf("editing role: %w", err)
}
customRole = *interactiveRole
preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Are you sure you wish to update the role? " + preview,
Default: "yes",
IsConfirm: true,
})
if err != nil {
return xerrors.Errorf("abort: %w", err)
}
}
var updated codersdk.Role
if dryRun {
// Do not actually post
updated = customRole
} else {
updated, err = client.PatchOrganizationRole(ctx, customRole)
if err != nil {
return xerrors.Errorf("patch role: %w", err)
}
}
output, err := formatter.Format(ctx, updated)
if err != nil {
return xerrors.Errorf("formatting: %w", err)
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, error) {
ctx := inv.Context()
roles, err := client.ListOrganizationRoles(ctx, orgID)
if err != nil {
return nil, xerrors.Errorf("listing roles: %w", err)
}
// Make sure the role actually exists first
var originalRole codersdk.AssignableRoles
for _, r := range roles {
if strings.EqualFold(inv.Args[0], r.Name) {
originalRole = r
break
}
}
if originalRole.Name == "" {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "No organization role exists with that name, do you want to create one?",
Default: "yes",
IsConfirm: true,
})
if err != nil {
return nil, xerrors.Errorf("abort: %w", err)
}
originalRole.Role = codersdk.Role{
Name: inv.Args[0],
OrganizationID: orgID.String(),
}
}
// Some checks since interactive mode is limited in what it currently sees
if len(originalRole.SitePermissions) > 0 {
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
}
if len(originalRole.UserPermissions) > 0 {
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
}
role := &originalRole.Role
allowedResources := []codersdk.RBACResource{
codersdk.ResourceTemplate,
codersdk.ResourceWorkspace,
codersdk.ResourceUser,
codersdk.ResourceGroup,
}
const done = "Finish and submit changes"
const abort = "Cancel changes"
// Now starts the role editing "game".
customRoleLoop:
for {
selected, err := cliui.Select(inv, cliui.SelectOptions{
Message: "Select which resources to edit permissions",
Options: append(permissionPreviews(role, allowedResources), done, abort),
})
if err != nil {
return role, xerrors.Errorf("selecting resource: %w", err)
}
switch selected {
case done:
break customRoleLoop
case abort:
return role, xerrors.Errorf("edit role %q aborted", role.Name)
default:
strs := strings.Split(selected, "::")
resource := strings.TrimSpace(strs[0])
actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource),
Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]),
Defaults: defaultActions(role, resource),
})
if err != nil {
return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
}
applyOrgResourceActions(role, resource, actions)
// back to resources!
}
}
// This println is required because the prompt ends us on the same line as some text.
_, _ = fmt.Println()
return role, nil
}
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = make([]codersdk.Permission, 0)
}
// Construct new site perms with only new perms for the resource
keep := make([]codersdk.Permission, 0)
for _, perm := range role.OrganizationPermissions {
perm := perm
if string(perm.ResourceType) != resource {
keep = append(keep, perm)
}
}
// Add new perms
for _, action := range actions {
keep = append(keep, codersdk.Permission{
Negate: false,
ResourceType: codersdk.RBACResource(resource),
Action: codersdk.RBACAction(action),
})
}
role.OrganizationPermissions = keep
}
func defaultActions(role *codersdk.Role, resource string) []string {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = []codersdk.Permission{}
}
defaults := make([]string, 0)
for _, perm := range role.OrganizationPermissions {
if string(perm.ResourceType) == resource {
defaults = append(defaults, string(perm.Action))
}
}
return defaults
}
func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string {
previews := make([]string, 0, len(resources))
for _, resource := range resources {
previews = append(previews, permissionPreview(role, resource))
}
return previews
}
func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = []codersdk.Permission{}
}
count := 0
for _, perm := range role.OrganizationPermissions {
if perm.ResourceType == resource {
count++
}
}
return fmt.Sprintf("%s :: %d permissions", resource, count)
}
func roleToTableView(role codersdk.Role) roleTableRow {
return roleTableRow{
Name: role.Name,
DisplayName: role.DisplayName,
OrganizationID: role.OrganizationID,
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
OrganizationPermissions: fmt.Sprintf("%d permissions", len(role.OrganizationPermissions)),
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
}
}
type roleTableRow struct {
type assignableRolesTableRow struct {
Name string `table:"name,default_sort"`
DisplayName string `table:"display_name"`
OrganizationID string `table:"organization_id"`
SitePermissions string ` table:"site_permissions"`
// map[<org_id>] -> Permissions
OrganizationPermissions string `table:"organization_permissions"`
OrganizationPermissions string `table:"org_permissions"`
UserPermissions string `table:"user_permissions"`
Assignable bool `table:"assignable"`
BuiltIn bool `table:"built_in"`
}
-3
View File
@@ -58,9 +58,6 @@ func (r *RootCmd) ping() *serpent.Command {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
opts.BlockEndpoints = true
}
if !r.disableNetworkTelemetry {
opts.EnableTelemetry = true
}
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
if err != nil {
return err
-3
View File
@@ -106,9 +106,6 @@ func (r *RootCmd) portForward() *serpent.Command {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
opts.BlockEndpoints = true
}
if !r.disableNetworkTelemetry {
opts.EnableTelemetry = true
}
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
if err != nil {
return err
-186
View File
@@ -1,186 +0,0 @@
package cli
import (
"fmt"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (RootCmd) promptExample() *serpent.Command {
promptCmd := func(use string, prompt func(inv *serpent.Invocation) error, options ...serpent.Option) *serpent.Command {
return &serpent.Command{
Use: use,
Options: options,
Handler: func(inv *serpent.Invocation) error {
return prompt(inv)
},
}
}
var useSearch bool
useSearchOption := serpent.Option{
Name: "search",
Description: "Show the search.",
Required: false,
Flag: "search",
Value: serpent.BoolOf(&useSearch),
}
cmd := &serpent.Command{
Use: "prompt-example",
Short: "Example of various prompt types used within coder cli.",
Long: "Example of various prompt types used within coder cli. " +
"This command exists to aid in adjusting visuals of command prompts.",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
promptCmd("confirm", func(inv *serpent.Invocation) error {
value, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Basic confirmation prompt.",
Default: "yes",
IsConfirm: true,
})
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
return err
}),
promptCmd("validation", func(inv *serpent.Invocation) error {
value, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Input a string that starts with a capital letter.",
Default: "",
Secret: false,
IsConfirm: false,
Validate: func(s string) error {
if len(s) == 0 {
return xerrors.Errorf("an input string is required")
}
if strings.ToUpper(string(s[0])) != string(s[0]) {
return xerrors.Errorf("input string must start with a capital letter")
}
return nil
},
})
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
return err
}),
promptCmd("secret", func(inv *serpent.Invocation) error {
value, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Input a secret",
Default: "",
Secret: true,
IsConfirm: false,
Validate: func(s string) error {
if len(s) == 0 {
return xerrors.Errorf("an input string is required")
}
return nil
},
})
_, _ = fmt.Fprintf(inv.Stdout, "Your secret of length %d is safe with me\n", len(value))
return err
}),
promptCmd("select", func(inv *serpent.Invocation) error {
value, err := cliui.Select(inv, cliui.SelectOptions{
Options: []string{
"Blue", "Green", "Yellow", "Red", "Something else",
},
Default: "",
Message: "Select your favorite color:",
Size: 5,
HideSearch: !useSearch,
})
if value == "Something else" {
_, _ = fmt.Fprint(inv.Stdout, "I would have picked blue.\n")
} else {
_, _ = fmt.Fprintf(inv.Stdout, "%s is a nice color.\n", value)
}
return err
}, useSearchOption),
promptCmd("multiple", func(inv *serpent.Invocation) error {
_, _ = fmt.Fprintf(inv.Stdout, "This command exists to test the behavior of multiple prompts. The survey library does not erase the original message prompt after.")
thing, err := cliui.Select(inv, cliui.SelectOptions{
Message: "Select a thing",
Options: []string{
"Car", "Bike", "Plane", "Boat", "Train",
},
Default: "Car",
})
if err != nil {
return err
}
color, err := cliui.Select(inv, cliui.SelectOptions{
Message: "Select a color",
Options: []string{
"Blue", "Green", "Yellow", "Red",
},
Default: "Blue",
})
if err != nil {
return err
}
properties, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Message: "Select properties",
Options: []string{
"Fast", "Cool", "Expensive", "New",
},
Defaults: []string{"Fast"},
})
if err != nil {
return err
}
_, _ = fmt.Fprintf(inv.Stdout, "Your %s %s is awesome! Did you paint it %s?\n",
strings.Join(properties, " "),
thing,
color,
)
return err
}),
promptCmd("multi-select", func(inv *serpent.Invocation) error {
values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Message: "Select some things:",
Options: []string{
"Code", "Chair", "Whale", "Diamond", "Carrot",
},
Defaults: []string{"Code"},
})
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(values, ", "))
return err
}),
promptCmd("rich-parameter", func(inv *serpent.Invocation) error {
value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{
Options: []codersdk.TemplateVersionParameterOption{
{
Name: "Blue",
Description: "Like the ocean.",
Value: "blue",
Icon: "/logo/blue.png",
},
{
Name: "Red",
Description: "Like a clown's nose.",
Value: "red",
Icon: "/logo/red.png",
},
{
Name: "Yellow",
Description: "Like a bumblebee. ",
Value: "yellow",
Icon: "/logo/yellow.png",
},
},
Default: "blue",
Size: 5,
HideSearch: useSearch,
})
_, _ = fmt.Fprintf(inv.Stdout, "%s is a good choice.\n", value.Name)
return err
}, useSearchOption),
},
}
return cmd
}
+1 -1
View File
@@ -21,7 +21,7 @@ func TestRename(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+5 -5
View File
@@ -38,7 +38,7 @@ func TestRestart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -69,7 +69,7 @@ func TestRestart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name, "--build-options")
@@ -123,7 +123,7 @@ func TestRestart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name,
@@ -202,7 +202,7 @@ func TestRestartWithParameters(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: immutableParameterName,
@@ -250,7 +250,7 @@ func TestRestartWithParameters(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: mutableParameterName,
+72 -88
View File
@@ -52,20 +52,20 @@ var (
)
const (
varURL = "url"
varToken = "token"
varAgentToken = "agent-token"
varAgentTokenFile = "agent-token-file"
varAgentURL = "agent-url"
varHeader = "header"
varHeaderCommand = "header-command"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varNoFeatureWarning = "no-feature-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
varDisableDirect = "disable-direct-connections"
varDisableNetworkTelemetry = "disable-network-telemetry"
varURL = "url"
varToken = "token"
varAgentToken = "agent-token"
varAgentTokenFile = "agent-token-file"
varAgentURL = "agent-url"
varHeader = "header"
varHeaderCommand = "header-command"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varNoFeatureWarning = "no-feature-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
varOrganizationSelect = "organization"
varDisableDirect = "disable-direct-connections"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
@@ -87,8 +87,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.login(),
r.logout(),
r.netcheck(),
r.notifications(),
r.organizations(),
r.portForward(),
r.publickey(),
r.resetPassword(),
@@ -97,6 +95,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.tokens(),
r.users(),
r.version(defaultVersionInfo),
r.organizations(),
// Workspace Commands
r.autoupdate(),
@@ -118,14 +117,13 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.stop(),
r.unfavorite(),
r.update(),
r.whoami(),
// Hidden
r.expCmd(),
r.gitssh(),
r.support(),
r.vscodeSSH(),
r.workspaceAgent(),
r.expCmd(),
r.support(),
}
}
@@ -438,13 +436,6 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Value: serpent.BoolOf(&r.disableDirect),
Group: globalGroup,
},
{
Flag: varDisableNetworkTelemetry,
Env: "CODER_DISABLE_NETWORK_TELEMETRY",
Description: "Disable network telemetry. Network telemetry is collected when connecting to workspaces using the CLI, and is forwarded to the server. If telemetry is also enabled on the server, it may be sent to Coder. Network telemetry is used to measure network quality and detect regressions.",
Value: serpent.BoolOf(&r.disableNetworkTelemetry),
Group: globalGroup,
},
{
Flag: "debug-http",
Description: "Debug codersdk HTTP requests.",
@@ -460,6 +451,15 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Value: serpent.StringOf(&r.globalConfig),
Group: globalGroup,
},
{
Flag: varOrganizationSelect,
FlagShorthand: "z",
Env: "CODER_ORGANIZATION",
Description: "Select which organization (uuid or name) to use This overrides what is present in the config file.",
Value: serpent.StringOf(&r.organizationSelect),
Hidden: true,
Group: globalGroup,
},
{
Flag: "version",
// This was requested by a customer to assist with their migration.
@@ -476,24 +476,24 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
// RootCmd contains parameters and helpers useful to all commands.
type RootCmd struct {
clientURL *url.URL
token string
globalConfig string
header []string
headerCommand string
agentToken string
agentTokenFile string
agentURL *url.URL
forceTTY bool
noOpen bool
verbose bool
versionFlag bool
disableDirect bool
debugHTTP bool
clientURL *url.URL
token string
globalConfig string
header []string
headerCommand string
agentToken string
agentTokenFile string
agentURL *url.URL
forceTTY bool
noOpen bool
verbose bool
organizationSelect string
versionFlag bool
disableDirect bool
debugHTTP bool
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
noVersionCheck bool
noFeatureWarning bool
}
// InitClient authenticates the client with files from disk
@@ -632,68 +632,52 @@ func (r *RootCmd) createAgentClient() (*agentsdk.Client, error) {
return client, nil
}
type OrganizationContext struct {
// FlagSelect is the value passed in via the --org flag
FlagSelect string
}
func NewOrganizationContext() *OrganizationContext {
return &OrganizationContext{}
}
func (*OrganizationContext) optionName() string { return "Organization" }
func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
cmd.Options = append(cmd.Options, serpent.Option{
Name: o.optionName(),
Description: "Select which organization (uuid or name) to use.",
// Only required if the user is a part of more than 1 organization.
// Otherwise, we can assume a default value.
Required: false,
Flag: "org",
FlagShorthand: "O",
Env: "CODER_ORGANIZATION",
Value: serpent.StringOf(&o.FlagSelect),
})
}
func (o *OrganizationContext) ValueSource(inv *serpent.Invocation) (string, serpent.ValueSource) {
opt := inv.Command.Options.ByName(o.optionName())
if opt == nil {
return o.FlagSelect, serpent.ValueSourceNone
// CurrentOrganization returns the currently active organization for the authenticated user.
func CurrentOrganization(r *RootCmd, inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
conf := r.createConfig()
selected := r.organizationSelect
if selected == "" && conf.Organization().Exists() {
org, err := conf.Organization().Read()
if err != nil {
return codersdk.Organization{}, xerrors.Errorf("read selected organization from config file %q: %w", conf.Organization(), err)
}
selected = org
}
return o.FlagSelect, opt.ValueSource
}
func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
// Fetch the set of organizations the user is a member of.
// Verify the org exists and the user is a member
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return codersdk.Organization{}, xerrors.Errorf("get organizations: %w", err)
return codersdk.Organization{}, err
}
// User manually selected an organization
if o.FlagSelect != "" {
if selected != "" {
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == o.FlagSelect || org.ID.String() == o.FlagSelect
return org.Name == selected || org.ID.String() == selected
})
if index < 0 {
var names []string
for _, org := range orgs {
names = append(names, org.Name)
}
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected)
}
return orgs[index], nil
}
if len(orgs) == 1 {
return orgs[0], nil
// User did not select an organization, so use the default.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.IsDefault
})
if index < 0 {
if len(orgs) == 1 {
// If there is no "isDefault", but only 1 org is present. We can just
// assume the single organization is correct. This is mainly a helper
// for cli hitting an old instance, or a user that belongs to a single
// org that is not the default.
return orgs[0], nil
}
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder org set <org>' to select an organization to use")
}
// No org selected, and we are more than 1? Return an error.
return codersdk.Organization{}, xerrors.Errorf("Must select an organization with --org=<org_name>.")
return orgs[index], nil
}
func splitNamedWorkspace(identifier string) (owner string, workspaceName string, err error) {
+17 -100
View File
@@ -55,11 +55,6 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/pretty"
"github.com/coder/retry"
"github.com/coder/serpent"
"github.com/coder/wgtunnel/tunnelsdk"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
@@ -67,9 +62,9 @@ import (
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/batchstats"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/database/dbmetrics"
"github.com/coder/coder/v2/coderd/database/dbpurge"
@@ -79,7 +74,6 @@ import (
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/oauthpki"
"github.com/coder/coder/v2/coderd/prometheusmetrics"
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
@@ -93,7 +87,7 @@ import (
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/workspaceusage"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/drpc"
"github.com/coder/coder/v2/cryptorand"
@@ -104,9 +98,13 @@ import (
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/pretty"
"github.com/coder/retry"
"github.com/coder/serpent"
"github.com/coder/wgtunnel/tunnelsdk"
)
func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) {
func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) {
if vals.OIDC.ClientID == "" {
return nil, xerrors.Errorf("OIDC client ID must be set!")
}
@@ -114,12 +112,6 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
return nil, xerrors.Errorf("OIDC issuer URL must be set!")
}
// Skipping issuer checks is not recommended.
if vals.OIDC.SkipIssuerChecks {
logger.Warn(ctx, "issuer checks with OIDC is disabled. This is not recommended as it can compromise the security of the authentication")
ctx = oidc.InsecureIssuerURLContext(ctx, vals.OIDC.IssuerURL.String())
}
oidcProvider, err := oidc.NewProvider(
ctx, vals.OIDC.IssuerURL.String(),
)
@@ -173,14 +165,10 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
Provider: oidcProvider,
Verifier: oidcProvider.Verifier(&oidc.Config{
ClientID: vals.OIDC.ClientID.String(),
// Enabling this skips checking the "iss" claim in the token
// matches the issuer URL. This is not recommended.
SkipIssuerCheck: vals.OIDC.SkipIssuerChecks.Value(),
}),
EmailDomain: vals.OIDC.EmailDomain,
AllowSignups: vals.OIDC.AllowSignups.Value(),
UsernameField: vals.OIDC.UsernameField.String(),
NameField: vals.OIDC.NameField.String(),
EmailField: vals.OIDC.EmailField.String(),
AuthURLParams: vals.OIDC.AuthURLParams.Value,
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
@@ -604,7 +592,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
SSHConfigOptions: configSSHOptions,
},
AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(),
NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled.
}
if httpServers.TLSConfig != nil {
options.TLSCertificates = httpServers.TLSConfig.Certificates
@@ -666,17 +653,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// Missing:
// - Userinfo
// - Verify
oc, err := createOIDCConfig(ctx, options.Logger, vals)
oc, err := createOIDCConfig(ctx, vals)
if err != nil {
return xerrors.Errorf("create oidc config: %w", err)
}
options.OIDCConfig = oc
}
experiments := coderd.ReadExperiments(
options.Logger, options.DeploymentValues.Experiments.Value(),
)
// We'll read from this channel in the select below that tracks shutdown. If it remains
// nil, that case of the select will just never fire, but it's important not to have a
// "bare" read on this channel.
@@ -847,7 +830,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
defer options.Telemetry.Close()
} else {
logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/admin/telemetry`)
logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/v2/latest/admin/telemetry`)
}
// This prevents the pprof import from being accidentally deleted.
@@ -873,9 +856,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
options.SwaggerEndpoint = vals.Swagger.Enable.Value()
}
batcher, closeBatcher, err := workspacestats.NewBatcher(ctx,
workspacestats.BatcherWithLogger(options.Logger.Named("batchstats")),
workspacestats.BatcherWithStore(options.Database),
batcher, closeBatcher, err := batchstats.New(ctx,
batchstats.WithLogger(options.Logger.Named("batchstats")),
batchstats.WithStore(options.Database),
)
if err != nil {
return xerrors.Errorf("failed to create agent stats batcher: %w", err)
@@ -980,39 +963,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
defer purger.Close()
// Updates workspace usage
tracker := workspacestats.NewTracker(options.Database,
workspacestats.TrackerWithLogger(logger.Named("workspace_usage_tracker")),
tracker := workspaceusage.New(options.Database,
workspaceusage.WithLogger(logger.Named("workspace_usage_tracker")),
)
options.WorkspaceUsageTracker = tracker
defer tracker.Close()
// Manage notifications.
var (
notificationsManager *notifications.Manager
)
if experiments.Enabled(codersdk.ExperimentNotifications) {
cfg := options.DeploymentValues.Notifications
metrics := notifications.NewMetrics(options.PrometheusRegistry)
// The enqueuer is responsible for enqueueing notifications to the given store.
enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, templateHelpers(options), logger.Named("notifications.enqueuer"))
if err != nil {
return xerrors.Errorf("failed to instantiate notification store enqueuer: %w", err)
}
options.NotificationsEnqueuer = enqueuer
// The notification manager is responsible for:
// - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications)
// - keeping the store updated with status updates
notificationsManager, err = notifications.NewManager(cfg, options.Database, metrics, logger.Named("notifications.manager"))
if err != nil {
return xerrors.Errorf("failed to instantiate notification manager: %w", err)
}
// nolint:gocritic // TODO: create own role.
notificationsManager.Run(dbauthz.AsSystemRestricted(ctx))
}
// Wrap the server in middleware that redirects to the access URL if
// the request is not to a local IP.
var handler http.Handler = coderAPI.RootHandler
@@ -1075,7 +1031,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value())
defer autobuildTicker.Stop()
autobuildExecutor := autobuild.NewExecutor(
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer)
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C)
autobuildExecutor.Run()
hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value())
@@ -1093,10 +1049,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
case <-stopCtx.Done():
exitErr = stopCtx.Err()
waitForProvisionerJobs = true
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Stop caught, waiting for provisioner jobs to complete and gracefully exiting. Use ctrl+\\ to force quit\n"))
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Stop caught, waiting for provisioner jobs to complete and gracefully exiting. Use ctrl+\\ to force quit"))
case <-interruptCtx.Done():
exitErr = interruptCtx.Err()
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit\n"))
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit"))
case <-tunnelDone:
exitErr = xerrors.New("dev tunnel closed unexpectedly")
case <-pubsubWatchdogTimeout:
@@ -1132,21 +1088,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// Cancel any remaining in-flight requests.
shutdownConns()
if notificationsManager != nil {
// Stop the notification manager, which will cause any buffered updates to the store to be flushed.
// If the Stop() call times out, messages that were sent but not reflected as such in the store will have
// their leases expire after a period of time and will be re-queued for sending.
// See CODER_NOTIFICATIONS_LEASE_PERIOD.
cliui.Info(inv.Stdout, "Shutting down notifications manager..."+"\n")
err = shutdownWithTimeout(notificationsManager.Stop, 5*time.Second)
if err != nil {
cliui.Warnf(inv.Stderr, "Notifications manager shutdown took longer than 5s, "+
"this may result in duplicate notifications being sent: %s\n", err)
} else {
cliui.Info(inv.Stdout, "Gracefully shut down notifications manager\n")
}
}
// Shut down provisioners before waiting for WebSockets
// connections to close.
var wg sync.WaitGroup
@@ -1286,15 +1227,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return serverCmd
}
// templateHelpers builds a set of functions which can be called in templates.
// We build them here to avoid an import cycle by using coderd.Options in notifications.Manager.
// We can later use this to inject whitelabel fields when app name / logo URL are overridden.
func templateHelpers(options *coderd.Options) map[string]any {
return map[string]any{
"base_url": func() string { return options.AccessURL.String() },
}
}
// printDeprecatedOptions loops through all command options, and prints
// a warning for usage of deprecated options.
func PrintDeprecatedOptions() serpent.MiddlewareFunc {
@@ -1579,19 +1511,6 @@ func generateSelfSignedCertificate() (*tls.Certificate, error) {
return &cert, nil
}
// defaultCipherSuites is a list of safe cipher suites that we default to. This
// is different from Golang's list of defaults, which unfortunately includes
// `TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA`.
var defaultCipherSuites = func() []uint16 {
ret := []uint16{}
for _, suite := range tls.CipherSuites() {
ret = append(ret, suite.ID)
}
return ret
}()
// configureServerTLS returns the TLS config used for the Coderd server
// connections to clients. A logger is passed in to allow printing warning
// messages that do not block startup.
@@ -1622,8 +1541,6 @@ func configureServerTLS(ctx context.Context, logger slog.Logger, tlsMinVersion,
return nil, err
}
tlsConfig.CipherSuites = cipherIDs
} else {
tlsConfig.CipherSuites = defaultCipherSuites
}
switch tlsClientAuth {
+2 -5
View File
@@ -85,7 +85,6 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
// Use the validator tags so we match the API's validation.
req := codersdk.CreateUserRequest{
Username: "username",
Name: "Admin User",
Email: "email@coder.com",
Password: "ValidPa$$word123!",
OrganizationID: uuid.New(),
@@ -117,7 +116,6 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
return err
}
}
if newUserEmail == "" {
newUserEmail, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Email",
@@ -191,11 +189,10 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
ID: uuid.New(),
Email: newUserEmail,
Username: newUserUsername,
Name: "Admin User",
HashedPassword: []byte(hashedPassword),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
RBACRoles: []string{rbac.RoleOwner().String()},
RBACRoles: []string{rbac.RoleOwner()},
LoginType: database.LoginTypePassword,
})
if err != nil {
@@ -225,7 +222,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
UserID: newUser.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Roles: []string{rbac.RoleOrgAdmin()},
Roles: []string{rbac.RoleOrgAdmin(org.ID)},
})
if err != nil {
return xerrors.Errorf("insert organization member: %w", err)
+4 -5
View File
@@ -17,7 +17,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
@@ -57,7 +56,7 @@ func TestServerCreateAdminUser(t *testing.T) {
require.NoError(t, err)
require.True(t, ok, "password does not match")
require.EqualValues(t, []string{codersdk.RoleOwner}, user.RBACRoles, "user does not have owner role")
require.EqualValues(t, []string{rbac.RoleOwner()}, user.RBACRoles, "user does not have owner role")
// Check that user is admin in every org.
orgs, err := db.GetOrganizations(ctx)
@@ -67,12 +66,12 @@ func TestServerCreateAdminUser(t *testing.T) {
orgIDs[org.ID] = struct{}{}
}
orgMemberships, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{UserID: user.ID})
orgMemberships, err := db.GetOrganizationMembershipsByUserID(ctx, user.ID)
require.NoError(t, err)
orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships))
for _, membership := range orgMemberships {
orgIDs2[membership.OrganizationMember.OrganizationID] = struct{}{}
assert.Equal(t, []string{rbac.RoleOrgAdmin()}, membership.OrganizationMember.Roles, "user is not org admin")
orgIDs2[membership.OrganizationID] = struct{}{}
assert.Equal(t, []string{rbac.RoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin")
}
require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs")
-22
View File
@@ -20,28 +20,6 @@ import (
"github.com/coder/serpent"
)
func Test_configureServerTLS(t *testing.T) {
t.Parallel()
t.Run("DefaultNoInsecureCiphers", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
cfg, err := configureServerTLS(context.Background(), logger, "tls12", "none", nil, nil, "", nil, false)
require.NoError(t, err)
require.NotEmpty(t, cfg)
insecureCiphers := tls.InsecureCipherSuites()
for _, cipher := range cfg.CipherSuites {
for _, insecure := range insecureCiphers {
if cipher == insecure.ID {
t.Logf("Insecure cipher found by default: %s", insecure.Name)
t.Fail()
}
}
}
})
}
func Test_configureCipherSuites(t *testing.T) {
t.Parallel()
+30 -41
View File
@@ -967,32 +967,26 @@ func TestServer(t *testing.T) {
assert.NoError(t, err)
// nolint:bodyclose
res, err = http.DefaultClient.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
return err == nil
}, testutil.WaitShort, testutil.IntervalFast)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
hasActiveUsers := false
for scanner.Scan() {
// This metric is manually registered to be tracked in the server. That's
// why we test it's tracked here.
if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") {
hasActiveUsers = true
continue
}
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled")
}
t.Logf("scanned %s", scanner.Text())
scanner := bufio.NewScanner(res.Body)
hasActiveUsers := false
for scanner.Scan() {
// This metric is manually registered to be tracked in the server. That's
// why we test it's tracked here.
if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") {
hasActiveUsers = true
continue
}
if scanner.Err() != nil {
t.Logf("scanner err: %s", scanner.Err().Error())
return false
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled")
}
return hasActiveUsers
}, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_api_active_users_duration_hour in time")
t.Logf("scanned %s", scanner.Text())
}
require.NoError(t, scanner.Err())
require.True(t, hasActiveUsers)
})
t.Run("DBMetricsEnabled", func(t *testing.T) {
@@ -1023,25 +1017,20 @@ func TestServer(t *testing.T) {
assert.NoError(t, err)
// nolint:bodyclose
res, err = http.DefaultClient.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
return err == nil
}, testutil.WaitShort, testutil.IntervalFast)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
hasDBMetrics := false
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
hasDBMetrics = true
}
t.Logf("scanned %s", scanner.Text())
scanner := bufio.NewScanner(res.Body)
hasDBMetrics := false
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
hasDBMetrics = true
}
if scanner.Err() != nil {
t.Logf("scanner err: %s", scanner.Err().Error())
return false
}
return hasDBMetrics
}, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_db_query_latencies_seconds in time")
t.Logf("scanned %s", scanner.Text())
}
require.NoError(t, scanner.Err())
require.True(t, hasDBMetrics)
})
})
t.Run("GitHubOAuth", func(t *testing.T) {
@@ -1358,7 +1347,7 @@ func TestServer(t *testing.T) {
}
return lastStat.Size() > 0
},
dur, //nolint:gocritic
testutil.WaitShort,
testutil.IntervalFast,
"file at %s should exist, last stat: %+v",
fiName, lastStat,
+1 -1
View File
@@ -20,7 +20,7 @@ func TestShow(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
args := []string{
+13 -64
View File
@@ -6,6 +6,7 @@ import (
"os"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors"
tsspeedtest "tailscale.com/net/speedtest"
"tailscale.com/wgengine/capture"
@@ -18,51 +19,12 @@ import (
"github.com/coder/serpent"
)
type SpeedtestResult struct {
Overall SpeedtestResultInterval `json:"overall"`
Intervals []SpeedtestResultInterval `json:"intervals"`
}
type SpeedtestResultInterval struct {
StartTimeSeconds float64 `json:"start_time_seconds"`
EndTimeSeconds float64 `json:"end_time_seconds"`
ThroughputMbits float64 `json:"throughput_mbits"`
}
type speedtestTableItem struct {
Interval string `table:"Interval,nosort"`
Throughput string `table:"Throughput"`
}
func (r *RootCmd) speedtest() *serpent.Command {
var (
direct bool
duration time.Duration
direction string
pcapFile string
formatter = cliui.NewOutputFormatter(
cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) {
res, ok := data.(SpeedtestResult)
if !ok {
// This should never happen
return "", xerrors.Errorf("expected speedtestResult, got %T", data)
}
tableRows := make([]any, len(res.Intervals)+2)
for i, r := range res.Intervals {
tableRows[i] = speedtestTableItem{
Interval: fmt.Sprintf("%.2f-%.2f sec", r.StartTimeSeconds, r.EndTimeSeconds),
Throughput: fmt.Sprintf("%.4f Mbits/sec", r.ThroughputMbits),
}
}
tableRows[len(res.Intervals)] = cliui.TableSeparator{}
tableRows[len(res.Intervals)+1] = speedtestTableItem{
Interval: fmt.Sprintf("%.2f-%.2f sec", res.Overall.StartTimeSeconds, res.Overall.EndTimeSeconds),
Throughput: fmt.Sprintf("%.4f Mbits/sec", res.Overall.ThroughputMbits),
}
return tableRows, nil
}),
cliui.JSONFormat(),
)
)
client := new(codersdk.Client)
cmd := &serpent.Command{
@@ -102,9 +64,6 @@ func (r *RootCmd) speedtest() *serpent.Command {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
opts.BlockEndpoints = true
}
if !r.disableNetworkTelemetry {
opts.EnableTelemetry = true
}
if pcapFile != "" {
s := capture.New()
opts.CaptureHook = s.LogPacket
@@ -142,14 +101,14 @@ func (r *RootCmd) speedtest() *serpent.Command {
}
peer := status.Peer[status.Peers()[0]]
if !p2p && direct {
cliui.Infof(inv.Stderr, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay)
cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay)
continue
}
via := peer.Relay
if via == "" {
via = "direct"
}
cliui.Infof(inv.Stderr, "%dms via %s", dur.Milliseconds(), via)
cliui.Infof(inv.Stdout, "%dms via %s", dur.Milliseconds(), via)
break
}
} else {
@@ -165,33 +124,24 @@ func (r *RootCmd) speedtest() *serpent.Command {
default:
return xerrors.Errorf("invalid direction: %q", direction)
}
cliui.Infof(inv.Stderr, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
results, err := conn.Speedtest(ctx, tsDir, duration)
if err != nil {
return err
}
var outputResult SpeedtestResult
tableWriter := cliui.Table()
tableWriter.AppendHeader(table.Row{"Interval", "Throughput"})
startTime := results[0].IntervalStart
outputResult.Intervals = make([]SpeedtestResultInterval, len(results)-1)
for i, r := range results {
interval := SpeedtestResultInterval{
StartTimeSeconds: r.IntervalStart.Sub(startTime).Seconds(),
EndTimeSeconds: r.IntervalEnd.Sub(startTime).Seconds(),
ThroughputMbits: r.MBitsPerSecond(),
}
for _, r := range results {
if r.Total {
interval.StartTimeSeconds = 0
outputResult.Overall = interval
} else {
outputResult.Intervals[i] = interval
tableWriter.AppendSeparator()
}
tableWriter.AppendRow(table.Row{
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
})
}
conn.Conn.SendSpeedtestTelemetry(outputResult.Overall.ThroughputMbits)
out, err := formatter.Format(inv.Context(), outputResult)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
_, err = fmt.Fprintln(inv.Stdout, tableWriter.Render())
return err
},
}
@@ -223,6 +173,5 @@ func (r *RootCmd) speedtest() *serpent.Command {
Value: serpent.StringOf(&pcapFile),
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
-45
View File
@@ -1,9 +1,7 @@
package cli_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
@@ -12,7 +10,6 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
@@ -59,45 +56,3 @@ func TestSpeedtest(t *testing.T) {
})
<-cmdDone
}
func TestSpeedtestJson(t *testing.T) {
t.Parallel()
t.Skip("Potentially flaky test - see https://github.com/coder/coder/issues/6321")
if testing.Short() {
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
}
client, workspace, agentToken := setupWorkspaceForAgent(t)
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
require.Eventually(t, func() bool {
ws, err := client.Workspace(ctx, workspace.ID)
if !assert.NoError(t, err) {
return false
}
a := ws.LatestBuild.Resources[0].Agents[0]
return a.Status == codersdk.WorkspaceAgentConnected &&
a.LifecycleState == codersdk.WorkspaceAgentLifecycleReady
}, testutil.WaitLong, testutil.IntervalFast, "agent is not ready")
inv, root := clitest.New(t, "speedtest", "--output=json", workspace.Name)
clitest.SetupConfig(t, client, root)
out := bytes.NewBuffer(nil)
inv.Stdout = out
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv.Logger = slogtest.Make(t, nil).Named("speedtest").Leveled(slog.LevelDebug)
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
<-cmdDone
var result cli.SpeedtestResult
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
require.Len(t, result.Intervals, 5)
}
+4 -45
View File
@@ -12,7 +12,6 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -41,10 +40,6 @@ import (
"github.com/coder/serpent"
)
const (
disableUsageApp = "disable"
)
var (
workspacePollInterval = time.Minute
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
@@ -62,7 +57,6 @@ func (r *RootCmd) ssh() *serpent.Command {
logDirPath string
remoteForwards []string
env []string
usageApp string
disableAutostart bool
)
client := new(codersdk.Client)
@@ -243,9 +237,8 @@ func (r *RootCmd) ssh() *serpent.Command {
}
conn, err := workspacesdk.New(client).
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
Logger: logger,
BlockEndpoints: r.disableDirect,
EnableTelemetry: !r.disableNetworkTelemetry,
Logger: logger,
BlockEndpoints: r.disableDirect,
})
if err != nil {
return xerrors.Errorf("dial agent: %w", err)
@@ -258,15 +251,6 @@ func (r *RootCmd) ssh() *serpent.Command {
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
defer stopPolling()
usageAppName := getUsageAppName(usageApp)
if usageAppName != "" {
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: workspaceAgent.ID,
AppName: usageAppName,
})
defer closeUsage()
}
if stdio {
rawSSH, err := conn.SSH(ctx)
if err != nil {
@@ -437,7 +421,6 @@ func (r *RootCmd) ssh() *serpent.Command {
}
err = sshSession.Wait()
conn.SendDisconnectedTelemetry()
if err != nil {
if exitErr := (&gossh.ExitError{}); errors.As(err, &exitErr) {
// Clear the error since it's not useful beyond
@@ -526,13 +509,6 @@ func (r *RootCmd) ssh() *serpent.Command {
FlagShorthand: "e",
Value: serpent.StringArrayOf(&env),
},
{
Flag: "usage-app",
Description: "Specifies the usage app to use for workspace activity tracking.",
Env: "CODER_SSH_USAGE_APP",
Value: serpent.StringOf(&usageApp),
Hidden: true,
},
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
}
return cmd
@@ -735,12 +711,12 @@ func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, work
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
conditionCtx, cancelCondition := context.WithCancel(ctx)
condition := notifyCondition(conditionCtx, client, workspace.ID, lock)
notifier := notify.New(condition, workspacePollInterval, autostopNotifyCountdown)
stopFunc := notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...)
return func() {
// With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled.
// Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely.
cancelCondition()
notifier.Close()
stopFunc()
}
}
@@ -1068,20 +1044,3 @@ func (r stdioErrLogReader) Read(_ []byte) (int, error) {
r.l.Error(context.Background(), "reading from stdin in stdio mode is not allowed")
return 0, io.EOF
}
func getUsageAppName(usageApp string) codersdk.UsageAppName {
if usageApp == disableUsageApp {
return ""
}
allowedUsageApps := []string{
string(codersdk.UsageAppNameSSH),
string(codersdk.UsageAppNameVscode),
string(codersdk.UsageAppNameJetbrains),
}
if slices.Contains(allowedUsageApps, usageApp) {
return codersdk.UsageAppName(usageApp)
}
return codersdk.UsageAppNameSSH
}
+3 -114
View File
@@ -36,7 +36,6 @@ import (
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -44,7 +43,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspacestats/workspacestatstest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -108,7 +106,7 @@ func TestSSH(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
@@ -166,7 +164,7 @@ func TestSSH(t *testing.T) {
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID)
template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
@@ -373,7 +371,7 @@ func TestSSH(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
@@ -1294,115 +1292,6 @@ func TestSSH(t *testing.T) {
require.NoError(t, err)
require.Len(t, ents, 1, "expected one file in logdir %s", logDir)
})
t.Run("UpdateUsage", func(t *testing.T) {
t.Parallel()
type testCase struct {
name string
experiment bool
usageAppName string
expectedCalls int
expectedCountSSH int
expectedCountJetbrains int
expectedCountVscode int
}
tcs := []testCase{
{
name: "NoExperiment",
},
{
name: "Empty",
experiment: true,
expectedCalls: 1,
expectedCountSSH: 1,
},
{
name: "SSH",
experiment: true,
usageAppName: "ssh",
expectedCalls: 1,
expectedCountSSH: 1,
},
{
name: "Jetbrains",
experiment: true,
usageAppName: "jetbrains",
expectedCalls: 1,
expectedCountJetbrains: 1,
},
{
name: "Vscode",
experiment: true,
usageAppName: "vscode",
expectedCalls: 1,
expectedCountVscode: 1,
},
{
name: "InvalidDefaultsToSSH",
experiment: true,
usageAppName: "invalid",
expectedCalls: 1,
expectedCountSSH: 1,
},
{
name: "Disable",
experiment: true,
usageAppName: "disable",
},
}
for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
if tc.experiment {
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)}
}
batcher := &workspacestatstest.StatsBatcher{
LastStats: &agentproto.Stats{},
}
admin, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: dv,
StatsBatcher: batcher,
})
admin.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
first := coderdtest.CreateFirstUser(t, admin)
client, user := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
OrganizationID: first.OrganizationID,
OwnerID: user.ID,
}).WithAgent().Do()
workspace := r.Workspace
agentToken := r.AgentToken
inv, root := clitest.New(t, "ssh", workspace.Name, fmt.Sprintf("--usage-app=%s", tc.usageAppName))
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
pty.ExpectMatch("Waiting")
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
pty.WriteLine("exit")
<-cmdDone
require.EqualValues(t, tc.expectedCalls, batcher.Called)
require.EqualValues(t, tc.expectedCountSSH, batcher.LastStats.SessionCountSsh)
require.EqualValues(t, tc.expectedCountJetbrains, batcher.LastStats.SessionCountJetbrains)
require.EqualValues(t, tc.expectedCountVscode, batcher.LastStats.SessionCountVscode)
})
}
})
}
//nolint:paralleltest // This test uses t.Setenv, parent test MUST NOT be parallel.
+5 -5
View File
@@ -109,7 +109,7 @@ func TestStart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
@@ -163,7 +163,7 @@ func TestStart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
@@ -211,7 +211,7 @@ func TestStartWithParameters(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, immutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: immutableParameterName,
@@ -263,7 +263,7 @@ func TestStartWithParameters(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: mutableParameterName,
@@ -349,7 +349,7 @@ func TestStartAutoUpdate(t *testing.T) {
version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
+3 -3
View File
@@ -100,7 +100,7 @@ func TestStatePush(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
stateFile, err := os.CreateTemp(t.TempDir(), "")
require.NoError(t, err)
@@ -126,7 +126,7 @@ func TestStatePush(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "state", "push", "--build", strconv.Itoa(int(workspace.LatestBuild.BuildNumber)), workspace.Name, "-")
clitest.SetupConfig(t, templateAdmin, root)
@@ -146,7 +146,7 @@ func TestStatePush(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "state", "push",
"--build", strconv.Itoa(int(workspace.LatestBuild.BuildNumber)),
-1
View File
@@ -254,7 +254,6 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
"deployment/health.json": src.Deployment.HealthReport,
"network/connection_info.json": src.Network.ConnectionInfo,
"network/netcheck.json": src.Network.Netcheck,
"network/interfaces.json": src.Network.Interfaces,
"workspace/template.json": src.Workspace.Template,
"workspace/template_version.json": src.Workspace.TemplateVersion,
"workspace/parameters.json": src.Workspace.Parameters,
-4
View File
@@ -197,10 +197,6 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
var v derphealth.Report
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "netcheck should not be empty")
case "network/interfaces.json":
var v healthsdk.InterfacesReport
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "interfaces should not be empty")
case "workspace/workspace.json":
var v codersdk.Workspace
decodeJSONFromZip(t, f, &v)
+5 -7
View File
@@ -31,7 +31,6 @@ func (r *RootCmd) templateCreate() *serpent.Command {
dormancyAutoDeletion time.Duration
uploadFlags templateUploadFlags
orgContext = NewOrganizationContext()
)
client := new(codersdk.Client)
cmd := &serpent.Command{
@@ -69,7 +68,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
}
}
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -97,7 +96,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
var varsFiles []string
if !uploadFlags.stdin() {
varsFiles, err = codersdk.DiscoverVarsFiles(uploadFlags.directory)
varsFiles, err = DiscoverVarsFiles(uploadFlags.directory)
if err != nil {
return err
}
@@ -118,7 +117,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
return err
}
userVariableValues, err := codersdk.ParseUserVariableValues(
userVariableValues, err := ParseUserVariableValues(
varsFiles,
variablesFile,
commandLineVariables)
@@ -160,7 +159,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
RequireActiveVersion: requireActiveVersion,
}
template, err := client.CreateTemplate(inv.Context(), organization.ID, createReq)
_, err = client.CreateTemplate(inv.Context(), organization.ID, createReq)
if err != nil {
return err
}
@@ -171,7 +170,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+
"Developers can provision a workspace with this template using:")+"\n")
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q --org=%q [workspace name]", templateName, template.OrganizationName)))
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
_, _ = fmt.Fprintln(inv.Stdout)
return nil
@@ -244,7 +243,6 @@ func (r *RootCmd) templateCreate() *serpent.Command {
cliui.SkipPromptOption(),
}
orgContext.AttachOptions(cmd)
cmd.Options = append(cmd.Options, uploadFlags.options()...)
return cmd
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestCliTemplateCreate(t *testing.T) {
func TestTemplateCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
+1 -3
View File
@@ -15,7 +15,6 @@ import (
)
func (r *RootCmd) templateDelete() *serpent.Command {
orgContext := NewOrganizationContext()
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "delete [name...]",
@@ -33,7 +32,7 @@ func (r *RootCmd) templateDelete() *serpent.Command {
templates = []codersdk.Template{}
)
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -82,7 +81,6 @@ func (r *RootCmd) templateDelete() *serpent.Command {
return nil
},
}
orgContext.AttachOptions(cmd)
return cmd
}
+1 -3
View File
@@ -36,7 +36,6 @@ func (r *RootCmd) templateEdit() *serpent.Command {
requireActiveVersion bool
deprecationMessage string
disableEveryone bool
orgContext = NewOrganizationContext()
)
client := new(codersdk.Client)
@@ -78,7 +77,7 @@ func (r *RootCmd) templateEdit() *serpent.Command {
}
}
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
@@ -325,7 +324,6 @@ func (r *RootCmd) templateEdit() *serpent.Command {
},
cliui.SkipPromptOption(),
}
orgContext.AttachOptions(cmd)
return cmd
}
+7 -3
View File
@@ -12,7 +12,7 @@ import (
func (r *RootCmd) templateList() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.TableFormat([]templateTableRow{}, []string{"name", "organization name", "last updated", "used by"}),
cliui.TableFormat([]templateTableRow{}, []string{"name", "last updated", "used by"}),
cliui.JSONFormat(),
)
@@ -25,13 +25,17 @@ func (r *RootCmd) templateList() *serpent.Command {
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
if err != nil {
return err
}
if len(templates) == 0 {
_, _ = fmt.Fprintf(inv.Stderr, "%s No templates found! Create one:\n\n", Caret)
_, _ = fmt.Fprintf(inv.Stderr, "%s No templates found in %s! Create one:\n\n", Caret, color.HiWhiteString(organization.Name))
_, _ = fmt.Fprintln(inv.Stderr, color.HiMagentaString(" $ coder templates push <directory>\n"))
return nil
}
+5 -1
View File
@@ -88,6 +88,9 @@ func TestTemplateList(t *testing.T) {
client := coderdtest.New(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, client)
org, err := client.Organization(context.Background(), owner.OrganizationID)
require.NoError(t, err)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
inv, root := clitest.New(t, "templates", "list")
@@ -107,7 +110,8 @@ func TestTemplateList(t *testing.T) {
require.NoError(t, <-errC)
pty.ExpectMatch("No templates found")
pty.ExpectMatch("No templates found in")
pty.ExpectMatch(org.Name)
pty.ExpectMatch("Create one:")
})
}
+1 -3
View File
@@ -20,7 +20,6 @@ func (r *RootCmd) templatePull() *serpent.Command {
tarMode bool
zipMode bool
versionName string
orgContext = NewOrganizationContext()
)
client := new(codersdk.Client)
@@ -46,7 +45,7 @@ func (r *RootCmd) templatePull() *serpent.Command {
return xerrors.Errorf("either tar or zip can be selected")
}
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
@@ -188,7 +187,6 @@ func (r *RootCmd) templatePull() *serpent.Command {
},
cliui.SkipPromptOption(),
}
orgContext.AttachOptions(cmd)
return cmd
}
+6 -17
View File
@@ -34,7 +34,6 @@ func (r *RootCmd) templatePush() *serpent.Command {
provisionerTags []string
uploadFlags templateUploadFlags
activate bool
orgContext = NewOrganizationContext()
)
client := new(codersdk.Client)
cmd := &serpent.Command{
@@ -47,7 +46,7 @@ func (r *RootCmd) templatePush() *serpent.Command {
Handler: func(inv *serpent.Invocation) error {
uploadFlags.setWorkdir(workdir)
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -81,7 +80,7 @@ func (r *RootCmd) templatePush() *serpent.Command {
var varsFiles []string
if !uploadFlags.stdin() {
varsFiles, err = codersdk.DiscoverVarsFiles(uploadFlags.directory)
varsFiles, err = DiscoverVarsFiles(uploadFlags.directory)
if err != nil {
return err
}
@@ -101,17 +100,7 @@ func (r *RootCmd) templatePush() *serpent.Command {
return err
}
// If user hasn't provided new provisioner tags, inherit ones from the active template version.
if len(tags) == 0 && template.ActiveVersionID != uuid.Nil {
templateVersion, err := client.TemplateVersion(inv.Context(), template.ActiveVersionID)
if err != nil {
return err
}
tags = templateVersion.Job.Tags
inv.Logger.Info(inv.Context(), "reusing existing provisioner tags", "tags", tags)
}
userVariableValues, err := codersdk.ParseUserVariableValues(
userVariableValues, err := ParseUserVariableValues(
varsFiles,
variablesFile,
commandLineVariables)
@@ -227,7 +216,6 @@ func (r *RootCmd) templatePush() *serpent.Command {
cliui.SkipPromptOption(),
}
cmd.Options = append(cmd.Options, uploadFlags.options()...)
orgContext.AttachOptions(cmd)
return cmd
}
@@ -419,8 +407,9 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat
if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
return nil, err
}
return nil, err
if err != nil {
return nil, err
}
}
version, err = client.TemplateVersion(inv.Context(), version.ID)
if err != nil {
-129
View File
@@ -403,135 +403,6 @@ func TestTemplatePush(t *testing.T) {
assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID)
})
t.Run("ProvisionerTags", func(t *testing.T) {
t.Parallel()
t.Run("ChangeTags", func(t *testing.T) {
t.Parallel()
// Start the first provisioner
client, provisionerDocker, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ProvisionerDaemonTags: map[string]string{
"docker": "true",
},
})
defer provisionerDocker.Close()
// Start the second provisioner
provisionerFoobar := coderdtest.NewTaggedProvisionerDaemon(t, api, "provisioner-foobar", map[string]string{
"foobar": "foobaz",
})
defer provisionerFoobar.Close()
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
// Create the template with initial tagged template version.
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.ProvisionerTags = map[string]string{
"docker": "true",
}
})
templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
// Push new template version without provisioner tags. CLI should reuse tags from the previous version.
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
})
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name,
"--provisioner-tag", "foobar=foobaz")
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
matches := []struct {
match string
write string
}{
{match: "Upload", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
}
require.NoError(t, <-execDone)
// Verify template version tags
template, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID)
require.NoError(t, err)
require.EqualValues(t, map[string]string{"foobar": "foobaz", "owner": "", "scope": "organization"}, templateVersion.Job.Tags)
})
t.Run("DoNotChangeTags", func(t *testing.T) {
t.Parallel()
// Start the tagged provisioner
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ProvisionerDaemonTags: map[string]string{
"docker": "true",
},
})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
// Create the template with initial tagged template version.
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.ProvisionerTags = map[string]string{
"docker": "true",
}
})
templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
// Push new template version without provisioner tags. CLI should reuse tags from the previous version.
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
})
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name)
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
matches := []struct {
match string
write string
}{
{match: "Upload", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
}
require.NoError(t, <-execDone)
// Verify template version tags
template, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID)
require.NoError(t, err)
require.EqualValues(t, map[string]string{"docker": "true", "owner": "", "scope": "organization"}, templateVersion.Job.Tags)
})
})
t.Run("Variables", func(t *testing.T) {
t.Parallel()
+21 -19
View File
@@ -17,6 +17,10 @@ func (r *RootCmd) templates() *serpent.Command {
Use: "templates",
Short: "Manage templates",
Long: "Templates are written in standard Terraform and describe the infrastructure for workspaces\n" + FormatExamples(
Example{
Description: "Make changes to your template, and plan the changes",
Command: "coder templates plan my-template",
},
Example{
Description: "Create or push an update to the template. Your developers can update their workspaces",
Command: "coder templates push my-template",
@@ -79,15 +83,14 @@ type templateTableRow struct {
Template codersdk.Template
// Used by table format:
Name string `json:"-" table:"name,default_sort"`
CreatedAt string `json:"-" table:"created at"`
LastUpdated string `json:"-" table:"last updated"`
OrganizationID uuid.UUID `json:"-" table:"organization id"`
OrganizationName string `json:"-" table:"organization name"`
Provisioner codersdk.ProvisionerType `json:"-" table:"provisioner"`
ActiveVersionID uuid.UUID `json:"-" table:"active version id"`
UsedBy string `json:"-" table:"used by"`
DefaultTTL time.Duration `json:"-" table:"default ttl"`
Name string `json:"-" table:"name,default_sort"`
CreatedAt string `json:"-" table:"created at"`
LastUpdated string `json:"-" table:"last updated"`
OrganizationID uuid.UUID `json:"-" table:"organization id"`
Provisioner codersdk.ProvisionerType `json:"-" table:"provisioner"`
ActiveVersionID uuid.UUID `json:"-" table:"active version id"`
UsedBy string `json:"-" table:"used by"`
DefaultTTL time.Duration `json:"-" table:"default ttl"`
}
// templateToRows converts a list of templates to a list of templateTableRow for
@@ -96,16 +99,15 @@ func templatesToRows(templates ...codersdk.Template) []templateTableRow {
rows := make([]templateTableRow, len(templates))
for i, template := range templates {
rows[i] = templateTableRow{
Template: template,
Name: template.Name,
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
LastUpdated: template.UpdatedAt.Format("January 2, 2006"),
OrganizationID: template.OrganizationID,
OrganizationName: template.OrganizationName,
Provisioner: template.Provisioner,
ActiveVersionID: template.ActiveVersionID,
UsedBy: pretty.Sprint(cliui.DefaultStyles.Fuchsia, formatActiveDevelopers(template.ActiveUserCount)),
DefaultTTL: (time.Duration(template.DefaultTTLMillis) * time.Millisecond),
Template: template,
Name: template.Name,
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
LastUpdated: template.UpdatedAt.Format("January 2, 2006"),
OrganizationID: template.OrganizationID,
Provisioner: template.Provisioner,
ActiveVersionID: template.ActiveVersionID,
UsedBy: pretty.Sprint(cliui.DefaultStyles.Fuchsia, formatActiveDevelopers(template.ActiveUserCount)),
DefaultTTL: (time.Duration(template.DefaultTTLMillis) * time.Millisecond),
}
}
@@ -1,4 +1,4 @@
package codersdk
package cli
import (
"encoding/json"
@@ -13,6 +13,8 @@ import (
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/zclconf/go-cty/cty"
"github.com/coder/coder/v2/codersdk"
)
/**
@@ -52,7 +54,7 @@ func DiscoverVarsFiles(workDir string) ([]string, error) {
return found, nil
}
func ParseUserVariableValues(varsFiles []string, variablesFile string, commandLineVariables []string) ([]VariableValue, error) {
func ParseUserVariableValues(varsFiles []string, variablesFile string, commandLineVariables []string) ([]codersdk.VariableValue, error) {
fromVars, err := parseVariableValuesFromVarsFiles(varsFiles)
if err != nil {
return nil, err
@@ -71,15 +73,15 @@ func ParseUserVariableValues(varsFiles []string, variablesFile string, commandLi
return combineVariableValues(fromVars, fromFile, fromCommandLine), nil
}
func parseVariableValuesFromVarsFiles(varsFiles []string) ([]VariableValue, error) {
var parsed []VariableValue
func parseVariableValuesFromVarsFiles(varsFiles []string) ([]codersdk.VariableValue, error) {
var parsed []codersdk.VariableValue
for _, varsFile := range varsFiles {
content, err := os.ReadFile(varsFile)
if err != nil {
return nil, err
}
var t []VariableValue
var t []codersdk.VariableValue
ext := filepath.Ext(varsFile)
switch ext {
case ".tfvars":
@@ -101,7 +103,7 @@ func parseVariableValuesFromVarsFiles(varsFiles []string) ([]VariableValue, erro
return parsed, nil
}
func parseVariableValuesFromHCL(content []byte) ([]VariableValue, error) {
func parseVariableValuesFromHCL(content []byte) ([]codersdk.VariableValue, error) {
parser := hclparse.NewParser()
hclFile, diags := parser.ParseHCL(content, "file.hcl")
if diags.HasErrors() {
@@ -157,7 +159,7 @@ func parseVariableValuesFromHCL(content []byte) ([]VariableValue, error) {
// parseVariableValuesFromJSON converts the .tfvars.json content into template variables.
// The function visits only root-level properties as template variables do not support nested
// structures.
func parseVariableValuesFromJSON(content []byte) ([]VariableValue, error) {
func parseVariableValuesFromJSON(content []byte) ([]codersdk.VariableValue, error) {
var data map[string]interface{}
err := json.Unmarshal(content, &data)
if err != nil {
@@ -181,10 +183,10 @@ func parseVariableValuesFromJSON(content []byte) ([]VariableValue, error) {
return convertMapIntoVariableValues(stringData), nil
}
func convertMapIntoVariableValues(m map[string]string) []VariableValue {
var parsed []VariableValue
func convertMapIntoVariableValues(m map[string]string) []codersdk.VariableValue {
var parsed []codersdk.VariableValue
for key, value := range m {
parsed = append(parsed, VariableValue{
parsed = append(parsed, codersdk.VariableValue{
Name: key,
Value: value,
})
@@ -195,8 +197,8 @@ func convertMapIntoVariableValues(m map[string]string) []VariableValue {
return parsed
}
func parseVariableValuesFromFile(variablesFile string) ([]VariableValue, error) {
var values []VariableValue
func parseVariableValuesFromFile(variablesFile string) ([]codersdk.VariableValue, error) {
var values []codersdk.VariableValue
if variablesFile == "" {
return values, nil
}
@@ -207,7 +209,7 @@ func parseVariableValuesFromFile(variablesFile string) ([]VariableValue, error)
}
for name, value := range variablesMap {
values = append(values, VariableValue{
values = append(values, codersdk.VariableValue{
Name: name,
Value: value,
})
@@ -235,15 +237,15 @@ func createVariablesMapFromFile(variablesFile string) (map[string]string, error)
return variablesMap, nil
}
func parseVariableValuesFromCommandLine(variables []string) ([]VariableValue, error) {
var values []VariableValue
func parseVariableValuesFromCommandLine(variables []string) ([]codersdk.VariableValue, error) {
var values []codersdk.VariableValue
for _, keyValue := range variables {
split := strings.SplitN(keyValue, "=", 2)
if len(split) < 2 {
return nil, xerrors.Errorf("format key=value expected, but got %s", keyValue)
}
values = append(values, VariableValue{
values = append(values, codersdk.VariableValue{
Name: split[0],
Value: split[1],
})
@@ -251,7 +253,7 @@ func parseVariableValuesFromCommandLine(variables []string) ([]VariableValue, er
return values, nil
}
func combineVariableValues(valuesSets ...[]VariableValue) []VariableValue {
func combineVariableValues(valuesSets ...[]codersdk.VariableValue) []codersdk.VariableValue {
combinedValues := make(map[string]string)
for _, values := range valuesSets {
@@ -260,9 +262,9 @@ func combineVariableValues(valuesSets ...[]VariableValue) []VariableValue {
}
}
var result []VariableValue
var result []codersdk.VariableValue
for name, value := range combinedValues {
result = append(result, VariableValue{Name: name, Value: value})
result = append(result, codersdk.VariableValue{Name: name, Value: value})
}
sort.Slice(result, func(i, j int) bool {
@@ -1,4 +1,4 @@
package codersdk_test
package cli_test
import (
"os"
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/codersdk"
)
@@ -46,7 +47,7 @@ func TestDiscoverVarsFiles(t *testing.T) {
}
// When
found, err := codersdk.DiscoverVarsFiles(tempDir)
found, err := cli.DiscoverVarsFiles(tempDir)
require.NoError(t, err)
// Then
@@ -96,7 +97,7 @@ go_image = ["1.19","1.20","1.21"]`
require.NoError(t, err)
// When
actual, err := codersdk.ParseUserVariableValues([]string{
actual, err := cli.ParseUserVariableValues([]string{
filepath.Join(tempDir, hclFilename1),
filepath.Join(tempDir, hclFilename2),
filepath.Join(tempDir, jsonFilename3),
@@ -135,7 +136,7 @@ func TestParseVariableValuesFromVarsFiles_InvalidJSON(t *testing.T) {
require.NoError(t, err)
// When
actual, err := codersdk.ParseUserVariableValues([]string{
actual, err := cli.ParseUserVariableValues([]string{
filepath.Join(tempDir, jsonFilename),
}, "", nil)
@@ -166,7 +167,7 @@ cores: 2`
require.NoError(t, err)
// When
actual, err := codersdk.ParseUserVariableValues([]string{
actual, err := cli.ParseUserVariableValues([]string{
filepath.Join(tempDir, hclFilename),
}, "", nil)
+3 -7
View File
@@ -31,7 +31,6 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *serpent.Command {
pastVerb = "unarchived"
}
orgContext := NewOrganizationContext()
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: presentVerb + " <template-name> [template-version-names...] ",
@@ -48,7 +47,7 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *serpent.Command {
versions []codersdk.TemplateVersion
)
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -93,7 +92,6 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *serpent.Command {
return nil
},
}
orgContext.AttachOptions(cmd)
return cmd
}
@@ -101,7 +99,6 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *serpent.Command {
func (r *RootCmd) archiveTemplateVersions() *serpent.Command {
var all serpent.Bool
client := new(codersdk.Client)
orgContext := NewOrganizationContext()
cmd := &serpent.Command{
Use: "archive [template-name...] ",
Short: "Archive unused or failed template versions from a given template(s)",
@@ -124,7 +121,7 @@ func (r *RootCmd) archiveTemplateVersions() *serpent.Command {
templates = []codersdk.Template{}
)
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@@ -169,7 +166,7 @@ func (r *RootCmd) archiveTemplateVersions() *serpent.Command {
inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)),
)
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok {
data, err := json.Marshal(resp)
if err != nil {
return xerrors.Errorf("marshal verbose response: %w", err)
@@ -182,7 +179,6 @@ func (r *RootCmd) archiveTemplateVersions() *serpent.Command {
return nil
},
}
orgContext.AttachOptions(cmd)
return cmd
}
+1 -3
View File
@@ -51,7 +51,6 @@ func (r *RootCmd) templateVersionsList() *serpent.Command {
cliui.JSONFormat(),
)
client := new(codersdk.Client)
orgContext := NewOrganizationContext()
var includeArchived serpent.Bool
@@ -94,7 +93,7 @@ func (r *RootCmd) templateVersionsList() *serpent.Command {
},
},
Handler: func(inv *serpent.Invocation) error {
organization, err := orgContext.Selected(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
@@ -123,7 +122,6 @@ func (r *RootCmd) templateVersionsList() *serpent.Command {
},
}
orgContext.AttachOptions(cmd)
formatter.AttachOptions(&cmd.Options)
return cmd
}
-9
View File
@@ -27,7 +27,6 @@ SUBCOMMANDS:
login Authenticate with Coder deployment
logout Unauthenticate your local session
netcheck Print network debug information for DERP and STUN
notifications Manage Coder notifications
open Open a workspace
ping Ping a workspace
port-forward Forward ports from a workspace to the local machine. For
@@ -56,7 +55,6 @@ SUBCOMMANDS:
date
users Manage users
version Show coder version
whoami Fetch authenticated user info for Coder deployment
GLOBAL OPTIONS:
Global options are applied to all commands. They can be set using environment
@@ -68,13 +66,6 @@ variables or flags.
--disable-direct-connections bool, $CODER_DISABLE_DIRECT_CONNECTIONS
Disable direct (P2P) connections to workspaces.
--disable-network-telemetry bool, $CODER_DISABLE_NETWORK_TELEMETRY
Disable network telemetry. Network telemetry is collected when
connecting to workspaces using the CLI, and is forwarded to the
server. If telemetry is also enabled on the server, it may be sent to
Coder. Network telemetry is used to measure network quality and detect
regressions.
--global-config string, $CODER_CONFIG_DIR (default: ~/.config/coderv2)
Path to the global `coder` config directory.
-3
View File
@@ -18,9 +18,6 @@ OPTIONS:
--auth string, $CODER_AGENT_AUTH (default: token)
Specify the authentication type to use for the agent.
--block-file-transfer bool, $CODER_AGENT_BLOCK_FILE_TRANSFER (default: false)
Block file transfer using known applications: nc,rsync,scp,sftp.
--debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113)
The bind address to serve a debug HTTP server.
-3
View File
@@ -10,9 +10,6 @@ USAGE:
$ coder create <username>/<workspace_name>
OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
--automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never)
Specify automatic updates setting for the workspace (accepts 'always'
or 'never').
+2 -3
View File
@@ -13,9 +13,8 @@ OPTIONS:
-c, --column string-array (default: workspace,template,status,healthy,last built,current version,outdated,starts at,stops after)
Columns to display in table output. Available columns: favorite,
workspace, organization id, organization name, template, status,
healthy, last built, current version, outdated, starts at, starts
next, stops after, stops next, daily cost.
workspace, template, status, healthy, last built, current version,
outdated, starts at, starts next, stops after, stops next, daily cost.
-o, --output string (default: table)
Output format. Available formats: table, json.
-1
View File
@@ -7,7 +7,6 @@
"owner_name": "testuser",
"owner_avatar_url": "",
"organization_id": "[first org ID]",
"organization_name": "first-organization",
"template_id": "[template ID]",
"template_name": "test-template",
"template_display_name": "",
-3
View File
@@ -10,9 +10,6 @@ OPTIONS:
Specifies an email address to use if creating the first user for the
deployment.
--first-user-full-name string, $CODER_FIRST_USER_FULL_NAME
Specifies a human-readable name for the first user of the deployment.
--first-user-password string, $CODER_FIRST_USER_PASSWORD
Specifies a password to use if creating the first user for the
deployment.
-28
View File
@@ -1,28 +0,0 @@
coder v0.0.0-devel
USAGE:
coder notifications
Manage Coder notifications
Aliases: notification
Administrators can use these commands to change notification settings.
- Pause Coder notifications. Administrators can temporarily stop notifiers
from
dispatching messages in case of the target outage (for example: unavailable
SMTP
server or Webhook not responding).:
$ coder notifications pause
- Resume Coder notifications:
$ coder notifications resume
SUBCOMMANDS:
pause Pause notifications
resume Resume notifications
———
Run `coder --help` for a list of global options.
-9
View File
@@ -1,9 +0,0 @@
coder v0.0.0-devel
USAGE:
coder notifications pause
Pause notifications
———
Run `coder --help` for a list of global options.
-9
View File
@@ -1,9 +0,0 @@
coder v0.0.0-devel
USAGE:
coder notifications resume
Resume notifications
———
Run `coder --help` for a list of global options.

Some files were not shown because too many files have changed in this diff Show More