Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d65eea8132 | |||
| 2f0c2d77dc | |||
| 82ed9e4dc7 | |||
| 534d4ea752 | |||
| 8ce8700424 | |||
| b9779af5b2 | |||
| e54ff57a9a | |||
| ae220f52e7 | |||
| 90f82da311 | |||
| 201cb1cbed | |||
| b701620a01 | |||
| 0703fc6888 | |||
| a9e5648557 | |||
| 3fbfb534d0 | |||
| 5e69a9d18b | |||
| ba0bf43de4 | |||
| 40af6206cc |
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -19,7 +19,6 @@ on:
|
||||
|
||||
jobs:
|
||||
build_image:
|
||||
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -68,6 +68,3 @@ result
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
[](https://discord.gg/coder)
|
||||
[](https://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder/v2)
|
||||
[](https://goreportcard.com/report/github.com/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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -13,7 +13,6 @@ func (r *RootCmd) expCmd() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.scaletestCmd(),
|
||||
r.errorExample(),
|
||||
r.promptExample(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
+27
-32
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
-9
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -1,9 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder notifications pause
|
||||
|
||||
Pause notifications
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -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
Reference in New Issue
Block a user