Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67952cf95e | |||
| 269e0b3261 | |||
| 3861d1c555 | |||
| bef6f67b70 | |||
| e6072eff59 | |||
| f9f7283e16 | |||
| cd1a2d2d5d | |||
| f5a7538637 | |||
| a5073a8770 | |||
| 575bfabfcb | |||
| 41b58cd027 | |||
| c7e1ecfe36 | |||
| 1df72ee093 | |||
| c0d9e32300 | |||
| 627fbe5874 | |||
| a5d39adf3e | |||
| 8e4af79cb2 | |||
| e72a2ad907 | |||
| 69241d06e7 | |||
| d9436fab69 | |||
| 8e9cbdd71b | |||
| 84120767a7 | |||
| 5a3985e6be | |||
| 41cefef95a | |||
| 370934afdf | |||
| 2296432e8b | |||
| 01652e8afb | |||
| f5d623ff3f | |||
| d5ab06ed68 | |||
| 0171ccbf62 | |||
| efee03fdec | |||
| 56a69b7eea | |||
| 19ae42af53 | |||
| f96365a181 | |||
| dda8170427 | |||
| 4f3ac95a39 | |||
| 2effea5806 | |||
| d34540ca30 | |||
| d2ef727064 | |||
| a23a471034 | |||
| bbe33fef41 | |||
| 52d7dfa253 | |||
| 9f6edab53b | |||
| fa7deaaa5c | |||
| f70726b43c | |||
| fe16b2a06d | |||
| 7bcbf197c1 | |||
| 68324c7263 | |||
| eb8d5b4408 | |||
| aec15905b5 | |||
| 70d71bc7bc | |||
| 34225b0380 | |||
| 6807ad0d1b | |||
| a4ca8ffa65 | |||
| 888766c10d | |||
| 9b602f55e0 | |||
| 763147e5f2 | |||
| 242676bac3 | |||
| aa68e0f8c9 | |||
| f1fe2b5c06 | |||
| 59e919ab4a | |||
| 421e529763 | |||
| bb03df8148 | |||
| 0d30a1eb72 | |||
| 8ee3e2c541 | |||
| 5a968e2f93 | |||
| ab7e676b54 | |||
| dcf6c20132 | |||
| 66fa2a1a8c | |||
| e6b17b6ea7 | |||
| 0124289f1a | |||
| 04d45f3c1c | |||
| 24592332e2 | |||
| 2db9df4491 | |||
| c0dfbdf143 | |||
| 0b63825a07 | |||
| a231c1a384 | |||
| c51b5a05db | |||
| 0dba2defd1 | |||
| 175be621cf | |||
| de0601d611 | |||
| 8968a00035 | |||
| ebe1b56c08 | |||
| a36cd0bd7b | |||
| 925b29836c | |||
| 91a4c2dce1 | |||
| 5e540e3439 | |||
| 4e14cc5207 | |||
| c5128db484 | |||
| 3e2477f255 | |||
| ed114ec341 | |||
| f1419bbc49 | |||
| e67d131514 | |||
| 829cfee29d | |||
| 5e36fd522c | |||
| 3969a8b58b | |||
| 856f0ab6f5 | |||
| 5435bceaf0 | |||
| 8bb7e17bf1 | |||
| d124fab642 | |||
| 4b093115e2 | |||
| 05dc83e522 | |||
| b6dab5fbf7 | |||
| 54eb6a5b42 | |||
| 8d254bd94e | |||
| f711abb236 | |||
| 86c1753e2b | |||
| 26b54cd144 | |||
| 3e2e2ac49e | |||
| 461c0d0d39 | |||
| 341c4329f4 | |||
| c8f34bbad7 | |||
| 418022943a | |||
| cfd02d959c | |||
| c505e8b207 | |||
| 43b61ce33c | |||
| bae69df8f9 | |||
| ac27cf8c07 | |||
| 308a0602b6 | |||
| 0eb25306ad | |||
| 8d9528545a | |||
| 2bbeff53f9 | |||
| 935bb99bed | |||
| 2ac31684f4 | |||
| c5cfefe3b2 | |||
| c7ce3e70da | |||
| 50dfc2082b | |||
| 86257ce7fc | |||
| ca31f1b782 | |||
| a7e8f98e33 | |||
| e3cf759968 | |||
| 1bc4eb5329 | |||
| e359f3cd23 | |||
| dc6d271293 | |||
| f239ca7ee3 | |||
| 9983c07e13 | |||
| 5a786edc3d | |||
| e61234f260 | |||
| d1f8fec1d3 | |||
| 88d3496a99 | |||
| a19c6fc988 | |||
| e76f947da2 | |||
| 0c0e3f0e4d | |||
| fcd5511403 | |||
| ffb8df9655 | |||
| e2aec2709b | |||
| 79c71d2d2c | |||
| fceac39143 | |||
| 31d38d4246 | |||
| 787b8b2a51 | |||
| 44c10bbe3c | |||
| 6b6eac2518 | |||
| 306fe4a91b | |||
| e96fdbed26 | |||
| 4bc420dc48 | |||
| 25ebebac5f | |||
| d170d27e80 | |||
| 8bc247d0c9 | |||
| 84995b7320 | |||
| c0b251ac52 | |||
| b39ba02bf0 | |||
| 27386d49d0 | |||
| 012a9e759e | |||
| 8e702d89bb | |||
| b103685170 | |||
| ad0dd1be5d | |||
| 663f7a3f12 | |||
| 2a4ef38a4f | |||
| 90b0adabc1 | |||
| ec2293a4e4 | |||
| 1a018c571b | |||
| f7baf45ae3 | |||
| 5a568d8a9b | |||
| 8df02f42c0 | |||
| 4fc4c01cea | |||
| 560c8ce0f6 | |||
| 50d1c7191a | |||
| 1c42a20865 | |||
| d72d312e1f | |||
| a071bfa8aa | |||
| 40a5c0476f | |||
| 760419a965 | |||
| 08a6a18226 | |||
| e7fc21e285 | |||
| 2b864cee9e |
+33
-33
@@ -6,27 +6,27 @@ ENV EDITOR=vim
|
||||
RUN apt-get update && apt-get upgrade --yes
|
||||
|
||||
RUN apt-get install --yes \
|
||||
ca-certificates \
|
||||
bash-completion \
|
||||
build-essential \
|
||||
curl \
|
||||
cmake \
|
||||
direnv \
|
||||
emacs-nox \
|
||||
gnupg \
|
||||
htop \
|
||||
jq \
|
||||
less \
|
||||
lsb-release \
|
||||
lsof \
|
||||
man-db \
|
||||
nano \
|
||||
neovim \
|
||||
ssl-cert \
|
||||
sudo \
|
||||
unzip \
|
||||
xz-utils \
|
||||
zip
|
||||
ca-certificates \
|
||||
bash-completion \
|
||||
build-essential \
|
||||
curl \
|
||||
cmake \
|
||||
direnv \
|
||||
emacs-nox \
|
||||
gnupg \
|
||||
htop \
|
||||
jq \
|
||||
less \
|
||||
lsb-release \
|
||||
lsof \
|
||||
man-db \
|
||||
nano \
|
||||
neovim \
|
||||
ssl-cert \
|
||||
sudo \
|
||||
unzip \
|
||||
xz-utils \
|
||||
zip
|
||||
|
||||
# configure locales to UTF8
|
||||
RUN apt-get install locales && locale-gen en_US.UTF-8
|
||||
@@ -39,22 +39,22 @@ RUN direnv hook bash >> $HOME/.bashrc
|
||||
RUN sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
|
||||
RUN mkdir -p $HOME/.config/nix $HOME/.config/nixpkgs \
|
||||
&& echo 'sandbox = false' >> $HOME/.config/nix/nix.conf \
|
||||
&& echo '{ allowUnfree = true; }' >> $HOME/.config/nixpkgs/config.nix \
|
||||
&& echo '. $HOME/.nix-profile/etc/profile.d/nix.sh' >> $HOME/.bashrc
|
||||
&& echo 'sandbox = false' >> $HOME/.config/nix/nix.conf \
|
||||
&& echo '{ allowUnfree = true; }' >> $HOME/.config/nixpkgs/config.nix \
|
||||
&& echo '. $HOME/.nix-profile/etc/profile.d/nix.sh' >> $HOME/.bashrc
|
||||
|
||||
|
||||
# install docker and configure daemon to use vfs as GitHub codespaces requires vfs
|
||||
# https://github.com/moby/moby/issues/13742#issuecomment-725197223
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes docker-ce docker-ce-cli containerd.io docker-compose-plugin \
|
||||
&& mkdir -p /etc/docker \
|
||||
&& echo '{"cgroup-parent":"/actions_job","storage-driver":"vfs"}' >> /etc/docker/daemon.json
|
||||
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes docker-ce docker-ce-cli containerd.io docker-compose-plugin \
|
||||
&& mkdir -p /etc/docker \
|
||||
&& echo '{"cgroup-parent":"/actions_job","storage-driver":"vfs"}' >> /etc/docker/daemon.json
|
||||
|
||||
# install golang and language tooling
|
||||
ENV GO_VERSION=1.19
|
||||
@@ -67,6 +67,7 @@ RUN echo 'export PATH=$GOPATH/bin:$PATH' >> $HOME/.bashrc
|
||||
RUN bash -c ". $HOME/.bashrc \
|
||||
go install -v golang.org/x/tools/gopls@latest \
|
||||
&& go install -v mvdan.cc/sh/v3/cmd/shfmt@latest \
|
||||
&& go install -v github.com/mikefarah/yq/v4@v4.30.6 \
|
||||
"
|
||||
|
||||
# install nodejs
|
||||
@@ -80,4 +81,3 @@ RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/horta/zstd.install/m
|
||||
RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list \
|
||||
&& apt update \
|
||||
&& apt install nfpm
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json
|
||||
{
|
||||
"name": "Development environments on your infrastructure",
|
||||
"name": "Development environments on your infrastructure",
|
||||
|
||||
// Sets the run context to one level up instead of the .devcontainer folder.
|
||||
"context": ".",
|
||||
// Sets the run context to one level up instead of the .devcontainer folder.
|
||||
"context": ".",
|
||||
|
||||
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
|
||||
"dockerFile": "Dockerfile",
|
||||
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
|
||||
"dockerFile": "Dockerfile",
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
"postStartCommand": "dockerd",
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// privileged is required by GitHub codespaces - https://github.com/microsoft/vscode-dev-containers/issues/727
|
||||
"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--privileged", "--init" ]
|
||||
"postStartCommand": "dockerd",
|
||||
|
||||
// privileged is required by GitHub codespaces - https://github.com/microsoft/vscode-dev-containers/issues/727
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined",
|
||||
"--privileged",
|
||||
"--init"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
|
||||
[*.{md,json,yaml,yml,tf,tfvars}]
|
||||
[*.{md,json,yaml,yml,tf,tfvars,nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ updates:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
- version-update:semver-patch
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
@@ -53,7 +53,7 @@ updates:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
- 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"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
###############################################################################
|
||||
# This file configures "Semantic Pull Requests", which is documented here:
|
||||
# https://github.com/zeke/semantic-pull-requests
|
||||
#
|
||||
# This action/spec implements the "Conventional Commits" RFC which is
|
||||
# available here:
|
||||
# https://www.notion.so/coderhq/Conventional-commits-1d51287f58b64026bb29393f277734ed
|
||||
###############################################################################
|
||||
|
||||
# We have no valid scopes right now.
|
||||
# A scope should be added when commits aren't aligning with associated change anymore.
|
||||
scopes:
|
||||
|
||||
# We only check that the PR title is semantic. The PR title is automatically
|
||||
# applied to the "Squash & Merge" flow as the suggested commit message, so this
|
||||
# should suffice unless someone drastically alters the message in that flow.
|
||||
titleOnly: true
|
||||
|
||||
# Types are the 'tag' types in a commit or PR title. For example, in
|
||||
#
|
||||
# chore: fix thing
|
||||
#
|
||||
# 'chore' is the type.
|
||||
types:
|
||||
# A build of any kind.
|
||||
- build
|
||||
|
||||
# Any code task that operates outside of CI, docs, or the product. Examples
|
||||
# include configurations, linters etc.
|
||||
- chore
|
||||
|
||||
# Any work performed on CI.
|
||||
- ci
|
||||
|
||||
- example
|
||||
|
||||
# Work that directly implements or supports the implementation of a feature.
|
||||
- feat
|
||||
|
||||
# A fix for either a released or unrelesed bug.
|
||||
- fix
|
||||
|
||||
# A fix for a released bug (regression fix) that is intended for patch-release
|
||||
# purposes.
|
||||
- hotfix
|
||||
|
||||
# A refactor changes code structure without any behavioral change.
|
||||
- refactor
|
||||
|
||||
# A git revert for any style of commit.
|
||||
- revert
|
||||
|
||||
# Adding tests of any kind. Should be separate from feature or fix
|
||||
# implementations. For example, if a commit adds a fix + test, it's a fix
|
||||
# commit. If a commit is simply bumping coverage, it's a test commit.
|
||||
- test
|
||||
@@ -3,7 +3,7 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened,closed,synchronize]
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN : ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
|
||||
with:
|
||||
remote-organization-name: 'coder'
|
||||
remote-repository-name: 'cla'
|
||||
path-to-signatures: 'v2022-09-04/signatures.json'
|
||||
path-to-document: 'https://github.com/coder/cla/blob/main/README.md'
|
||||
remote-organization-name: "coder"
|
||||
remote-repository-name: "cla"
|
||||
path-to-signatures: "v2022-09-04/signatures.json"
|
||||
path-to-document: "https://github.com/coder/cla/blob/main/README.md"
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
branch: "main"
|
||||
allowlist: dependabot*
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: "CodeQL"
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
# run every week at 10:24 on Thursday
|
||||
- cron: "24 10 * * 4"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["go", "javascript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Go Cache Paths
|
||||
if: matrix.language == 'go'
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Mod Cache
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Remove Makefile # workaround to prevent CodeQL from building site
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
# Disable Analysis step from trying to build the project.
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -222,6 +222,8 @@ jobs:
|
||||
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
- name: Install yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.30.6
|
||||
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
@@ -313,7 +315,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -338,8 +340,23 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=cover::false
|
||||
fi
|
||||
set -x
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotestsum.json" --packages="./..." --debug -- -parallel=8 -timeout=3m -short -failfast $COVERAGE_FLAGS
|
||||
set +e
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotestsum.json" --packages="./..." --debug -- -parallel=8 -timeout=5m -short -failfast $COVERAGE_FLAGS
|
||||
ret=$?
|
||||
if ((ret)); then
|
||||
# Eternalize test timeout logs because "re-run failed" erases
|
||||
# artifacts and gotestsum doesn't always capture it:
|
||||
# https://github.com/gotestyourself/gotestsum/issues/292
|
||||
# Multiple test packages could've failed, each one may or may
|
||||
# not run into the edge case. PS. Don't summon ShellCheck here.
|
||||
for testWithStack in $(grep 'panic: test timed out' gotestsum.json | grep -E -o '("Test":[^,}]*)'); do
|
||||
if [ -n "$testWithStack" ] && grep -q "${testWithStack}.*PASS" gotestsum.json; then
|
||||
echo "Conditions met for gotestsum stack trace missing bug, outputting panic trace:"
|
||||
grep -A 999999 "${testWithStack}.*panic: test timed out" gotestsum.json
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit $ret
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
@@ -348,6 +365,13 @@ jobs:
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotests-${{ matrix.os }}.xml
|
||||
path: ./gotests.xml
|
||||
retention-days: 30
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
# the `fail_ci_if_error` option that defaults to `false`, but
|
||||
@@ -394,7 +418,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -407,7 +431,24 @@ jobs:
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
run: make test-postgres
|
||||
run: |
|
||||
set +e
|
||||
make test-postgres
|
||||
ret=$?
|
||||
if ((ret)); then
|
||||
# Eternalize test timeout logs because "re-run failed" erases
|
||||
# artifacts and gotestsum doesn't always capture it:
|
||||
# https://github.com/gotestyourself/gotestsum/issues/292
|
||||
# Multiple test packages could've failed, each one may or may
|
||||
# not run into the edge case. PS. Don't summon ShellCheck here.
|
||||
for testWithStack in $(grep 'panic: test timed out' gotestsum.json | grep -E -o '("Test":[^,}]*)'); do
|
||||
if [ -n "$testWithStack" ] && grep -q "${testWithStack}.*PASS" gotestsum.json; then
|
||||
echo "Conditions met for gotestsum stack trace missing bug, outputting panic trace:"
|
||||
grep -A 999999 "${testWithStack}.*panic: test timed out" gotestsum.json
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit $ret
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
@@ -416,6 +457,13 @@ jobs:
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotests-postgres.xml
|
||||
path: ./gotests.xml
|
||||
retention-days: 30
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
# the `fail_ci_if_error` option that defaults to `false`, but
|
||||
@@ -451,7 +499,7 @@ jobs:
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
uses: google-github-actions/setup-gcloud@v1
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
@@ -690,18 +738,18 @@ jobs:
|
||||
markdown-link-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
# For the main branch:
|
||||
- if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
- uses: actions/checkout@master
|
||||
# For the main branch:
|
||||
- if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: yes
|
||||
use-verbose-mode: yes
|
||||
config-file: .github/workflows/mlc_config.json
|
||||
# For pull requests:
|
||||
- if: github.ref != 'refs/heads/main' || github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
# For pull requests:
|
||||
- if: github.ref != 'refs/heads/main' || github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: yes
|
||||
use-verbose-mode: yes
|
||||
check-modified-files-only: yes
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v6.3
|
||||
uses: tj-actions/branch-names@v6.4
|
||||
|
||||
- name: "Branch name to Docker tag name"
|
||||
id: docker-tag-name
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": ":\/\/localhost"
|
||||
},
|
||||
{
|
||||
"pattern": ":\/\/.*.?example\\.com"
|
||||
},
|
||||
{
|
||||
"pattern": "developer.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
}
|
||||
]
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "://.*.?example\\.com"
|
||||
},
|
||||
{
|
||||
"pattern": "developer.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,32 +15,41 @@ jobs:
|
||||
run: |
|
||||
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
|
||||
# the package version is the same as the release tag without the leading
|
||||
# "v", and with a trailing ".0" (e.g. "v1.2.3" -> "1.2.3.0")
|
||||
- name: Calculate package version
|
||||
id: version
|
||||
run: |
|
||||
$version = $env:CODER_VERSION -replace "^v", ""
|
||||
$version += ".0"
|
||||
echo "::set-output name=version::$version"
|
||||
|
||||
- name: Submit updated manifest to winget-pkgs
|
||||
run: |
|
||||
$release_assets = gh release view --repo coder/coder "$env:CODER_VERSION" --json assets | `
|
||||
ConvertFrom-Json
|
||||
|
||||
# Get the installer URL from the release assets.
|
||||
$installer_url = $release_assets.assets | `
|
||||
Where-Object name -Match ".*_windows_amd64_installer.exe$" | `
|
||||
Select -ExpandProperty url
|
||||
|
||||
echo "Installer URL: $installer_url"
|
||||
|
||||
# The package version is the same as the tag minus the leading "v".
|
||||
$version = $env:CODER_VERSION.Trim('v')
|
||||
|
||||
echo "Package version: $version"
|
||||
|
||||
# The URL "|X64" suffix forces the architecture as it cannot be
|
||||
# sniffed properly from the URL. wingetcreate checks both the URL and
|
||||
# binary magic bytes for the architecture and they need to both match,
|
||||
# but they only check for `x64`, `win64` and `_64` in the URL. Our URL
|
||||
# contains `amd64` which doesn't match sadly.
|
||||
#
|
||||
# wingetcreate will still do the binary magic bytes check, so if we
|
||||
# accidentally change the architecture of the installer, it will fail
|
||||
# submission.
|
||||
.\wingetcreate.exe update Coder.Coder `
|
||||
--submit `
|
||||
--version "${{ steps.version.outputs.version }}" `
|
||||
--urls "$installer_url" `
|
||||
--version "${version}" `
|
||||
--urls "${installer_url}|X64" `
|
||||
--token "${{ secrets.CDRCI_GITHUB_TOKEN }}"
|
||||
|
||||
env:
|
||||
# For gh CLI:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Comment on PR
|
||||
run: |
|
||||
# find the PR that wingetcreate just made
|
||||
@@ -48,4 +57,4 @@ jobs:
|
||||
ConvertFrom-Json`
|
||||
$pr_number = $pr_list[0].number
|
||||
|
||||
gh pr comment --repo microsoft/winget-pkgs "$pr_number" --body "🤖 cc: @deansheather"
|
||||
gh pr comment --repo microsoft/winget-pkgs "$pr_number" --body "🤖 cc: @deansheather @matifali"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Lint PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
requireScope: false
|
||||
+109
-12
@@ -1,19 +1,32 @@
|
||||
# GitHub release workflow.
|
||||
name: release
|
||||
name: Release
|
||||
run-name: Release ${{ github.ref_name }}${{ inputs.dry_run && ' (DRYRUN)' || '' }}
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
snapshot:
|
||||
description: Force a dev version to be generated, implies dry_run.
|
||||
increment:
|
||||
description: Preferred version increment (release script may promote e.g. patch to minor depending on changes).
|
||||
type: choice
|
||||
required: true
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
draft:
|
||||
description: Create a draft release (for manually editing release notes before publishing).
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
dry_run:
|
||||
description: Perform a dry-run release.
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
ignore_missing_commit_metadata:
|
||||
description: WARNING! This option disables the requirement that all commits have a PR. Not needed for dry_run.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
# Required to publish a release
|
||||
@@ -23,19 +36,36 @@ permissions:
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
id-token: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }}
|
||||
# Use `inputs` (vs `github.event.inputs`) to ensure that booleans are actual
|
||||
# booleans, not strings.
|
||||
# https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/
|
||||
CODER_RELEASE: ${{ !inputs.dry_run }}
|
||||
CODER_RELEASE_INCREMENT: ${{ inputs.increment }}
|
||||
CODER_RELEASE_DRAFT: ${{ inputs.draft }}
|
||||
CODER_DRY_RUN: ${{ inputs.dry_run }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
steps:
|
||||
- name: Check release on main (or dry-run)
|
||||
if: ${{ github.ref_name != 'main' && !inputs.dry_run }}
|
||||
run: |
|
||||
echo "Release not allowed on ${{ github.ref_name }}, use dry-run."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Set token for pushing protected tag (vX.X.X).
|
||||
token: ${{ secrets.RELEASE_GITHUB_PAT }}
|
||||
|
||||
# If the event that triggered the build was an annotated tag (which our
|
||||
# tags are supposed to be), actions/checkout has a bug where the tag in
|
||||
@@ -45,6 +75,59 @@ jobs:
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
# Configure git user name/email for creating annotated version tag.
|
||||
- name: Setup git config
|
||||
run: |
|
||||
git config user.name "Coder CI"
|
||||
git config user.email "dean+cdrci@coder.com"
|
||||
|
||||
- name: Create release tag and release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref=HEAD
|
||||
old_version="$(git describe --abbrev=0 "$ref^1")"
|
||||
|
||||
if [[ "${{ inputs.ignore_missing_commit_metadata }}" == *t* ]]; then
|
||||
export CODER_IGNORE_MISSING_COMMIT_METADATA=1
|
||||
fi
|
||||
|
||||
# Warn if CODER_IGNORE_MISSING_COMMIT_METADATA is set any other way
|
||||
# than via dry-run.
|
||||
if [[ ${CODER_IGNORE_MISSING_COMMIT_METADATA:-0} != 0 ]]; then
|
||||
echo "WARNING: CODER_IGNORE_MISSING_COMMIT_METADATA is enabled and we will ignore missing commit metadata." 1>&2
|
||||
fi
|
||||
|
||||
version_args=()
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
# Allow dry-run of branches to pass.
|
||||
export CODER_IGNORE_MISSING_COMMIT_METADATA=1
|
||||
version_args+=(--dry-run)
|
||||
fi
|
||||
|
||||
# Cache commit metadata.
|
||||
. ./scripts/release/check_commit_metadata.sh "$old_version" "$ref"
|
||||
|
||||
declare -p version_args
|
||||
|
||||
# Create new release tag (note that this tag is not pushed before
|
||||
# release.sh is run).
|
||||
version="$(
|
||||
./scripts/release/tag_version.sh \
|
||||
"${version_args[@]}" \
|
||||
--ref "$ref" \
|
||||
--"$CODER_RELEASE_INCREMENT"
|
||||
)"
|
||||
|
||||
# Generate notes.
|
||||
release_notes_file="$(mktemp -t release_notes.XXXXXX)"
|
||||
./scripts/release/generate_release_notes.sh --old-version "$old_version" --new-version "$version" --ref "$ref" >> "$release_notes_file"
|
||||
echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> $GITHUB_ENV
|
||||
|
||||
- name: Echo release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -157,8 +240,20 @@ jobs:
|
||||
|
||||
- name: Publish release
|
||||
run: |
|
||||
./scripts/publish_release.sh \
|
||||
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
|
||||
set -euo pipefail
|
||||
|
||||
publish_args=()
|
||||
if [[ $CODER_RELEASE_DRAFT == *t* ]]; then
|
||||
publish_args+=(--draft)
|
||||
fi
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
publish_args+=(--dry-run)
|
||||
fi
|
||||
declare -p publish_args
|
||||
|
||||
./scripts/release/publish.sh \
|
||||
"${publish_args[@]}" \
|
||||
--release-notes-file "$CODER_RELEASE_NOTES_FILE" \
|
||||
./build/*_installer.exe \
|
||||
./build/*.zip \
|
||||
./build/*.tar.gz \
|
||||
@@ -176,9 +271,10 @@ jobs:
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: 'google-github-actions/setup-gcloud@v0'
|
||||
uses: "google-github-actions/setup-gcloud@v1"
|
||||
|
||||
- name: Publish Helm Chart
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(./scripts/version.sh)"
|
||||
@@ -189,12 +285,13 @@ jobs:
|
||||
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/coder_helm_${version}.tgz gs://helm.coder.com/v2
|
||||
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/index.yaml gs://helm.coder.com/v2
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run or snapshot)
|
||||
if: ${{ github.event.inputs.dry_run || github.event.inputs.snapshot }}
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
./build/*_installer.exe
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.tgz
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
steps:
|
||||
# v5.1.0 has a weird bug that makes stalebot add then remove its own label
|
||||
# https://github.com/actions/stale/pull/775
|
||||
- uses: actions/stale@v6.0.0
|
||||
- uses: actions/stale@v7.0.0
|
||||
with:
|
||||
stale-issue-label: 'stale'
|
||||
stale-pr-label: 'stale'
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
# Pull Requests become stale more quickly due to merge conflicts.
|
||||
# Also, we promote minimizing WIP.
|
||||
days-before-pr-stale: 7
|
||||
|
||||
@@ -11,8 +11,8 @@ jobs:
|
||||
- uses: wow-actions/welcome@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FIRST_PR_REACTIONS: '+1, hooray, rocket, heart'
|
||||
FIRST_PR_REACTIONS: "+1, hooray, rocket, heart"
|
||||
FIRST_PR_COMMENT: |
|
||||
👋 Welcome @{{ author }} to Coder! Yo @coder/docs this is @{{ author }}'s first pull-request here!
|
||||
👋 Welcome @{{ author }} to Coder! Yo @coder/docs this is @{{ author }}'s first pull-request here!
|
||||
FIRST_PR_MERGED: |
|
||||
🎉 Thanks for the contribution @{{ author }}! Yo @coder/docs @{{ author }}'s first contribution has been merged! 👀👀👀
|
||||
|
||||
+26
-33
@@ -1,40 +1,36 @@
|
||||
###############################################################################
|
||||
# NOTICE #
|
||||
# If you change this file, kindly copy-pasta your change into .prettierignore #
|
||||
# and .eslintignore as well. See the following discussions to understand why #
|
||||
# we have to resort to this duplication (at least for now): #
|
||||
# #
|
||||
# https://github.com/prettier/prettier/issues/8048 #
|
||||
# https://github.com/prettier/prettier/issues/8506 #
|
||||
# https://github.com/prettier/prettier/issues/8679 #
|
||||
###############################################################################
|
||||
|
||||
node_modules
|
||||
vendor
|
||||
# Common ignore patterns, these rules applies in both root and subdirectories.
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
.gitpod.yml
|
||||
.idea
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
.idea
|
||||
.gitpod.yml
|
||||
.DS_Store
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
!/.vscode/extensions.json
|
||||
!/.vscode/settings.json
|
||||
|
||||
# Front-end ignore patterns.
|
||||
.next/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/storageState.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
|
||||
# Front-end ignore
|
||||
.next/
|
||||
site/.eslintcache
|
||||
site/.next/
|
||||
site/node_modules/
|
||||
site/storybook-static/
|
||||
site/test-results/
|
||||
site/yarn-error.log
|
||||
coverage/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
@@ -46,10 +42,7 @@ site/out/
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
.vscode/launch.json
|
||||
**/*.swp
|
||||
.coderv2/*
|
||||
/.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
# direnv
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ linters-settings:
|
||||
settings:
|
||||
ruleguard:
|
||||
failOn: all
|
||||
rules: '${configDir}/scripts/rules.go'
|
||||
rules: "${configDir}/scripts/rules.go"
|
||||
|
||||
staticcheck:
|
||||
# https://staticcheck.io/docs/options#checks
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Code generated by Makefile (.gitignore .prettierignore.include). DO NOT EDIT.
|
||||
|
||||
# .gitignore:
|
||||
# Common ignore patterns, these rules applies in both root and subdirectories.
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.gitpod.yml
|
||||
.idea
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
!/.vscode/extensions.json
|
||||
!/.vscode/settings.json
|
||||
|
||||
# Front-end ignore patterns.
|
||||
.next/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/storageState.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
/.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
# .prettierignore.include:
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
helm/templates/*.yaml
|
||||
|
||||
# Terraform state files used in tests, these are automatically generated.
|
||||
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
|
||||
**/testdata/**/*.tf*.json
|
||||
|
||||
# Testdata shouldn't be formatted.
|
||||
scripts/apitypings/testdata/**/*.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
helm/templates/*.yaml
|
||||
|
||||
# Terraform state files used in tests, these are automatically generated.
|
||||
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
|
||||
**/testdata/**/*.tf*.json
|
||||
|
||||
# Testdata shouldn't be formatted.
|
||||
scripts/apitypings/testdata/**/*.ts
|
||||
@@ -0,0 +1,16 @@
|
||||
# This config file is used in conjunction with `.editorconfig` to specify
|
||||
# formatting for prettier-supported files. See `.editorconfig` and
|
||||
# `site/.editorconfig`for whitespace formatting options.
|
||||
printWidth: 80
|
||||
semi: false
|
||||
trailingComma: all
|
||||
overrides:
|
||||
- files:
|
||||
- README.md
|
||||
options:
|
||||
proseWrap: preserve
|
||||
- files:
|
||||
- "site/**/*.yaml"
|
||||
- "site/**/*.yml"
|
||||
options:
|
||||
proseWrap: always
|
||||
@@ -0,0 +1,8 @@
|
||||
// Replace all NullTime with string
|
||||
replace github.com/coder/coder/codersdk.NullTime string
|
||||
// Prevent swaggo from rendering enums for time.Duration
|
||||
replace time.Duration int64
|
||||
// Do not expose "echo" provider
|
||||
replace github.com/coder/coder/codersdk.ProvisionerType string
|
||||
// Do not render netip.Addr
|
||||
replace netip.Addr string
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"github.vscode-codeql",
|
||||
"golang.go",
|
||||
"hashicorp.terraform",
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
+6
-6
@@ -14,15 +14,15 @@ LABEL \
|
||||
org.opencontainers.image.source="https://github.com/coder/coder" \
|
||||
org.opencontainers.image.version="$CODER_VERSION"
|
||||
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
COPY --chown=coder:coder --chmod=755 coder /opt/coder
|
||||
|
||||
# Create coder group and user. We cannot use `addgroup` and `adduser` because
|
||||
# they won't work if we're building the image for a different architecture.
|
||||
COPY --chown=root:root --chmod=644 group passwd /etc/
|
||||
COPY --chown=coder:coder --chmod=700 empty-dir /home/coder
|
||||
COPY --chown=0:0 --chmod=644 group passwd /etc/
|
||||
COPY --chown=1000:1000 --chmod=700 empty-dir /home/coder
|
||||
|
||||
USER coder:coder
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
COPY --chown=1000:1000 --chmod=755 coder /opt/coder
|
||||
|
||||
USER 1000:1000
|
||||
ENV HOME=/home/coder
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt
|
||||
WORKDIR /home/coder
|
||||
|
||||
@@ -44,10 +44,17 @@ else
|
||||
ZSTDFLAGS := -6
|
||||
endif
|
||||
|
||||
# Common paths to exclude from find commands, this rule is written so
|
||||
# that it can be it can be used in a chain of AND statements (meaning
|
||||
# you can simply write `find . $(FIND_EXCLUSIONS) -name thing-i-want`).
|
||||
# Note, all find statements should be written with `.` or `./path` as
|
||||
# the search path so that these exclusions match.
|
||||
FIND_EXCLUSIONS= \
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path './site/out/*' \) -prune \)
|
||||
# Source files used for make targets, evaluated on use.
|
||||
GO_SRC_FILES = $(shell find . -not \( -path './.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path './site/node_modules/*' -o -path './site/out/*' \) -type f -name '*.go')
|
||||
GO_SRC_FILES = $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES = $(shell find . -not \( -path './.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path './site/node_modules/*' -o -path './site/out/*' \) -type f -name '*.sh')
|
||||
SHELL_SRC_FILES = $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
|
||||
OS_ARCHES := \
|
||||
@@ -101,19 +108,26 @@ build-fat build-full build: $(CODER_FAT_BINARIES)
|
||||
release: $(CODER_FAT_BINARIES) $(CODER_ALL_ARCHIVES) $(CODER_ALL_PACKAGES) $(CODER_ARCH_IMAGES) build/coder_helm_$(VERSION).tgz
|
||||
.PHONY: release
|
||||
|
||||
build/coder-slim_$(VERSION)_checksums.sha1 site/out/bin/coder.sha1: $(CODER_SLIM_BINARIES)
|
||||
build/coder-slim_$(VERSION)_checksums.sha1: site/out/bin/coder.sha1
|
||||
cp "$<" "$@"
|
||||
|
||||
site/out/bin/coder.sha1: $(CODER_SLIM_BINARIES)
|
||||
pushd ./site/out/bin
|
||||
openssl dgst -r -sha1 coder-* | tee coder.sha1
|
||||
popd
|
||||
|
||||
cp "site/out/bin/coder.sha1" "build/coder-slim_$(VERSION)_checksums.sha1"
|
||||
|
||||
build/coder-slim_$(VERSION).tar: build/coder-slim_$(VERSION)_checksums.sha1 $(CODER_SLIM_BINARIES)
|
||||
pushd ./site/out/bin
|
||||
tar cf "../../../build/$(@F)" coder-*
|
||||
popd
|
||||
|
||||
build/coder-slim_$(VERSION).tar.zst site/out/bin/coder.tar.zst: build/coder-slim_$(VERSION).tar
|
||||
# delete the uncompressed binaries from the embedded dir
|
||||
rm -f site/out/bin/coder-*
|
||||
|
||||
site/out/bin/coder.tar.zst: build/coder-slim_$(VERSION).tar.zst
|
||||
cp "$<" "$@"
|
||||
|
||||
build/coder-slim_$(VERSION).tar.zst: build/coder-slim_$(VERSION).tar
|
||||
zstd $(ZSTDFLAGS) \
|
||||
--force \
|
||||
--long \
|
||||
@@ -121,10 +135,6 @@ build/coder-slim_$(VERSION).tar.zst site/out/bin/coder.tar.zst: build/coder-slim
|
||||
-o "build/coder-slim_$(VERSION).tar.zst" \
|
||||
"build/coder-slim_$(VERSION).tar"
|
||||
|
||||
cp "build/coder-slim_$(VERSION).tar.zst" "site/out/bin/coder.tar.zst"
|
||||
# delete the uncompressed binaries from the embedded dir
|
||||
rm site/out/bin/coder-*
|
||||
|
||||
# Redirect from version-less targets to the versioned ones. There is a similar
|
||||
# target for slim binaries below.
|
||||
#
|
||||
@@ -338,7 +348,7 @@ build/coder_helm_$(VERSION).tgz:
|
||||
--version "$(VERSION)" \
|
||||
--output "$@"
|
||||
|
||||
site/out/index.html: site/package.json $(shell find ./site -not -path './site/node_modules/*' -type f \( -name '*.ts' -o -name '*.tsx' \))
|
||||
site/out/index.html: site/package.json $(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
|
||||
./scripts/yarn_install.sh
|
||||
cd site
|
||||
yarn build
|
||||
@@ -359,9 +369,9 @@ fmt/prettier:
|
||||
cd site
|
||||
# Avoid writing files in CI to reduce file write activity
|
||||
ifdef CI
|
||||
yarn run format:check . ../*.md ../docs
|
||||
yarn run format:check
|
||||
else
|
||||
yarn run format:write . ../*.md ../docs
|
||||
yarn run format:write
|
||||
endif
|
||||
.PHONY: fmt/prettier
|
||||
|
||||
@@ -400,13 +410,32 @@ gen: \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md
|
||||
docs/admin/prometheus.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore
|
||||
.PHONY: gen
|
||||
|
||||
# Mark all generated files as fresh so make thinks they're up-to-date. This is
|
||||
# used during releases so we don't run generation scripts.
|
||||
gen/mark-fresh:
|
||||
files="coderd/database/dump.sql coderd/database/querier.go provisionersdk/proto/provisioner.pb.go provisionerd/proto/provisionerd.pb.go site/src/api/typesGenerated.ts docs/admin/prometheus.md"
|
||||
files="\
|
||||
coderd/database/dump.sql \
|
||||
coderd/database/querier.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
"
|
||||
for file in $$files; do
|
||||
echo "$$file"
|
||||
if [ ! -f "$$file" ]; then
|
||||
@@ -444,7 +473,7 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find codersdk -type f -name '*.go')
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts
|
||||
cd site
|
||||
yarn run format:types
|
||||
@@ -452,16 +481,80 @@ site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find codersdk
|
||||
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
cd site
|
||||
yarn run format:write ../docs/admin/prometheus.md
|
||||
yarn run format:write:only ../docs/admin/prometheus.md
|
||||
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen -not \( -path './scripts/apidocgen/node_modules' -prune \) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) .swaggo
|
||||
./scripts/apidocgen/generate.sh
|
||||
cd site
|
||||
yarn run format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json
|
||||
|
||||
update-golden-files: cli/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(GO_SRC_FILES)
|
||||
|
||||
go test ./cli -run=TestCommandHelp -update
|
||||
touch "$@"
|
||||
|
||||
# Generate a prettierrc for the site package that uses relative paths for
|
||||
# overrides. This allows us to share the same prettier config between the
|
||||
# site and the root of the repo.
|
||||
site/.prettierrc.yaml: .prettierrc.yaml
|
||||
. ./scripts/lib.sh
|
||||
dependencies yq
|
||||
|
||||
echo "# Code generated by Makefile (../$<). DO NOT EDIT." > "$@"
|
||||
echo "" >> "$@"
|
||||
|
||||
# Replace all listed override files with relative paths inside site/.
|
||||
# - ./ -> ../
|
||||
# - ./site -> ./
|
||||
yq \
|
||||
'.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./"))' \
|
||||
"$<" >> "$@"
|
||||
|
||||
# Combine .gitignore with .prettierignore.include to generate .prettierignore.
|
||||
.prettierignore: .gitignore .prettierignore.include
|
||||
echo "# Code generated by Makefile ($^). DO NOT EDIT." > "$@"
|
||||
echo "" >> "$@"
|
||||
for f in $^; do
|
||||
echo "# $${f}:" >> "$@"
|
||||
cat "$$f" >> "$@"
|
||||
done
|
||||
|
||||
# Generate ignore files based on gitignore into the site directory. We turn all
|
||||
# rules into relative paths for the `site/` directory (where applicable),
|
||||
# following the pattern format defined by git:
|
||||
# https://git-scm.com/docs/gitignore#_pattern_format
|
||||
#
|
||||
# This is done for compatibility reasons, see:
|
||||
# https://github.com/prettier/prettier/issues/8048
|
||||
# https://github.com/prettier/prettier/issues/8506
|
||||
# https://github.com/prettier/prettier/issues/8679
|
||||
site/.eslintignore site/.prettierignore: .prettierignore Makefile
|
||||
rm -f "$@"
|
||||
touch "$@"
|
||||
# Skip generated by header, inherit `.prettierignore` header as-is.
|
||||
while read -r rule; do
|
||||
# Remove leading ! if present to simplify rule, added back at the end.
|
||||
tmp="$${rule#!}"
|
||||
ignore="$${rule%"$$tmp"}"
|
||||
rule="$$tmp"
|
||||
case "$$rule" in
|
||||
# Comments or empty lines (include).
|
||||
\#*|'') ;;
|
||||
# Generic rules (include).
|
||||
\*\**) ;;
|
||||
# Site prefixed rules (include).
|
||||
site/*) rule="$${rule#site/}";;
|
||||
./site/*) rule="$${rule#./site/}";;
|
||||
# Rules that are non-generic and don't start with site (rewrite).
|
||||
/*) rule=.."$$rule";;
|
||||
*/?*) rule=../"$$rule";;
|
||||
*) ;;
|
||||
esac
|
||||
echo "$${ignore}$${rule}" >> "$@"
|
||||
done < "$<"
|
||||
|
||||
test: test-clean
|
||||
gotestsum --debug -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
@@ -66,7 +66,7 @@ curl -L https://coder.com/install.sh | sh -s -- --help
|
||||
|
||||
Once installed, you can start a production deployment<sup>1</sup> with a single command:
|
||||
|
||||
```sh
|
||||
```console
|
||||
# Automatically sets up an external access URL on *.try.coder.app
|
||||
coder server
|
||||
|
||||
@@ -88,18 +88,27 @@ Find our templates [here](./examples/templates).
|
||||
|
||||
## Comparison
|
||||
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to: [What Coder is not](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not).
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to:
|
||||
|
||||
| Tool | Type | Delivery Model | Cost | Environments |
|
||||
| :---------------------------------------------------------- | :------- | :----------------- | :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://github.com/coder/coder) | Platform | OSS + Self-Managed | Pay your cloud | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [code-server](https://github.com/cdr/code-server) | Web IDE | OSS + Self-Managed | Pay your cloud | Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [Coder (Classic)](https://coder.com/docs) | Platform | Self-Managed | Pay your cloud + license fees | Kubernetes Linux Containers |
|
||||
| [GitHub Codespaces](https://github.com/features/codespaces) | Platform | SaaS | 2x Azure Compute | Linux Virtual Machines |
|
||||
- [What Coder is not](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [The Self-Hosting Paradox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [GitHub Codespaces, Coder, and Enterprise Customers](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
|
||||
- [How our development team shares one giant bare metal machine](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
|
||||
---
|
||||
| Tool | Type | Delivery Model | Cost | Internet Access Required | Latency and Data Sovereignty | Security isolation model | Product quality | Service Availability | Environments | IDE |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Unopinionated (whatever/wherever you choose to deploy thus 100% configurable) | [Defect history](https://github.com/coder/coder/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-Hosted | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 | Anything (vim, emacs, theia, code-server, openvscode-server, entire jetbrains suite inc gateway remote development, visual studio code desktop, visual studio for mac, visual studio for windows) you choose to install and deploy |
|
||||
| [code-server](https://coder.com/blog/code-server-multiple-users?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/coder/code-server/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64, arm64 | [code-server](https://github.com/coder/code-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [openvscode-server](https://github.com/gitpod-io/openvscode-server) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/gitpod-io/openvscode-server) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64 | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [Amazon CodeCatalyst](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Pay AWS | Yes | US West (Oregon) | ["all customer multi-tenancy isolation is done through virtual machines" for security reasons](https://devclass.com/2022/12/05/interview-why-aws-prefers-vms-for-code-isolation-and-tips-on-developing-for-lambda/) | N/A | [Service Health](https://health.aws.amazon.com/health/status) | Linux Virtual Machines | Cloud9, Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [CodeAnywhere](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Per user | Yes | N/A | N/A | N/A | N/A | N/A | Theia |
|
||||
| [GitHub Codespaces](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | 2x Azure Compute | Yes | Four regions (US West, US East, Europe West, Southeast Asia) | ["two codespaces are never co-located on the same VM"](https://docs.github.com/en/codespaces/codespaces-reference/security-in-github-codespaces) | N/A | [Incident History](https://www.githubstatus.com/history) | Linux Virtual Machines, [GPUs supported](https://docs.github.com/en/codespaces/developing-in-codespaces/getting-started-with-github-codespaces-for-machine-learning) | Visual Studio Code ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [Gitpod](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | [SaaS](https://news.ycombinator.com/item?id=33907897) | [Credits](https://www.gitpod.io/pricing) | Yes | Two regions (Europe, US) | [All customers intermixed on the same machine isolated via runc](https://kinvolk.io/blog/2020/12/improving-kubernetes-and-container-security-with-user-namespaces/) | [Defect history](https://github.com/gitpod-io/gitpod/issues?q=is%3Aissue+label%3A%22type%3A+bug%22+sort%3Aupdated-desc+) | [Incident history](https://www.gitpodstatus.com/history) | Basic Linux containers, [GPUs](https://github.com/gitpod-io/gitpod/issues/10650) and [kubernetes/k3s](https://github.com/gitpod-io/gitpod/issues/4889) is not yet possible | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) inhibiting functionality of [.NET](https://www.isdotnetopen.com), [Python](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx), [C](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [C++](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [Jupyter](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx) and usage of [GitHub Co-pilot](https://github.com/gitpod-io/gitpod/issues/10032). Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway supported |
|
||||
| [Google Cloud Workstations](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Google | Yes | southamerica-west1, us-east1, us-central1, us-west1, asia-east1, asia-southeast1, europe-north1, europe-southwest1, europe-west1, europe-west2, europe-west3, europe-west4 | N/A | N/A | Not generally available, offered in preview mode. | Linux | code-oss ([with restrictions](https://ghuntley.com/fracture)), Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [JetBrains Space](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS + On-Prem ([Dev environments are not supported](https://www.jetbrains.com/help/space-on-premises/space-on-premises-installation.html)) | Pay JetBrains | Yes | EU Ireland region (eu-west-1) | EC2 | N/A | [Service Health](https://status.jetbrains.space/) | Linux Virtual Machines | JetBrains Suite |
|
||||
| [Microsoft DevBox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Microsoft | Yes | Australia East, Europe West, Japan East, Canada Central, UK South, US East, US East 2, US South Central, and US West 3 | Microsoft Azure Virtual Machine | N/A | Not generally available, offered in preview mode. | Windows Virtual Machine | Any application that runs on Windows via Microsoft Remote Desktop |
|
||||
|
||||
_Last updated: 5/27/22_
|
||||
_Last updated: 14/12/2022_
|
||||
|
||||
## Community and Support
|
||||
|
||||
|
||||
+95
-54
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/atomic"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -90,7 +91,7 @@ func New(options Options) io.Closer {
|
||||
}
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
server := &agent{
|
||||
a := &agent{
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
@@ -101,8 +102,8 @@ func New(options Options) io.Closer {
|
||||
filesystem: options.Filesystem,
|
||||
tempDir: options.TempDir,
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
a.init(ctx)
|
||||
return a
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
@@ -225,6 +226,25 @@ func (a *agent) run(ctx context.Context) error {
|
||||
_ = network.Close()
|
||||
return xerrors.New("agent is closed")
|
||||
}
|
||||
|
||||
// Report statistics from the created network.
|
||||
cl, err := a.client.AgentReportStats(ctx, a.logger, func() *codersdk.AgentStats {
|
||||
stats := network.ExtractTrafficStats()
|
||||
return convertAgentStats(stats)
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
} else {
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
// This is OK because the agent never re-creates the tailnet
|
||||
// and the only shutdown indicator is agent.Close().
|
||||
<-a.closed
|
||||
_ = cl.Close()
|
||||
}); err != nil {
|
||||
a.logger.Debug(ctx, "report stats goroutine", slog.Error(err))
|
||||
_ = cl.Close()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update the DERP map!
|
||||
network.SetDERPMap(metadata.DERPMap)
|
||||
@@ -284,7 +304,18 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go a.sshServer.HandleConn(conn)
|
||||
closed := make(chan struct{})
|
||||
_ = a.trackConnGoroutine(func() {
|
||||
select {
|
||||
case <-network.Closed():
|
||||
case <-closed:
|
||||
}
|
||||
_ = conn.Close()
|
||||
})
|
||||
_ = a.trackConnGoroutine(func() {
|
||||
defer close(closed)
|
||||
a.sshServer.HandleConn(conn)
|
||||
})
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -300,10 +331,12 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
}
|
||||
}()
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
logger := a.logger.Named("reconnecting-pty")
|
||||
|
||||
for {
|
||||
conn, err := reconnectingPTYListener.Accept()
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, "accept pty failed", slog.Error(err))
|
||||
logger.Debug(ctx, "accept pty failed", slog.Error(err))
|
||||
return
|
||||
}
|
||||
// This cannot use a JSON decoder, since that can
|
||||
@@ -324,7 +357,9 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go a.handleReconnectingPTY(ctx, msg, conn)
|
||||
go func() {
|
||||
_ = a.handleReconnectingPTY(ctx, logger, msg, conn)
|
||||
}()
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -456,12 +491,16 @@ func (a *agent) init(ctx context.Context) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sshLogger := a.logger.Named("ssh-server")
|
||||
forwardHandler := &ssh.ForwardedTCPHandler{}
|
||||
unixForwardHandler := &forwardedUnixHandler{log: a.logger}
|
||||
|
||||
a.sshServer = &ssh.Server{
|
||||
ChannelHandlers: map[string]ssh.ChannelHandler{
|
||||
"direct-tcpip": ssh.DirectTCPIPHandler,
|
||||
"session": ssh.DefaultSessionHandler,
|
||||
"direct-tcpip": ssh.DirectTCPIPHandler,
|
||||
"direct-streamlocal@openssh.com": directStreamLocalHandler,
|
||||
"session": ssh.DefaultSessionHandler,
|
||||
},
|
||||
ConnectionFailedCallback: func(conn net.Conn, err error) {
|
||||
sshLogger.Info(ctx, "ssh connection ended", slog.Error(err))
|
||||
@@ -501,8 +540,10 @@ func (a *agent) init(ctx context.Context) {
|
||||
return true
|
||||
},
|
||||
RequestHandlers: map[string]ssh.RequestHandler{
|
||||
"tcpip-forward": forwardHandler.HandleSSHRequest,
|
||||
"cancel-tcpip-forward": forwardHandler.HandleSSHRequest,
|
||||
"tcpip-forward": forwardHandler.HandleSSHRequest,
|
||||
"cancel-tcpip-forward": forwardHandler.HandleSSHRequest,
|
||||
"streamlocal-forward@openssh.com": unixForwardHandler.HandleSSHRequest,
|
||||
"cancel-streamlocal-forward@openssh.com": unixForwardHandler.HandleSSHRequest,
|
||||
},
|
||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||
return &gossh.ServerConfig{
|
||||
@@ -556,28 +597,6 @@ func (a *agent) init(ctx context.Context) {
|
||||
}
|
||||
|
||||
go a.runLoop(ctx)
|
||||
cl, err := a.client.AgentReportStats(ctx, a.logger, func() *codersdk.AgentStats {
|
||||
stats := map[netlogtype.Connection]netlogtype.Counts{}
|
||||
a.closeMutex.Lock()
|
||||
if a.network != nil {
|
||||
stats = a.network.ExtractTrafficStats()
|
||||
}
|
||||
a.closeMutex.Unlock()
|
||||
return convertAgentStats(stats)
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
<-a.closed
|
||||
_ = cl.Close()
|
||||
}); err != nil {
|
||||
a.logger.Error(ctx, "report stats goroutine", slog.Error(err))
|
||||
_ = cl.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func convertAgentStats(counts map[netlogtype.Connection]netlogtype.Counts) *codersdk.AgentStats {
|
||||
@@ -798,38 +817,56 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.ReconnectingPTYInit, conn net.Conn) {
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.ReconnectingPTYInit, conn net.Conn) (retErr error) {
|
||||
defer conn.Close()
|
||||
|
||||
connectionID := uuid.NewString()
|
||||
logger = logger.With(slog.F("id", msg.ID), slog.F("connection_id", connectionID))
|
||||
|
||||
defer func() {
|
||||
if err := retErr; err != nil {
|
||||
a.closeMutex.Lock()
|
||||
closed := a.isClosed()
|
||||
a.closeMutex.Unlock()
|
||||
|
||||
// If the agent is closed, we don't want to
|
||||
// log this as an error since it's expected.
|
||||
if closed {
|
||||
logger.Debug(ctx, "session error after agent close", slog.Error(err))
|
||||
} else {
|
||||
logger.Error(ctx, "session error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
logger.Debug(ctx, "session closed")
|
||||
}()
|
||||
|
||||
var rpty *reconnectingPTY
|
||||
rawRPTY, ok := a.reconnectingPTYs.Load(msg.ID)
|
||||
if ok {
|
||||
logger.Debug(ctx, "connecting to existing session")
|
||||
rpty, ok = rawRPTY.(*reconnectingPTY)
|
||||
if !ok {
|
||||
a.logger.Error(ctx, "found invalid type in reconnecting pty map", slog.F("id", msg.ID))
|
||||
return
|
||||
return xerrors.Errorf("found invalid type in reconnecting pty map: %T", rawRPTY)
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "creating new session")
|
||||
|
||||
// Empty command will default to the users shell!
|
||||
cmd, err := a.createCommand(ctx, msg.Command, nil)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "create reconnecting pty command", slog.Error(err))
|
||||
return
|
||||
return xerrors.Errorf("create command: %w", err)
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
// Default to buffer 64KiB.
|
||||
circularBuffer, err := circbuf.NewBuffer(64 << 10)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "create circular buffer", slog.Error(err))
|
||||
return
|
||||
return xerrors.Errorf("create circular buffer: %w", err)
|
||||
}
|
||||
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "start reconnecting pty command", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
return xerrors.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
@@ -873,7 +910,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.Reconnec
|
||||
_, err = rpty.circularBuffer.Write(part)
|
||||
rpty.circularBufferMutex.Unlock()
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", msg.ID))
|
||||
logger.Error(ctx, "write to circular buffer", slog.Error(err))
|
||||
break
|
||||
}
|
||||
rpty.activeConnsMutex.Lock()
|
||||
@@ -889,23 +926,27 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.Reconnec
|
||||
rpty.Close()
|
||||
a.reconnectingPTYs.Delete(msg.ID)
|
||||
}); err != nil {
|
||||
a.logger.Error(ctx, "start reconnecting pty routine", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
return xerrors.Errorf("start routine: %w", err)
|
||||
}
|
||||
}
|
||||
// Resize the PTY to initial height + width.
|
||||
err := rpty.ptty.Resize(msg.Height, msg.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
logger.Error(ctx, "resize", slog.Error(err))
|
||||
}
|
||||
// Write any previously stored data for the TTY.
|
||||
rpty.circularBufferMutex.RLock()
|
||||
_, err = conn.Write(rpty.circularBuffer.Bytes())
|
||||
prevBuf := slices.Clone(rpty.circularBuffer.Bytes())
|
||||
rpty.circularBufferMutex.RUnlock()
|
||||
// Note that there is a small race here between writing buffered
|
||||
// data and storing conn in activeConns. This is likely a very minor
|
||||
// edge case, but we should look into ways to avoid it. Holding
|
||||
// activeConnsMutex would be one option, but holding this mutex
|
||||
// while also holding circularBufferMutex seems dangerous.
|
||||
_, err = conn.Write(prevBuf)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
return xerrors.Errorf("write buffer to conn: %w", err)
|
||||
}
|
||||
// Multiple connections to the same TTY are permitted.
|
||||
// This could easily be used for terminal sharing, but
|
||||
@@ -946,16 +987,16 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.Reconnec
|
||||
for {
|
||||
err = decoder.Decode(&req)
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
logger.Warn(ctx, "read conn", slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
_, err = rpty.ptty.Input().Write([]byte(req.Data))
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
logger.Warn(ctx, "write to pty", slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
// Check if a resize needs to happen!
|
||||
if req.Height == 0 || req.Width == 0 {
|
||||
@@ -964,7 +1005,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.Reconnec
|
||||
err = rpty.ptty.Resize(req.Height, req.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
logger.Error(ctx, "resize", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+863
-629
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -34,10 +34,11 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
hasHealthchecksEnabled := false
|
||||
health := make(map[uuid.UUID]codersdk.WorkspaceAppHealth, 0)
|
||||
for _, app := range apps {
|
||||
health[app.ID] = app.Health
|
||||
if !hasHealthchecksEnabled && app.Health != codersdk.WorkspaceAppHealthDisabled {
|
||||
hasHealthchecksEnabled = true
|
||||
if app.Health == codersdk.WorkspaceAppHealthDisabled {
|
||||
continue
|
||||
}
|
||||
health[app.ID] = app.Health
|
||||
hasHealthchecksEnabled = true
|
||||
}
|
||||
|
||||
// no need to run this loop if no health checks are configured.
|
||||
@@ -77,7 +78,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
return err
|
||||
}
|
||||
// successful healthcheck is a non-5XX status code
|
||||
res.Body.Close()
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode >= http.StatusInternalServerError {
|
||||
return xerrors.Errorf("error status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
+124
-127
@@ -19,148 +19,145 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestAppHealth(t *testing.T) {
|
||||
func TestAppHealth_Healthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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{
|
||||
nil,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
nil,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
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)
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
})
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
}
|
||||
|
||||
t.Run("500", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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,
|
||||
func TestAppHealth_500(t *testing.T) {
|
||||
t.Parallel()
|
||||
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) {
|
||||
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusInternalServerError, 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)
|
||||
})
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
}
|
||||
|
||||
t.Run("Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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,
|
||||
func TestAppHealth_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
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
|
||||
}
|
||||
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)
|
||||
})
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
}
|
||||
|
||||
t.Run("NotSpamming", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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,
|
||||
func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
t.Parallel()
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
var counter = new(int32)
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(counter, 1)
|
||||
}),
|
||||
}
|
||||
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
// 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, *counter, int32(2))
|
||||
})
|
||||
counter := new(int32)
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(counter, 1)
|
||||
}),
|
||||
}
|
||||
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
// 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, *counter, int32(2))
|
||||
}
|
||||
|
||||
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
|
||||
|
||||
@@ -32,7 +32,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.ListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.MinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
// streamLocalForwardPayload describes the extra data sent in a
|
||||
// streamlocal-forward@openssh.com containing the socket path to bind to.
|
||||
type streamLocalForwardPayload struct {
|
||||
SocketPath string
|
||||
}
|
||||
|
||||
// forwardedStreamLocalPayload describes the data sent as the payload in the new
|
||||
// channel request when a Unix connection is accepted by the listener.
|
||||
type forwardedStreamLocalPayload struct {
|
||||
SocketPath string
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
// forwardedUnixHandler is a clone of ssh.ForwardedTCPHandler that does
|
||||
// streamlocal forwarding (aka. unix forwarding) instead of TCP forwarding.
|
||||
type forwardedUnixHandler struct {
|
||||
sync.Mutex
|
||||
log slog.Logger
|
||||
forwards map[string]net.Listener
|
||||
}
|
||||
|
||||
func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server, req *gossh.Request) (bool, []byte) {
|
||||
h.Lock()
|
||||
if h.forwards == nil {
|
||||
h.forwards = make(map[string]net.Listener)
|
||||
}
|
||||
h.Unlock()
|
||||
conn, ok := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||
if !ok {
|
||||
h.log.Warn(ctx, "SSH unix forward request from client with no gossh connection")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case "streamlocal-forward@openssh.com":
|
||||
var reqPayload streamLocalForwardPayload
|
||||
err := gossh.Unmarshal(req.Payload, &reqPayload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "parse streamlocal-forward@openssh.com request payload from client", slog.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
addr := reqPayload.SocketPath
|
||||
h.Lock()
|
||||
_, ok := h.forwards[addr]
|
||||
h.Unlock()
|
||||
if ok {
|
||||
h.log.Warn(ctx, "SSH unix forward request for socket path that is already being forwarded (maybe to another client?)",
|
||||
slog.F("socket_path", addr),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Create socket parent dir if not exists.
|
||||
parentDir := filepath.Dir(addr)
|
||||
err = os.MkdirAll(parentDir, 0700)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "create parent dir for SSH unix forward request",
|
||||
slog.F("parent_dir", parentDir),
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", addr)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "listen on Unix socket for SSH unix forward request",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// The listener needs to successfully start before it can be added to
|
||||
// the map, so we don't have to worry about checking for an existing
|
||||
// listener.
|
||||
//
|
||||
// This is also what the upstream TCP version of this code does.
|
||||
h.Lock()
|
||||
h.forwards[addr] = ln
|
||||
h.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if !xerrors.Is(err, net.ErrClosed) {
|
||||
h.log.Warn(ctx, "accept on local Unix socket for SSH unix forward request",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
// closed below
|
||||
break
|
||||
}
|
||||
payload := gossh.Marshal(&forwardedStreamLocalPayload{
|
||||
SocketPath: addr,
|
||||
})
|
||||
|
||||
go func() {
|
||||
ch, reqs, err := conn.OpenChannel("forwarded-streamlocal@openssh.com", payload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "open SSH channel to forward Unix connection to client",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
Bicopy(ctx, ch, c)
|
||||
}()
|
||||
}
|
||||
|
||||
h.Lock()
|
||||
ln2, ok := h.forwards[addr]
|
||||
if ok && ln2 == ln {
|
||||
delete(h.forwards, addr)
|
||||
}
|
||||
h.Unlock()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
|
||||
case "cancel-streamlocal-forward@openssh.com":
|
||||
var reqPayload streamLocalForwardPayload
|
||||
err := gossh.Unmarshal(req.Payload, &reqPayload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "parse cancel-streamlocal-forward@openssh.com request payload from client", slog.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
h.Lock()
|
||||
ln, ok := h.forwards[reqPayload.SocketPath]
|
||||
h.Unlock()
|
||||
if ok {
|
||||
_ = ln.Close()
|
||||
}
|
||||
return true, nil
|
||||
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// directStreamLocalPayload describes the extra data sent in a
|
||||
// direct-streamlocal@openssh.com channel request containing the socket path.
|
||||
type directStreamLocalPayload struct {
|
||||
SocketPath string
|
||||
|
||||
Reserved1 string
|
||||
Reserved2 uint32
|
||||
}
|
||||
|
||||
func directStreamLocalHandler(_ *ssh.Server, _ *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
var reqPayload directStreamLocalPayload
|
||||
err := gossh.Unmarshal(newChan.ExtraData(), &reqPayload)
|
||||
if err != nil {
|
||||
_ = newChan.Reject(gossh.ConnectionFailed, "could not parse direct-streamlocal@openssh.com channel payload")
|
||||
return
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
dconn, err := dialer.DialContext(ctx, "unix", reqPayload.SocketPath)
|
||||
if err != nil {
|
||||
_ = newChan.Reject(gossh.ConnectionFailed, fmt.Sprintf("dial unix socket %q: %+v", reqPayload.SocketPath, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
_ = dconn.Close()
|
||||
return
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
|
||||
Bicopy(ctx, ch, dconn)
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func workspaceAgent() *cobra.Command {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
go dumpHandler(ctx)
|
||||
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_URL must be set: %w", err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -55,7 +56,7 @@ func CreateTemplateVersionSource(t *testing.T, responses *echo.Responses) string
|
||||
directory := t.TempDir()
|
||||
f, err := ioutil.TempFile(directory, "*.tf")
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
data, err := echo.Tar(responses)
|
||||
require.NoError(t, err)
|
||||
extractTar(t, data, directory)
|
||||
@@ -70,6 +71,9 @@ func extractTar(t *testing.T, data []byte, directory string) {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if header.Name == "." || strings.Contains(header.Name, "..") {
|
||||
continue
|
||||
}
|
||||
// #nosec
|
||||
path := filepath.Join(directory, header.Name)
|
||||
mode := header.FileInfo().Mode()
|
||||
|
||||
@@ -9,7 +9,7 @@ gitauth:
|
||||
|
||||
# Multiple providers are an Enterprise feature.
|
||||
# Contact sales@coder.com for a license.
|
||||
#
|
||||
#
|
||||
# If multiple providers are used, a unique "id"
|
||||
# must be provided for each one.
|
||||
# - id: example
|
||||
|
||||
+29
-5
@@ -232,13 +232,16 @@ func TestCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("zone: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--parameter-file", parameterFile.Name())
|
||||
_, _ = parameterFile.WriteString("username: \"boingo\"")
|
||||
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
@@ -247,11 +250,32 @@ func TestCreate(t *testing.T) {
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "Parameter value absent in parameter file for \"region\"!")
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{
|
||||
match: "Specify a name",
|
||||
write: "my-workspace",
|
||||
},
|
||||
{
|
||||
match: fmt.Sprintf("Enter a value (default: %q):", defaultValue),
|
||||
write: "bingo",
|
||||
},
|
||||
{
|
||||
match: "Confirm create?",
|
||||
write: "yes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("FailedDryRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
|
||||
+90
-22
@@ -32,12 +32,22 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Usage: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
|
||||
Flag: "wildcard-access-url",
|
||||
},
|
||||
// DEPRECATED: Use HTTPAddress or TLS.Address instead.
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "Bind address of the server.",
|
||||
Flag: "address",
|
||||
Shorthand: "a",
|
||||
Default: "127.0.0.1:3000",
|
||||
// Deprecated, so we don't have a default. If set, it will overwrite
|
||||
// HTTPAddress and TLS.Address and print a warning.
|
||||
Hidden: true,
|
||||
Default: "",
|
||||
},
|
||||
HTTPAddress: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "HTTP bind address of the server. Unset to disable the HTTP endpoint.",
|
||||
Flag: "http-address",
|
||||
Default: "127.0.0.1:3000",
|
||||
},
|
||||
AutobuildPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Autobuild Poll Interval",
|
||||
@@ -238,6 +248,12 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Flag: "oidc-ignore-email-verified",
|
||||
Default: false,
|
||||
},
|
||||
UsernameField: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Username Field",
|
||||
Usage: "OIDC claim field to use as the username.",
|
||||
Flag: "oidc-username-field",
|
||||
Default: "preferred_username",
|
||||
},
|
||||
},
|
||||
|
||||
Telemetry: &codersdk.TelemetryConfig{
|
||||
@@ -267,6 +283,18 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Usage: "Whether TLS will be enabled.",
|
||||
Flag: "tls-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Address",
|
||||
Usage: "HTTPS bind address of the server.",
|
||||
Flag: "tls-address",
|
||||
Default: "127.0.0.1:3443",
|
||||
},
|
||||
RedirectHTTP: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Redirect HTTP to HTTPS",
|
||||
Usage: "Whether HTTP requests will be redirected to the access URL (if it's a https URL and TLS is enabled). Requests to local IP addresses are never redirected regardless of this setting.",
|
||||
Flag: "tls-redirect-http-to-https",
|
||||
Default: true,
|
||||
},
|
||||
CertFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Certificate Files",
|
||||
Usage: "Path to each certificate for TLS. It requires a PEM-encoded file. To configure the listener to use a CA certificate, concatenate the primary certificate and the CA certificate together. The primary certificate should appear first in the combined file.",
|
||||
@@ -281,7 +309,7 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Name: "TLS Client Auth",
|
||||
Usage: "Policy the server will follow for TLS Client Authentication. Accepted values are \"none\", \"request\", \"require-any\", \"verify-if-given\", or \"require-and-verify\".",
|
||||
Flag: "tls-client-auth",
|
||||
Default: "request",
|
||||
Default: "none",
|
||||
},
|
||||
KeyFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Key Files",
|
||||
@@ -334,12 +362,6 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Flag: "ssh-keygen-algorithm",
|
||||
Default: "ed25519",
|
||||
},
|
||||
AutoImportTemplates: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Auto Import Templates",
|
||||
Usage: "Templates to auto-import. Available auto-importable templates are: kubernetes",
|
||||
Flag: "auto-import-template",
|
||||
Hidden: true,
|
||||
},
|
||||
MetricsCacheRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Metrics Cache Refresh Interval",
|
||||
Usage: "How frequently metrics are refreshed",
|
||||
@@ -407,11 +429,22 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
},
|
||||
APIRateLimit: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "API Rate Limit",
|
||||
Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints are always rate limited regardless of this value to prevent denial-of-service attacks.",
|
||||
Flag: "api-rate-limit",
|
||||
Default: 512,
|
||||
RateLimit: &codersdk.RateLimitConfig{
|
||||
DisableAll: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable All Rate Limits",
|
||||
Usage: "Disables all rate limits. This is not recommended in production.",
|
||||
Flag: "dangerous-disable-rate-limits",
|
||||
Default: false,
|
||||
},
|
||||
API: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "API Rate Limit",
|
||||
Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints have separate strict rate limits regardless of this value to prevent denial-of-service or brute force attacks.",
|
||||
// Change the env from the auto-generated CODER_RATE_LIMIT_API to the
|
||||
// old value to avoid breaking existing deployments.
|
||||
EnvOverride: "CODER_API_RATE_LIMIT",
|
||||
Flag: "api-rate-limit",
|
||||
Default: 512,
|
||||
},
|
||||
},
|
||||
Experimental: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Experimental",
|
||||
@@ -424,6 +457,20 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Flag: "update-check",
|
||||
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
|
||||
},
|
||||
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Max Token Lifetime",
|
||||
Usage: "The maximum lifetime duration for any user creating a token.",
|
||||
Flag: "max-token-lifetime",
|
||||
Default: 24 * 30 * time.Hour,
|
||||
},
|
||||
Swagger: &codersdk.SwaggerConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Enable swagger endpoint",
|
||||
Usage: "Expose the swagger endpoint via /swagger.",
|
||||
Flag: "swagger-enable",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,21 +509,30 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) {
|
||||
// assigned a value.
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Value").Interface()
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetString(vip.GetString(prefix))
|
||||
case bool:
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetBool(vip.GetBool(prefix))
|
||||
case int:
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetInt(prefix)))
|
||||
case time.Duration:
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetDuration(prefix)))
|
||||
case []string:
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
// As of October 21st, 2022 we supported delimiting a string
|
||||
// with a comma, but Viper only supports with a space. This
|
||||
// is a small hack around it!
|
||||
@@ -544,6 +600,9 @@ func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T {
|
||||
|
||||
// Ensure the env entry for this key is registered
|
||||
// before checking value.
|
||||
//
|
||||
// We don't support DeploymentConfigField[].EnvOverride for array flags so
|
||||
// this is fine to just use `formatEnv` here.
|
||||
vip.MustBindEnv(configKey, formatEnv(configKey))
|
||||
|
||||
value := vip.Get(configKey)
|
||||
@@ -590,7 +649,7 @@ func setViperDefaults(prefix string, vip *viper.Viper, target interface{}) {
|
||||
val := reflect.ValueOf(target).Elem()
|
||||
val = reflect.Indirect(val)
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField") {
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Default").Interface()
|
||||
vip.SetDefault(prefix, value)
|
||||
return
|
||||
@@ -627,7 +686,7 @@ func AttachFlags(flagset *pflag.FlagSet, vip *viper.Viper, enterprise bool) {
|
||||
func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target interface{}, enterprise bool) {
|
||||
val := reflect.Indirect(reflect.ValueOf(target))
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField") {
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
isEnt := val.FieldByName("Enterprise").Bool()
|
||||
if enterprise != isEnt {
|
||||
return
|
||||
@@ -636,15 +695,24 @@ func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target in
|
||||
if flg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
usage := val.FieldByName("Usage").String()
|
||||
usage = fmt.Sprintf("%s\n%s", usage, cliui.Styles.Placeholder.Render("Consumes $"+formatEnv(prefix)))
|
||||
usage = fmt.Sprintf("%s\n%s", usage, cliui.Styles.Placeholder.Render("Consumes $"+env))
|
||||
shorthand := val.FieldByName("Shorthand").String()
|
||||
hidden := val.FieldByName("Hidden").Bool()
|
||||
value := val.FieldByName("Default").Interface()
|
||||
|
||||
// Allow currently set environment variables
|
||||
// to override default values in help output.
|
||||
vip.MustBindEnv(prefix, formatEnv(prefix))
|
||||
vip.MustBindEnv(prefix, env)
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
|
||||
-391
@@ -1,391 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/loadtest/harness"
|
||||
)
|
||||
|
||||
const loadtestTracerName = "coder_loadtest"
|
||||
|
||||
func loadtest() *cobra.Command {
|
||||
var (
|
||||
configPath string
|
||||
outputSpecs []string
|
||||
|
||||
traceEnable bool
|
||||
traceCoder bool
|
||||
traceHoneycombAPIKey string
|
||||
tracePropagate bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "loadtest --config <path> [--output json[:path]] [--output text[:path]]]",
|
||||
Short: "Load test the Coder API",
|
||||
// TODO: documentation and a JSON schema file
|
||||
Long: "Perform load tests against the Coder server. The load tests are configurable via a JSON file.",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "Run a loadtest with the given configuration file",
|
||||
Command: "coder loadtest --config path/to/config.json",
|
||||
},
|
||||
example{
|
||||
Description: "Run a loadtest, reading the configuration from stdin",
|
||||
Command: "cat path/to/config.json | coder loadtest --config -",
|
||||
},
|
||||
example{
|
||||
Description: "Run a loadtest outputting JSON results instead",
|
||||
Command: "coder loadtest --config path/to/config.json --output json",
|
||||
},
|
||||
example{
|
||||
Description: "Run a loadtest outputting JSON results to a file",
|
||||
Command: "coder loadtest --config path/to/config.json --output json:path/to/results.json",
|
||||
},
|
||||
example{
|
||||
Description: "Run a loadtest outputting text results to stdout and JSON results to a file",
|
||||
Command: "coder loadtest --config path/to/config.json --output text --output json:path/to/results.json",
|
||||
},
|
||||
),
|
||||
Hidden: true,
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := tracing.SetTracerName(cmd.Context(), loadtestTracerName)
|
||||
|
||||
config, err := loadLoadTestConfigFile(configPath, cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputs, err := parseLoadTestOutputs(outputSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch current user: %w", err)
|
||||
}
|
||||
|
||||
// Only owners can do loadtests. This isn't a very strong check but
|
||||
// there's not much else we can do. Ratelimits are enforced for
|
||||
// non-owners so hopefully that limits the damage if someone
|
||||
// disables this check and runs it against a non-owner account.
|
||||
ok := false
|
||||
for _, role := range me.Roles {
|
||||
if role.Name == "owner" {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return xerrors.Errorf("Not logged in as a site owner. Load testing is only available to site owners.")
|
||||
}
|
||||
|
||||
// Setup tracing and start a span.
|
||||
var (
|
||||
shouldTrace = traceEnable || traceCoder || traceHoneycombAPIKey != ""
|
||||
tracerProvider trace.TracerProvider = trace.NewNoopTracerProvider()
|
||||
closeTracingOnce sync.Once
|
||||
closeTracing = func(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
)
|
||||
if shouldTrace {
|
||||
tracerProvider, closeTracing, err = tracing.TracerProvider(ctx, loadtestTracerName, tracing.TracerOpts{
|
||||
Default: traceEnable,
|
||||
Coder: traceCoder,
|
||||
Honeycomb: traceHoneycombAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("initialize tracing: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
closeTracingOnce.Do(func() {
|
||||
// Allow time for traces to flush even if command
|
||||
// context is canceled.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = closeTracing(ctx)
|
||||
})
|
||||
}()
|
||||
}
|
||||
tracer := tracerProvider.Tracer(loadtestTracerName)
|
||||
|
||||
// Disable ratelimits and propagate tracing spans for future
|
||||
// requests. Individual tests will setup their own loggers.
|
||||
client.BypassRatelimits = true
|
||||
client.PropagateTracing = tracePropagate
|
||||
|
||||
// Prepare the test.
|
||||
runStrategy := config.Strategy.ExecutionStrategy()
|
||||
cleanupStrategy := config.CleanupStrategy.ExecutionStrategy()
|
||||
th := harness.NewTestHarness(runStrategy, cleanupStrategy)
|
||||
|
||||
for i, t := range config.Tests {
|
||||
name := fmt.Sprintf("%s-%d", t.Type, i)
|
||||
|
||||
for j := 0; j < t.Count; j++ {
|
||||
id := strconv.Itoa(j)
|
||||
runner, err := t.NewRunner(client.Clone())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create %q runner for %s/%s: %w", t.Type, name, id, err)
|
||||
}
|
||||
|
||||
th.AddRun(name, id, &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%s", name, id),
|
||||
runner: runner,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Running load test...")
|
||||
|
||||
testCtx := ctx
|
||||
if config.Timeout > 0 {
|
||||
var cancel func()
|
||||
testCtx, cancel = context.WithTimeout(testCtx, time.Duration(config.Timeout))
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// TODO: live progress output
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// Print the results.
|
||||
res := th.Results()
|
||||
for _, output := range outputs {
|
||||
var (
|
||||
w = cmd.OutOrStdout()
|
||||
c io.Closer
|
||||
)
|
||||
if output.path != "-" {
|
||||
f, err := os.Create(output.path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create output file: %w", err)
|
||||
}
|
||||
w, c = f, f
|
||||
}
|
||||
|
||||
switch output.format {
|
||||
case loadTestOutputFormatText:
|
||||
res.PrintText(w)
|
||||
case loadTestOutputFormatJSON:
|
||||
err = json.NewEncoder(w).Encode(res)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("encode JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
err = c.Close()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close output file: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup.
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nCleaning up...")
|
||||
err = th.Cleanup(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// Upload traces.
|
||||
if shouldTrace {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nUploading traces...")
|
||||
closeTracingOnce.Do(func() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
|
||||
defer cancel()
|
||||
err := closeTracing(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &configPath, "config", "", "CODER_LOADTEST_CONFIG_PATH", "", "Path to the load test configuration file, or - to read from stdin.")
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &outputSpecs, "output", "", "CODER_LOADTEST_OUTPUTS", []string{"text"}, "Output formats, see usage for more information.")
|
||||
|
||||
cliflag.BoolVarP(cmd.Flags(), &traceEnable, "trace", "", "CODER_LOADTEST_TRACE", false, "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md")
|
||||
cliflag.BoolVarP(cmd.Flags(), &traceCoder, "trace-coder", "", "CODER_LOADTEST_TRACE_CODER", false, "Whether opentelemetry traces are sent to Coder. We recommend keeping this disabled unless we advise you to enable it.")
|
||||
cliflag.StringVarP(cmd.Flags(), &traceHoneycombAPIKey, "trace-honeycomb-api-key", "", "CODER_LOADTEST_TRACE_HONEYCOMB_API_KEY", "", "Enables trace exporting to Honeycomb.io using the provided API key.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &tracePropagate, "trace-propagate", "", "CODER_LOADTEST_TRACE_PROPAGATE", false, "Enables trace propagation to the Coder backend, which will be used to correlate server-side spans with client-side spans. Only enable this if the server is configured with the exact same tracing configuration as the client.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loadLoadTestConfigFile(configPath string, stdin io.Reader) (LoadTestConfig, error) {
|
||||
if configPath == "" {
|
||||
return LoadTestConfig{}, xerrors.New("config is required")
|
||||
}
|
||||
|
||||
var (
|
||||
configReader io.ReadCloser
|
||||
)
|
||||
if configPath == "-" {
|
||||
configReader = io.NopCloser(stdin)
|
||||
} else {
|
||||
f, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return LoadTestConfig{}, xerrors.Errorf("open config file %q: %w", configPath, err)
|
||||
}
|
||||
configReader = f
|
||||
}
|
||||
|
||||
var config LoadTestConfig
|
||||
err := json.NewDecoder(configReader).Decode(&config)
|
||||
_ = configReader.Close()
|
||||
if err != nil {
|
||||
return LoadTestConfig{}, xerrors.Errorf("read config file %q: %w", configPath, err)
|
||||
}
|
||||
|
||||
err = config.Validate()
|
||||
if err != nil {
|
||||
return LoadTestConfig{}, xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type loadTestOutputFormat string
|
||||
|
||||
const (
|
||||
loadTestOutputFormatText loadTestOutputFormat = "text"
|
||||
loadTestOutputFormatJSON loadTestOutputFormat = "json"
|
||||
// TODO: html format
|
||||
)
|
||||
|
||||
type loadTestOutput struct {
|
||||
format loadTestOutputFormat
|
||||
// Up to one path (the first path) will have the value "-" which signifies
|
||||
// stdout.
|
||||
path string
|
||||
}
|
||||
|
||||
func parseLoadTestOutputs(outputs []string) ([]loadTestOutput, error) {
|
||||
var stdoutFormat loadTestOutputFormat
|
||||
|
||||
validFormats := map[loadTestOutputFormat]struct{}{
|
||||
loadTestOutputFormatText: {},
|
||||
loadTestOutputFormatJSON: {},
|
||||
}
|
||||
|
||||
var out []loadTestOutput
|
||||
for i, o := range outputs {
|
||||
parts := strings.SplitN(o, ":", 2)
|
||||
format := loadTestOutputFormat(parts[0])
|
||||
if _, ok := validFormats[format]; !ok {
|
||||
return nil, xerrors.Errorf("invalid output format %q in output flag %d", parts[0], i)
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if stdoutFormat != "" {
|
||||
return nil, xerrors.Errorf("multiple output flags specified for stdout")
|
||||
}
|
||||
stdoutFormat = format
|
||||
continue
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return nil, xerrors.Errorf("invalid output flag %d: %q", i, o)
|
||||
}
|
||||
|
||||
out = append(out, loadTestOutput{
|
||||
format: format,
|
||||
path: parts[1],
|
||||
})
|
||||
}
|
||||
|
||||
// Default to --output text
|
||||
if stdoutFormat == "" && len(out) == 0 {
|
||||
stdoutFormat = loadTestOutputFormatText
|
||||
}
|
||||
|
||||
if stdoutFormat != "" {
|
||||
out = append([]loadTestOutput{{
|
||||
format: stdoutFormat,
|
||||
path: "-",
|
||||
}}, out...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
runner harness.Runnable
|
||||
|
||||
span trace.Span
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &runnableTraceWrapper{}
|
||||
var _ harness.Cleanable = &runnableTraceWrapper{}
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName, trace.WithNewRoot())
|
||||
defer span.End()
|
||||
r.span = span
|
||||
|
||||
traceID := "unknown trace ID"
|
||||
spanID := "unknown span ID"
|
||||
if span.SpanContext().HasTraceID() {
|
||||
traceID = span.SpanContext().TraceID().String()
|
||||
}
|
||||
if span.SpanContext().HasSpanID() {
|
||||
spanID = span.SpanContext().SpanID().String()
|
||||
}
|
||||
_, _ = fmt.Fprintf(logs, "Trace ID: %s\n", traceID)
|
||||
_, _ = fmt.Fprintf(logs, "Span ID: %s\n\n", spanID)
|
||||
|
||||
// Make a separate span for the run itself so the sub-spans are grouped
|
||||
// neatly. The cleanup span is also a child of the above span so this is
|
||||
// important for readability.
|
||||
ctx2, span2 := r.tracer.Start(ctx, r.spanName+" run")
|
||||
defer span2.End()
|
||||
return r.runner.Run(ctx2, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error {
|
||||
c, ok := r.runner.(harness.Cleanable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.span != nil {
|
||||
ctx = trace.ContextWithSpanContext(ctx, r.span.SpanContext())
|
||||
}
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName+" cleanup")
|
||||
defer span.End()
|
||||
|
||||
return c.Cleanup(ctx, id)
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/loadtest/harness"
|
||||
"github.com/coder/coder/loadtest/placebo"
|
||||
"github.com/coder/coder/loadtest/workspacebuild"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestLoadTest(t *testing.T) {
|
||||
t.Skipf("This test is flakey. See https://github.com/coder/coder/issues/4942")
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PlaceboFromStdin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
config := cli.LoadTestConfig{
|
||||
Strategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeLinear,
|
||||
},
|
||||
CleanupStrategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeLinear,
|
||||
},
|
||||
Tests: []cli.LoadTest{
|
||||
{
|
||||
Type: cli.LoadTestTypePlacebo,
|
||||
Count: 10,
|
||||
Placebo: &placebo.Config{
|
||||
Sleep: httpapi.Duration(10 * time.Millisecond),
|
||||
},
|
||||
},
|
||||
},
|
||||
Timeout: httpapi.Duration(testutil.WaitShort),
|
||||
}
|
||||
|
||||
configBytes, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, root := clitest.New(t, "loadtest", "--config", "-")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(bytes.NewReader(configBytes))
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, errC)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 10")
|
||||
cancelFunc()
|
||||
<-done
|
||||
})
|
||||
|
||||
t.Run("WorkspaceBuildFromFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
config := cli.LoadTestConfig{
|
||||
Strategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeConcurrent,
|
||||
ConcurrencyLimit: 2,
|
||||
},
|
||||
CleanupStrategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeConcurrent,
|
||||
ConcurrencyLimit: 2,
|
||||
},
|
||||
Tests: []cli.LoadTest{
|
||||
{
|
||||
Type: cli.LoadTestTypeWorkspaceBuild,
|
||||
Count: 2,
|
||||
WorkspaceBuild: &workspacebuild.Config{
|
||||
OrganizationID: user.OrganizationID,
|
||||
UserID: user.UserID.String(),
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Timeout: httpapi.Duration(testutil.WaitLong),
|
||||
}
|
||||
|
||||
d := t.TempDir()
|
||||
configPath := filepath.Join(d, "/config.loadtest.json")
|
||||
f, err := os.Create(configPath)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
err = json.NewEncoder(f).Encode(config)
|
||||
require.NoError(t, err)
|
||||
_ = f.Close()
|
||||
|
||||
cmd, root := clitest.New(t, "loadtest", "--config", configPath)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, errC)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
<-done
|
||||
cancelFunc()
|
||||
})
|
||||
|
||||
t.Run("OutputFormats", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("This test is flakey. See: https://github.com/coder/coder/actions/runs/3415360091/jobs/5684401383")
|
||||
|
||||
type outputFlag struct {
|
||||
format string
|
||||
path string
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
outputs []outputFlag
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "Default",
|
||||
outputs: []outputFlag{},
|
||||
},
|
||||
{
|
||||
name: "ExplicitText",
|
||||
outputs: []outputFlag{{format: "text"}},
|
||||
},
|
||||
{
|
||||
name: "JSON",
|
||||
outputs: []outputFlag{
|
||||
{
|
||||
format: "json",
|
||||
path: filepath.Join(dir, "results.json"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TextAndJSON",
|
||||
outputs: []outputFlag{
|
||||
{
|
||||
format: "text",
|
||||
},
|
||||
{
|
||||
format: "json",
|
||||
path: filepath.Join(dir, "results.json"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TextAndJSON2",
|
||||
outputs: []outputFlag{
|
||||
{
|
||||
format: "text",
|
||||
},
|
||||
{
|
||||
format: "text",
|
||||
path: filepath.Join(dir, "results.txt"),
|
||||
},
|
||||
{
|
||||
format: "json",
|
||||
path: filepath.Join(dir, "results.json"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
config := cli.LoadTestConfig{
|
||||
Strategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeLinear,
|
||||
},
|
||||
CleanupStrategy: cli.LoadTestStrategy{
|
||||
Type: cli.LoadTestStrategyTypeLinear,
|
||||
},
|
||||
Tests: []cli.LoadTest{
|
||||
{
|
||||
Type: cli.LoadTestTypePlacebo,
|
||||
Count: 10,
|
||||
Placebo: &placebo.Config{
|
||||
Sleep: httpapi.Duration(10 * time.Millisecond),
|
||||
},
|
||||
},
|
||||
},
|
||||
Timeout: httpapi.Duration(testutil.WaitShort),
|
||||
}
|
||||
|
||||
configBytes, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
args := []string{"loadtest", "--config", "-"}
|
||||
for _, output := range c.outputs {
|
||||
flag := output.format
|
||||
if output.path != "" {
|
||||
flag += ":" + output.path
|
||||
}
|
||||
args = append(args, "--output", flag)
|
||||
}
|
||||
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetIn(bytes.NewReader(configBytes))
|
||||
out := bytes.NewBuffer(nil)
|
||||
cmd.SetOut(out)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
if c.errContains != "" {
|
||||
assert.Error(t, errC)
|
||||
assert.Contains(t, errC.Error(), c.errContains)
|
||||
} else {
|
||||
assert.NoError(t, errC)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
<-done
|
||||
|
||||
if c.errContains != "" {
|
||||
return
|
||||
}
|
||||
if len(c.outputs) == 0 {
|
||||
// This is the default output format when no flags are
|
||||
// specified.
|
||||
c.outputs = []outputFlag{{format: "text"}}
|
||||
}
|
||||
for i, output := range c.outputs {
|
||||
msg := fmt.Sprintf("flag %d", i)
|
||||
var b []byte
|
||||
if output.path == "" {
|
||||
b = out.Bytes()
|
||||
} else {
|
||||
b, err = os.ReadFile(output.path)
|
||||
require.NoError(t, err, msg)
|
||||
}
|
||||
|
||||
t.Logf("output %d:\n\n%s", i, string(b))
|
||||
|
||||
switch output.format {
|
||||
case "text":
|
||||
require.Contains(t, string(b), "Test results:", msg)
|
||||
require.Contains(t, string(b), "Pass: 10", msg)
|
||||
case "json":
|
||||
var res harness.Results
|
||||
err = json.Unmarshal(b, &res)
|
||||
require.NoError(t, err, msg)
|
||||
require.Equal(t, 10, res.TotalRuns, msg)
|
||||
require.Equal(t, 10, res.TotalPass, msg)
|
||||
require.Len(t, res.Runs, 10, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/loadtest/agentconn"
|
||||
"github.com/coder/coder/loadtest/harness"
|
||||
"github.com/coder/coder/loadtest/placebo"
|
||||
"github.com/coder/coder/loadtest/reconnectingpty"
|
||||
"github.com/coder/coder/loadtest/workspacebuild"
|
||||
)
|
||||
|
||||
// LoadTestConfig is the overall configuration for a call to `coder loadtest`.
|
||||
type LoadTestConfig struct {
|
||||
Strategy LoadTestStrategy `json:"strategy"`
|
||||
CleanupStrategy LoadTestStrategy `json:"cleanup_strategy"`
|
||||
Tests []LoadTest `json:"tests"`
|
||||
// Timeout sets a timeout for the entire test run, to control the timeout
|
||||
// for each individual run use strategy.timeout.
|
||||
Timeout httpapi.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
type LoadTestStrategyType string
|
||||
|
||||
const (
|
||||
LoadTestStrategyTypeLinear LoadTestStrategyType = "linear"
|
||||
LoadTestStrategyTypeConcurrent LoadTestStrategyType = "concurrent"
|
||||
)
|
||||
|
||||
type LoadTestStrategy struct {
|
||||
// Type is the type of load test strategy to use. Strategies determine how
|
||||
// to run tests concurrently.
|
||||
Type LoadTestStrategyType `json:"type"`
|
||||
|
||||
// ConcurrencyLimit is the maximum number of concurrent runs. This only
|
||||
// applies if type == "concurrent". Negative values disable the concurrency
|
||||
// limit and attempts to perform all runs concurrently. The default value is
|
||||
// 100.
|
||||
ConcurrencyLimit int `json:"concurrency_limit"`
|
||||
|
||||
// Shuffle determines whether or not to shuffle the test runs before
|
||||
// executing them.
|
||||
Shuffle bool `json:"shuffle"`
|
||||
// Timeout is the maximum amount of time to run each test for. This is
|
||||
// independent of the timeout specified in the test run. A timeout of 0
|
||||
// disables the timeout.
|
||||
Timeout httpapi.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
func (s LoadTestStrategy) ExecutionStrategy() harness.ExecutionStrategy {
|
||||
var strategy harness.ExecutionStrategy
|
||||
switch s.Type {
|
||||
case LoadTestStrategyTypeLinear:
|
||||
strategy = harness.LinearExecutionStrategy{}
|
||||
case LoadTestStrategyTypeConcurrent:
|
||||
limit := s.ConcurrencyLimit
|
||||
if limit < 0 {
|
||||
return harness.ConcurrentExecutionStrategy{}
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 100
|
||||
}
|
||||
strategy = harness.ParallelExecutionStrategy{
|
||||
Limit: limit,
|
||||
}
|
||||
default:
|
||||
panic("unreachable, unknown strategy type " + s.Type)
|
||||
}
|
||||
|
||||
if s.Timeout > 0 {
|
||||
strategy = harness.TimeoutExecutionStrategyWrapper{
|
||||
Timeout: time.Duration(s.Timeout),
|
||||
Inner: strategy,
|
||||
}
|
||||
}
|
||||
if s.Shuffle {
|
||||
strategy = harness.ShuffleExecutionStrategyWrapper{
|
||||
Inner: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
return strategy
|
||||
}
|
||||
|
||||
type LoadTestType string
|
||||
|
||||
const (
|
||||
LoadTestTypeAgentConn LoadTestType = "agentconn"
|
||||
LoadTestTypePlacebo LoadTestType = "placebo"
|
||||
LoadTestTypeReconnectingPTY LoadTestType = "reconnectingpty"
|
||||
LoadTestTypeWorkspaceBuild LoadTestType = "workspacebuild"
|
||||
)
|
||||
|
||||
type LoadTest struct {
|
||||
// Type is the type of load test to run.
|
||||
Type LoadTestType `json:"type"`
|
||||
// Count is the number of test runs to execute with this configuration. If
|
||||
// the count is 0 or negative, defaults to 1.
|
||||
Count int `json:"count"`
|
||||
|
||||
// AgentConn must be set if type == "agentconn".
|
||||
AgentConn *agentconn.Config `json:"agentconn,omitempty"`
|
||||
// Placebo must be set if type == "placebo".
|
||||
Placebo *placebo.Config `json:"placebo,omitempty"`
|
||||
// ReconnectingPTY must be set if type == "reconnectingpty".
|
||||
ReconnectingPTY *reconnectingpty.Config `json:"reconnectingpty,omitempty"`
|
||||
// WorkspaceBuild must be set if type == "workspacebuild".
|
||||
WorkspaceBuild *workspacebuild.Config `json:"workspacebuild,omitempty"`
|
||||
}
|
||||
|
||||
func (t LoadTest) NewRunner(client *codersdk.Client) (harness.Runnable, error) {
|
||||
switch t.Type {
|
||||
case LoadTestTypeAgentConn:
|
||||
if t.AgentConn == nil {
|
||||
return nil, xerrors.New("agentconn config must be set")
|
||||
}
|
||||
return agentconn.NewRunner(client, *t.AgentConn), nil
|
||||
case LoadTestTypePlacebo:
|
||||
if t.Placebo == nil {
|
||||
return nil, xerrors.New("placebo config must be set")
|
||||
}
|
||||
return placebo.NewRunner(*t.Placebo), nil
|
||||
case LoadTestTypeReconnectingPTY:
|
||||
if t.ReconnectingPTY == nil {
|
||||
return nil, xerrors.New("reconnectingpty config must be set")
|
||||
}
|
||||
return reconnectingpty.NewRunner(client, *t.ReconnectingPTY), nil
|
||||
case LoadTestTypeWorkspaceBuild:
|
||||
if t.WorkspaceBuild == nil {
|
||||
return nil, xerrors.Errorf("workspacebuild config must be set")
|
||||
}
|
||||
return workspacebuild.NewRunner(client, *t.WorkspaceBuild), nil
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown test type %q", t.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LoadTestConfig) Validate() error {
|
||||
err := c.Strategy.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate strategy: %w", err)
|
||||
}
|
||||
err = c.CleanupStrategy.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate cleanup_strategy: %w", err)
|
||||
}
|
||||
|
||||
for i, test := range c.Tests {
|
||||
err := test.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate test %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LoadTestStrategy) Validate() error {
|
||||
switch s.Type {
|
||||
case LoadTestStrategyTypeLinear:
|
||||
case LoadTestStrategyTypeConcurrent:
|
||||
default:
|
||||
return xerrors.Errorf("invalid load test strategy type: %q", s.Type)
|
||||
}
|
||||
|
||||
if s.Timeout < 0 {
|
||||
return xerrors.Errorf("invalid load test strategy timeout: %q", s.Timeout)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *LoadTest) Validate() error {
|
||||
switch t.Type {
|
||||
case LoadTestTypeAgentConn:
|
||||
if t.AgentConn == nil {
|
||||
return xerrors.Errorf("agentconn test type must specify agentconn")
|
||||
}
|
||||
|
||||
err := t.AgentConn.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate agentconn: %w", err)
|
||||
}
|
||||
case LoadTestTypePlacebo:
|
||||
if t.Placebo == nil {
|
||||
return xerrors.Errorf("placebo test type must specify placebo")
|
||||
}
|
||||
|
||||
err := t.Placebo.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate placebo: %w", err)
|
||||
}
|
||||
case LoadTestTypeReconnectingPTY:
|
||||
if t.ReconnectingPTY == nil {
|
||||
return xerrors.Errorf("reconnectingpty test type must specify reconnectingpty")
|
||||
}
|
||||
|
||||
err := t.ReconnectingPTY.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate reconnectingpty: %w", err)
|
||||
}
|
||||
case LoadTestTypeWorkspaceBuild:
|
||||
if t.WorkspaceBuild == nil {
|
||||
return xerrors.New("workspacebuild test type must specify workspacebuild")
|
||||
}
|
||||
|
||||
err := t.WorkspaceBuild.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate workspacebuild: %w", err)
|
||||
}
|
||||
default:
|
||||
return xerrors.Errorf("invalid load test type: %q", t.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+11
-2
@@ -49,9 +49,18 @@ func login() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rawURL := args[0]
|
||||
rawURL := ""
|
||||
if len(args) == 0 {
|
||||
var err error
|
||||
rawURL, err = cmd.Flags().GetString(varURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get global url flag")
|
||||
}
|
||||
} else {
|
||||
rawURL = args[0]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
scheme := "https"
|
||||
|
||||
+33
-1
@@ -68,13 +68,45 @@ func TestLogin(t *testing.T) {
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Run("InitialUserTTYFlag", 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, "--url", client.URL.String(), "login", "--force-tty")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "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
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
|
||||
+7
-4
@@ -36,18 +36,21 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error)
|
||||
return nil, xerrors.Errorf("Parameter file name is not specified")
|
||||
}
|
||||
|
||||
// Returns a parameter value from a given map, if the map exists, else takes input from the user.
|
||||
// Throws an error if the map exists but does not include a value for the parameter.
|
||||
// Returns a parameter value from a given map, if the map does not exist or does not contain the item, it takes input from the user.
|
||||
// Throws an error if there are any errors with the users input.
|
||||
func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
var parameterValue string
|
||||
var err error
|
||||
if parameterMap != nil {
|
||||
var ok bool
|
||||
parameterValue, ok = parameterMap[parameterSchema.Name]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("Parameter value absent in parameter file for %q!", parameterSchema.Name)
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+2
-5
@@ -16,10 +16,6 @@ func rename() *cobra.Command {
|
||||
Use: "rename <workspace> <new name>",
|
||||
Short: "Rename a workspace",
|
||||
Args: cobra.ExactArgs(2),
|
||||
// Keep hidden until renaming is safe, see:
|
||||
// * https://github.com/coder/coder/issues/3000
|
||||
// * https://github.com/coder/coder/issues/3386
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
@@ -31,8 +27,9 @@ func rename() *cobra.Command {
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n",
|
||||
cliui.Styles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes)."),
|
||||
cliui.Styles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes). Please backup any data before proceeding."),
|
||||
)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "See: %s\n\n", "https://coder.com/docs/coder-oss/latest/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
|
||||
Validate: func(s string) error {
|
||||
|
||||
+3
-1
@@ -27,7 +27,9 @@ func TestRename(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
want := workspace.Name + "-test"
|
||||
// Only append one letter because it's easy to exceed maximum length:
|
||||
// E.g. "compassionate-chandrasekhar82" + "t".
|
||||
want := workspace.Name + "t"
|
||||
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestResetPassword(t *testing.T) {
|
||||
serverDone := make(chan struct{})
|
||||
serverCmd, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
|
||||
+103
-3
@@ -8,7 +8,11 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -76,7 +80,6 @@ func Core() []*cobra.Command {
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
loadtest(),
|
||||
login(),
|
||||
logout(),
|
||||
parameters(),
|
||||
@@ -84,6 +87,7 @@ func Core() []*cobra.Command {
|
||||
publickey(),
|
||||
rename(),
|
||||
resetPassword(),
|
||||
scaletest(),
|
||||
schedules(),
|
||||
show(),
|
||||
speedtest(),
|
||||
@@ -96,6 +100,7 @@ func Core() []*cobra.Command {
|
||||
update(),
|
||||
users(),
|
||||
versionCmd(),
|
||||
vscodeSSH(),
|
||||
workspaceAgent(),
|
||||
}
|
||||
}
|
||||
@@ -583,12 +588,17 @@ func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
|
||||
}
|
||||
|
||||
fmtWarningText := `version mismatch: client %s, server %s
|
||||
download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'
|
||||
`
|
||||
// Our installation script doesn't work on Windows, so instead we direct the user
|
||||
// to the GitHub release page to download the latest installer.
|
||||
if runtime.GOOS == "windows" {
|
||||
fmtWarningText += `download the server version from: https://github.com/coder/coder/releases/v%s`
|
||||
} else {
|
||||
fmtWarningText += `download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'`
|
||||
}
|
||||
|
||||
if !buildinfo.VersionsMatch(clientVersion, info.Version) {
|
||||
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
|
||||
// Trim the leading 'v', our install.sh script does not handle this case well.
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
@@ -624,3 +634,93 @@ func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
return h.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// dumpHandler provides a custom SIGQUIT and SIGTRAP handler that dumps the
|
||||
// stacktrace of all goroutines to stderr and a well-known file in the home
|
||||
// directory. This is useful for debugging deadlock issues that may occur in
|
||||
// production in workspaces, since the default Go runtime will only dump to
|
||||
// stderr (which is often difficult/impossible to read in a workspace).
|
||||
//
|
||||
// SIGQUITs will still cause the program to exit (similarly to the default Go
|
||||
// runtime behavior).
|
||||
//
|
||||
// A SIGQUIT handler will not be registered if GOTRACEBACK=crash.
|
||||
//
|
||||
// On Windows this immediately returns.
|
||||
func dumpHandler(ctx context.Context) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// free up the goroutine since it'll be permanently blocked anyways
|
||||
return
|
||||
}
|
||||
|
||||
listenSignals := []os.Signal{syscall.SIGTRAP}
|
||||
if os.Getenv("GOTRACEBACK") != "crash" {
|
||||
listenSignals = append(listenSignals, syscall.SIGQUIT)
|
||||
}
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, listenSignals...)
|
||||
defer signal.Stop(sigs)
|
||||
|
||||
for {
|
||||
sigStr := ""
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sig := <-sigs:
|
||||
switch sig {
|
||||
case syscall.SIGQUIT:
|
||||
sigStr = "SIGQUIT"
|
||||
case syscall.SIGTRAP:
|
||||
sigStr = "SIGTRAP"
|
||||
}
|
||||
}
|
||||
|
||||
// Start with a 1MB buffer and keep doubling it until we can fit the
|
||||
// entire stacktrace, stopping early once we reach 64MB.
|
||||
buf := make([]byte, 1_000_000)
|
||||
stacklen := 0
|
||||
for {
|
||||
stacklen = runtime.Stack(buf, true)
|
||||
if stacklen < len(buf) {
|
||||
break
|
||||
}
|
||||
if 2*len(buf) > 64_000_000 {
|
||||
// Write a message to the end of the buffer saying that it was
|
||||
// truncated.
|
||||
const truncatedMsg = "\n\n\nstack trace truncated due to size\n"
|
||||
copy(buf[len(buf)-len(truncatedMsg):], truncatedMsg)
|
||||
break
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s:\n%s\n", sigStr, buf[:stacklen])
|
||||
|
||||
// Write to a well-known file.
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
fpath := filepath.Join(dir, fmt.Sprintf("coder-agent-%s.dump", time.Now().Format("2006-01-02T15:04:05.000Z")))
|
||||
_, _ = fmt.Fprintf(os.Stderr, "writing dump to %q\n", fpath)
|
||||
|
||||
f, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to open dump file: %v\n", err.Error())
|
||||
goto done
|
||||
}
|
||||
_, err = f.Write(buf[:stacklen])
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to write dump file: %v\n", err.Error())
|
||||
goto done
|
||||
}
|
||||
|
||||
done:
|
||||
if sigStr == "SIGQUIT" {
|
||||
//nolint:revive
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-8
@@ -3,10 +3,12 @@ package cli_test
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -30,40 +32,68 @@ var updateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
func TestCommandHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
commonEnv := map[string]string{
|
||||
"CODER_CONFIG_DIR": "/tmp/coder-cli-test-config",
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
cmd []string
|
||||
env map[string]string
|
||||
}{
|
||||
}
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "coder --help",
|
||||
cmd: []string{"--help"},
|
||||
env: map[string]string{
|
||||
"CODER_CONFIG_DIR": "/tmp/coder-cli-test-config",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "coder server --help",
|
||||
cmd: []string{"server", "--help"},
|
||||
env: map[string]string{
|
||||
"CODER_CONFIG_DIR": "/tmp/coder-cli-test-config",
|
||||
"CODER_CACHE_DIRECTORY": "/tmp/coder-cli-test-cache",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := cli.Root(cli.AGPL())
|
||||
ExtractCommandPathsLoop:
|
||||
for _, cp := range extractVisibleCommandPaths(nil, root.Commands()) {
|
||||
name := fmt.Sprintf("coder %s --help", strings.Join(cp, " "))
|
||||
cmd := append(cp, "--help")
|
||||
for _, tt := range tests {
|
||||
if tt.name == name {
|
||||
continue ExtractCommandPathsLoop
|
||||
}
|
||||
}
|
||||
tests = append(tests, testCase{name: name, cmd: cmd})
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS == "windows" {
|
||||
wd = strings.ReplaceAll(wd, "\\", "\\\\")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := make(map[string]string)
|
||||
for k, v := range commonEnv {
|
||||
env[k] = v
|
||||
}
|
||||
for k, v := range tt.env {
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
// Unset all CODER_ environment variables for a clean slate.
|
||||
for _, kv := range os.Environ() {
|
||||
name := strings.Split(kv, "=")[0]
|
||||
if _, ok := tt.env[name]; !ok && strings.HasPrefix(name, "CODER_") {
|
||||
if _, ok := env[name]; !ok && strings.HasPrefix(name, "CODER_") {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
// Override environment variables for a reproducible test.
|
||||
for k, v := range tt.env {
|
||||
for k, v := range env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
@@ -79,6 +109,10 @@ func TestCommandHelp(t *testing.T) {
|
||||
// Remove CRLF newlines (Windows).
|
||||
got = bytes.ReplaceAll(got, []byte{'\r', '\n'}, []byte{'\n'})
|
||||
|
||||
// The `coder templates create --help` command prints the path
|
||||
// to the working directory (--directory flag default value).
|
||||
got = bytes.ReplaceAll(got, []byte(wd), []byte("/tmp/coder-cli-test-workdir"))
|
||||
|
||||
gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
||||
if *updateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", tt.name, gf)
|
||||
@@ -95,6 +129,19 @@ func TestCommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func extractVisibleCommandPaths(cmdPath []string, cmds []*cobra.Command) [][]string {
|
||||
var cmdPaths [][]string
|
||||
for _, c := range cmds {
|
||||
if c.Hidden {
|
||||
continue
|
||||
}
|
||||
cmdPath := append(cmdPath, c.Name())
|
||||
cmdPaths = append(cmdPaths, cmdPath)
|
||||
cmdPaths = append(cmdPaths, extractVisibleCommandPaths(cmdPath, c.Commands())...)
|
||||
}
|
||||
return cmdPaths
|
||||
}
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("FormatCobraError", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
"github.com/coder/coder/scaletest/agentconn"
|
||||
"github.com/coder/coder/scaletest/createworkspaces"
|
||||
"github.com/coder/coder/scaletest/harness"
|
||||
"github.com/coder/coder/scaletest/reconnectingpty"
|
||||
"github.com/coder/coder/scaletest/workspacebuild"
|
||||
)
|
||||
|
||||
const scaletestTracerName = "coder_scaletest"
|
||||
|
||||
func scaletest() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "scaletest",
|
||||
Short: "Run a scale test against the Coder API",
|
||||
Long: "Perform scale tests against the Coder server.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
scaletestCleanup(),
|
||||
scaletestCreateWorkspaces(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type scaletestTracingFlags struct {
|
||||
traceEnable bool
|
||||
traceCoder bool
|
||||
traceHoneycombAPIKey string
|
||||
tracePropagate bool
|
||||
}
|
||||
|
||||
func (s *scaletestTracingFlags) attach(cmd *cobra.Command) {
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.traceEnable, "trace", "", "CODER_LOADTEST_TRACE", false, "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md")
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.traceCoder, "trace-coder", "", "CODER_LOADTEST_TRACE_CODER", false, "Whether opentelemetry traces are sent to Coder. We recommend keeping this disabled unless we advise you to enable it.")
|
||||
cliflag.StringVarP(cmd.Flags(), &s.traceHoneycombAPIKey, "trace-honeycomb-api-key", "", "CODER_LOADTEST_TRACE_HONEYCOMB_API_KEY", "", "Enables trace exporting to Honeycomb.io using the provided API key.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.tracePropagate, "trace-propagate", "", "CODER_LOADTEST_TRACE_PROPAGATE", false, "Enables trace propagation to the Coder backend, which will be used to correlate server-side spans with client-side spans. Only enable this if the server is configured with the exact same tracing configuration as the client.")
|
||||
}
|
||||
|
||||
// provider returns a trace.TracerProvider, a close function and a bool showing
|
||||
// whether tracing is enabled or not.
|
||||
func (s *scaletestTracingFlags) provider(ctx context.Context) (trace.TracerProvider, func(context.Context) error, bool, error) {
|
||||
shouldTrace := s.traceEnable || s.traceCoder || s.traceHoneycombAPIKey != ""
|
||||
if !shouldTrace {
|
||||
tracerProvider := trace.NewNoopTracerProvider()
|
||||
return tracerProvider, func(_ context.Context) error { return nil }, false, nil
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, err := tracing.TracerProvider(ctx, scaletestTracerName, tracing.TracerOpts{
|
||||
Default: s.traceEnable,
|
||||
Coder: s.traceCoder,
|
||||
Honeycomb: s.traceHoneycombAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, false, xerrors.Errorf("initialize tracing: %w", err)
|
||||
}
|
||||
|
||||
var closeTracingOnce sync.Once
|
||||
return tracerProvider, func(ctx context.Context) error {
|
||||
var err error
|
||||
closeTracingOnce.Do(func() {
|
||||
err = closeTracing(ctx)
|
||||
})
|
||||
|
||||
return err
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
type scaletestStrategyFlags struct {
|
||||
cleanup bool
|
||||
concurrency int
|
||||
timeout time.Duration
|
||||
timeoutPerJob time.Duration
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) attach(cmd *cobra.Command) {
|
||||
concurrencyLong, concurrencyEnv, concurrencyDescription := "concurrency", "CODER_LOADTEST_CONCURRENCY", "Number of concurrent jobs to run. 0 means unlimited."
|
||||
timeoutLong, timeoutEnv, timeoutDescription := "timeout", "CODER_LOADTEST_TIMEOUT", "Timeout for the entire test run. 0 means unlimited."
|
||||
jobTimeoutLong, jobTimeoutEnv, jobTimeoutDescription := "job-timeout", "CODER_LOADTEST_JOB_TIMEOUT", "Timeout per job. Jobs may take longer to complete under higher concurrency limits."
|
||||
if s.cleanup {
|
||||
concurrencyLong, concurrencyEnv, concurrencyDescription = "cleanup-"+concurrencyLong, "CODER_LOADTEST_CLEANUP_CONCURRENCY", strings.ReplaceAll(concurrencyDescription, "jobs", "cleanup jobs")
|
||||
timeoutLong, timeoutEnv, timeoutDescription = "cleanup-"+timeoutLong, "CODER_LOADTEST_CLEANUP_TIMEOUT", strings.ReplaceAll(timeoutDescription, "test", "cleanup")
|
||||
jobTimeoutLong, jobTimeoutEnv, jobTimeoutDescription = "cleanup-"+jobTimeoutLong, "CODER_LOADTEST_CLEANUP_JOB_TIMEOUT", strings.ReplaceAll(jobTimeoutDescription, "jobs", "cleanup jobs")
|
||||
}
|
||||
|
||||
cliflag.IntVarP(cmd.Flags(), &s.concurrency, concurrencyLong, "", concurrencyEnv, 1, concurrencyDescription)
|
||||
cliflag.DurationVarP(cmd.Flags(), &s.timeout, timeoutLong, "", timeoutEnv, 30*time.Minute, timeoutDescription)
|
||||
cliflag.DurationVarP(cmd.Flags(), &s.timeoutPerJob, jobTimeoutLong, "", jobTimeoutEnv, 5*time.Minute, jobTimeoutDescription)
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) toStrategy() harness.ExecutionStrategy {
|
||||
var strategy harness.ExecutionStrategy
|
||||
if s.concurrency == 1 {
|
||||
strategy = harness.LinearExecutionStrategy{}
|
||||
} else if s.concurrency == 0 {
|
||||
strategy = harness.ConcurrentExecutionStrategy{}
|
||||
} else {
|
||||
strategy = harness.ParallelExecutionStrategy{
|
||||
Limit: s.concurrency,
|
||||
}
|
||||
}
|
||||
|
||||
if s.timeoutPerJob > 0 {
|
||||
strategy = harness.TimeoutExecutionStrategyWrapper{
|
||||
Timeout: s.timeoutPerJob,
|
||||
Inner: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
return strategy
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) toContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if s.timeout > 0 {
|
||||
return context.WithTimeout(ctx, s.timeout)
|
||||
}
|
||||
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
type scaleTestOutputFormat string
|
||||
|
||||
const (
|
||||
scaleTestOutputFormatText scaleTestOutputFormat = "text"
|
||||
scaleTestOutputFormatJSON scaleTestOutputFormat = "json"
|
||||
// TODO: html format
|
||||
)
|
||||
|
||||
type scaleTestOutput struct {
|
||||
format scaleTestOutputFormat
|
||||
// Zero or one (the first) path will have the path set to "-" to indicate
|
||||
// stdout.
|
||||
path string
|
||||
}
|
||||
|
||||
func (o *scaleTestOutput) write(res harness.Results, stdout io.Writer) error {
|
||||
var (
|
||||
w = stdout
|
||||
c io.Closer
|
||||
)
|
||||
if o.path != "-" {
|
||||
f, err := os.Create(o.path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create output file: %w", err)
|
||||
}
|
||||
w, c = f, f
|
||||
}
|
||||
|
||||
switch o.format {
|
||||
case scaleTestOutputFormatText:
|
||||
res.PrintText(w)
|
||||
case scaleTestOutputFormatJSON:
|
||||
err := json.NewEncoder(w).Encode(res)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("encode JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the file to disk if it's a file.
|
||||
if s, ok := w.(interface{ Sync() error }); ok {
|
||||
err := s.Sync()
|
||||
// On Linux, EINVAL is returned when calling fsync on /dev/stdout. We
|
||||
// can safely ignore this error.
|
||||
if err != nil && !xerrors.Is(err, syscall.EINVAL) {
|
||||
return xerrors.Errorf("flush output file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
err := c.Close()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close output file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type scaletestOutputFlags struct {
|
||||
outputSpecs []string
|
||||
}
|
||||
|
||||
func (s *scaletestOutputFlags) attach(cmd *cobra.Command) {
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &s.outputSpecs, "output", "", "CODER_SCALETEST_OUTPUTS", []string{"text"}, `Output format specs in the format "<format>[:<path>]". Not specifying a path will default to stdout. Available formats: text, json.`)
|
||||
}
|
||||
|
||||
func (s *scaletestOutputFlags) parse() ([]scaleTestOutput, error) {
|
||||
var stdoutFormat scaleTestOutputFormat
|
||||
|
||||
validFormats := map[scaleTestOutputFormat]struct{}{
|
||||
scaleTestOutputFormatText: {},
|
||||
scaleTestOutputFormatJSON: {},
|
||||
}
|
||||
|
||||
var out []scaleTestOutput
|
||||
for i, o := range s.outputSpecs {
|
||||
parts := strings.SplitN(o, ":", 2)
|
||||
format := scaleTestOutputFormat(parts[0])
|
||||
if _, ok := validFormats[format]; !ok {
|
||||
return nil, xerrors.Errorf("invalid output format %q in output flag %d", parts[0], i)
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if stdoutFormat != "" {
|
||||
return nil, xerrors.Errorf("multiple output flags specified for stdout")
|
||||
}
|
||||
stdoutFormat = format
|
||||
continue
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return nil, xerrors.Errorf("invalid output flag %d: %q", i, o)
|
||||
}
|
||||
|
||||
out = append(out, scaleTestOutput{
|
||||
format: format,
|
||||
path: parts[1],
|
||||
})
|
||||
}
|
||||
|
||||
// Default to --output text
|
||||
if stdoutFormat == "" && len(out) == 0 {
|
||||
stdoutFormat = scaleTestOutputFormatText
|
||||
}
|
||||
|
||||
if stdoutFormat != "" {
|
||||
out = append([]scaleTestOutput{{
|
||||
format: stdoutFormat,
|
||||
path: "-",
|
||||
}}, out...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User, error) {
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return codersdk.User{}, xerrors.Errorf("fetch current user: %w", err)
|
||||
}
|
||||
|
||||
// Only owners can do scaletests. This isn't a very strong check but there's
|
||||
// not much else we can do. Ratelimits are enforced for non-owners so
|
||||
// hopefully that limits the damage if someone disables this check and runs
|
||||
// it against a non-owner account on a production deployment.
|
||||
ok := false
|
||||
for _, role := range me.Roles {
|
||||
if role.Name == "owner" {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return me, xerrors.Errorf("Not logged in as a site owner. Scale testing is only available to site owners.")
|
||||
}
|
||||
|
||||
return me, nil
|
||||
}
|
||||
|
||||
// userCleanupRunner is a runner that deletes a user in the Run phase.
|
||||
type userCleanupRunner struct {
|
||||
client *codersdk.Client
|
||||
userID uuid.UUID
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &userCleanupRunner{}
|
||||
|
||||
// Run implements Runnable.
|
||||
func (r *userCleanupRunner) Run(ctx context.Context, _ string, _ io.Writer) error {
|
||||
if r.userID == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
err := r.client.DeleteUser(ctx, r.userID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete user %q: %w", r.userID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scaletestCleanup() *cobra.Command {
|
||||
var (
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Cleanup any orphaned scaletest resources",
|
||||
Long: "Cleanup scaletest workspaces, then cleanup scaletest users. The strategy flags will apply to each stage of the cleanup process.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
|
||||
cmd.PrintErrln("Fetching scaletest workspaces...")
|
||||
var (
|
||||
pageNumber = 0
|
||||
limit = 100
|
||||
workspaces []codersdk.Workspace
|
||||
)
|
||||
for {
|
||||
page, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: "scaletest-",
|
||||
Offset: pageNumber * limit,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err)
|
||||
}
|
||||
|
||||
pageNumber++
|
||||
if len(page.Workspaces) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pageWorkspaces := make([]codersdk.Workspace, 0, len(page.Workspaces))
|
||||
for _, w := range page.Workspaces {
|
||||
if isScaleTestWorkspace(w) {
|
||||
pageWorkspaces = append(pageWorkspaces, w)
|
||||
}
|
||||
}
|
||||
workspaces = append(workspaces, pageWorkspaces...)
|
||||
}
|
||||
|
||||
cmd.PrintErrf("Found %d scaletest workspaces\n", len(workspaces))
|
||||
if len(workspaces) != 0 {
|
||||
cmd.Println("Deleting scaletest workspaces...")
|
||||
harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{})
|
||||
|
||||
for i, w := range workspaces {
|
||||
const testName = "cleanup-workspace"
|
||||
r := workspacebuild.NewCleanupRunner(client, w.ID)
|
||||
harness.AddRun(testName, strconv.Itoa(i), r)
|
||||
}
|
||||
|
||||
ctx, cancel := cleanupStrategy.toContext(ctx)
|
||||
defer cancel()
|
||||
err := harness.Run(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness to delete workspaces (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
cmd.Println("Done deleting scaletest workspaces:")
|
||||
res := harness.Results()
|
||||
res.PrintText(cmd.ErrOrStderr())
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.Errorf("failed to delete scaletest workspaces")
|
||||
}
|
||||
}
|
||||
|
||||
cmd.PrintErrln("Fetching scaletest users...")
|
||||
pageNumber = 0
|
||||
limit = 100
|
||||
var users []codersdk.User
|
||||
for {
|
||||
page, err := client.Users(ctx, codersdk.UsersRequest{
|
||||
Search: "scaletest-",
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: pageNumber * limit,
|
||||
Limit: limit,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch scaletest users page %d: %w", pageNumber, err)
|
||||
}
|
||||
|
||||
pageNumber++
|
||||
if len(page.Users) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pageUsers := make([]codersdk.User, 0, len(page.Users))
|
||||
for _, u := range page.Users {
|
||||
if isScaleTestUser(u) {
|
||||
pageUsers = append(pageUsers, u)
|
||||
}
|
||||
}
|
||||
users = append(users, pageUsers...)
|
||||
}
|
||||
|
||||
cmd.PrintErrf("Found %d scaletest users\n", len(users))
|
||||
if len(workspaces) != 0 {
|
||||
cmd.Println("Deleting scaletest users...")
|
||||
harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{})
|
||||
|
||||
for i, u := range users {
|
||||
const testName = "cleanup-users"
|
||||
r := &userCleanupRunner{
|
||||
client: client,
|
||||
userID: u.ID,
|
||||
}
|
||||
harness.AddRun(testName, strconv.Itoa(i), r)
|
||||
}
|
||||
|
||||
ctx, cancel := cleanupStrategy.toContext(ctx)
|
||||
defer cancel()
|
||||
err := harness.Run(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness to delete users (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
cmd.Println("Done deleting scaletest users:")
|
||||
res := harness.Results()
|
||||
res.PrintText(cmd.ErrOrStderr())
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.Errorf("failed to delete scaletest users")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanupStrategy.attach(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func scaletestCreateWorkspaces() *cobra.Command {
|
||||
var (
|
||||
count int
|
||||
template string
|
||||
parametersFile string
|
||||
parameters []string // key=value
|
||||
|
||||
noPlan bool
|
||||
noCleanup bool
|
||||
// TODO: implement this flag
|
||||
// noCleanupFailures bool
|
||||
noWaitForAgents bool
|
||||
|
||||
runCommand string
|
||||
runTimeout time.Duration
|
||||
runExpectTimeout bool
|
||||
runExpectOutput string
|
||||
runLogOutput bool
|
||||
|
||||
// TODO: customizable agent, currently defaults to the first agent found
|
||||
// if there are multiple
|
||||
connectURL string // http://localhost:4/
|
||||
connectMode string // derp or direct
|
||||
connectHold time.Duration
|
||||
connectInterval time.Duration
|
||||
connectTimeout time.Duration
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
output = &scaletestOutputFlags{}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-workspaces",
|
||||
Short: "Creates many workspaces and waits for them to be ready",
|
||||
Long: `Creates many users, then creates a workspace for each user and waits for them finish building and fully come online. Optionally runs a command inside each workspace, and connects to the workspace over WireGuard.
|
||||
|
||||
It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
|
||||
if count <= 0 {
|
||||
return xerrors.Errorf("--count is required and must be greater than 0")
|
||||
}
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
var tpl codersdk.Template
|
||||
if template == "" {
|
||||
return xerrors.Errorf("--template is required")
|
||||
}
|
||||
if id, err := uuid.Parse(template); err == nil && id != uuid.Nil {
|
||||
tpl, err = client.Template(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by ID %q: %w", template, err)
|
||||
}
|
||||
} else {
|
||||
// List templates in all orgs until we find a match.
|
||||
orgLoop:
|
||||
for _, orgID := range me.OrganizationIDs {
|
||||
tpls, err := client.TemplatesByOrganization(ctx, orgID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list templates in org %q: %w", orgID, err)
|
||||
}
|
||||
|
||||
for _, t := range tpls {
|
||||
if t.Name == template {
|
||||
tpl = t
|
||||
break orgLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tpl.ID == uuid.Nil {
|
||||
return xerrors.Errorf("could not find template %q in any organization", template)
|
||||
}
|
||||
templateVersion, err := client.TemplateVersion(ctx, tpl.ActiveVersionID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version %q: %w", tpl.ActiveVersionID, err)
|
||||
}
|
||||
|
||||
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version schema %q: %w", templateVersion.ID, err)
|
||||
}
|
||||
|
||||
paramsMap := map[string]string{}
|
||||
if parametersFile != "" {
|
||||
fileMap, err := createParameterMapFromFile(parametersFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read parameters file %q: %w", parametersFile, err)
|
||||
}
|
||||
|
||||
paramsMap = fileMap
|
||||
}
|
||||
|
||||
for _, p := range parameters {
|
||||
parts := strings.SplitN(p, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return xerrors.Errorf("invalid parameter %q", p)
|
||||
}
|
||||
|
||||
paramsMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
params := []codersdk.CreateParameterRequest{}
|
||||
for _, p := range parameterSchemas {
|
||||
value, ok := paramsMap[p.Name]
|
||||
if !ok {
|
||||
value = ""
|
||||
}
|
||||
|
||||
params = append(params, codersdk.CreateParameterRequest{
|
||||
Name: p.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: p.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
|
||||
// Do a dry-run to ensure the template and parameters are valid
|
||||
// before we start creating users and workspaces.
|
||||
if !noPlan {
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(ctx, templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: "scaletest",
|
||||
ParameterValues: params,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start dry run workspace creation: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = closeTracing(ctx)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
for i := 0; i < count; i++ {
|
||||
const name = "workspacebuild"
|
||||
id := strconv.Itoa(i)
|
||||
|
||||
username, email, err := newScaleTestUser(id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create scaletest username and email: %w", err)
|
||||
}
|
||||
workspaceName, err := newScaleTestWorkspace(id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create scaletest workspace name: %w", err)
|
||||
}
|
||||
|
||||
config := createworkspaces.Config{
|
||||
User: createworkspaces.UserConfig{
|
||||
// TODO: configurable org
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
Username: username,
|
||||
Email: email,
|
||||
},
|
||||
Workspace: workspacebuild.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
// UserID is set by the test automatically.
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
Name: workspaceName,
|
||||
ParameterValues: params,
|
||||
},
|
||||
NoWaitForAgents: noWaitForAgents,
|
||||
},
|
||||
NoCleanup: noCleanup,
|
||||
}
|
||||
|
||||
if runCommand != "" {
|
||||
config.ReconnectingPTY = &reconnectingpty.Config{
|
||||
// AgentID is set by the test automatically.
|
||||
Init: codersdk.ReconnectingPTYInit{
|
||||
ID: uuid.Nil,
|
||||
Height: 24,
|
||||
Width: 80,
|
||||
Command: runCommand,
|
||||
},
|
||||
Timeout: httpapi.Duration(runTimeout),
|
||||
ExpectTimeout: runExpectTimeout,
|
||||
ExpectOutput: runExpectOutput,
|
||||
LogOutput: runLogOutput,
|
||||
}
|
||||
}
|
||||
if connectURL != "" {
|
||||
config.AgentConn = &agentconn.Config{
|
||||
// AgentID is set by the test automatically.
|
||||
// The ConnectionMode gets validated by the Validate()
|
||||
// call below.
|
||||
ConnectionMode: agentconn.ConnectionMode(connectMode),
|
||||
HoldDuration: httpapi.Duration(connectHold),
|
||||
Connections: []agentconn.Connection{
|
||||
{
|
||||
URL: connectURL,
|
||||
Interval: httpapi.Duration(connectInterval),
|
||||
Timeout: httpapi.Duration(connectTimeout),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = config.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = createworkspaces.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%s", name, id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
// TODO: live progress output
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Running load test...")
|
||||
testCtx, testCancel := strategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, cmd.OutOrStdout())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// Upload traces.
|
||||
if tracingEnabled {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nUploading traces...")
|
||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
|
||||
defer cancel()
|
||||
err := closeTracing(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.IntVarP(cmd.Flags(), &count, "count", "c", "CODER_LOADTEST_COUNT", 1, "Required: Number of workspaces to create.")
|
||||
cliflag.StringVarP(cmd.Flags(), &template, "template", "t", "CODER_LOADTEST_TEMPLATE", "", "Required: Name or ID of the template to use for workspaces.")
|
||||
cliflag.StringVarP(cmd.Flags(), ¶metersFile, "parameters-file", "", "CODER_LOADTEST_PARAMETERS_FILE", "", "Path to a YAML file containing the parameters to use for each workspace.")
|
||||
cliflag.StringArrayVarP(cmd.Flags(), ¶meters, "parameter", "", "CODER_LOADTEST_PARAMETERS", []string{}, "Parameters to use for each workspace. Can be specified multiple times. Overrides any existing parameters with the same name from --parameters-file. Format: key=value")
|
||||
|
||||
cliflag.BoolVarP(cmd.Flags(), &noPlan, "no-plan", "", "CODER_LOADTEST_NO_PLAN", false, "Skip the dry-run step to plan the workspace creation. This step ensures that the given parameters are valid for the given template.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noCleanup, "no-cleanup", "", "CODER_LOADTEST_NO_CLEANUP", false, "Do not clean up resources after the test completes. You can cleanup manually using `coder scaletest cleanup`.")
|
||||
// cliflag.BoolVarP(cmd.Flags(), &noCleanupFailures, "no-cleanup-failures", "", "CODER_LOADTEST_NO_CLEANUP_FAILURES", false, "Do not clean up resources from failed jobs to aid in debugging failures. You can cleanup manually using `coder scaletest cleanup`.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noWaitForAgents, "no-wait-for-agents", "", "CODER_LOADTEST_NO_WAIT_FOR_AGENTS", false, "Do not wait for agents to start before marking the test as succeeded. This can be useful if you are running the test against a template that does not start the agent quickly.")
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &runCommand, "run-command", "", "CODER_LOADTEST_RUN_COMMAND", "", "Command to run inside each workspace using reconnecting-pty (i.e. web terminal protocol). If not specified, no command will be run.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &runTimeout, "run-timeout", "", "CODER_LOADTEST_RUN_TIMEOUT", 5*time.Second, "Timeout for the command to complete.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &runExpectTimeout, "run-expect-timeout", "", "CODER_LOADTEST_RUN_EXPECT_TIMEOUT", false, "Expect the command to timeout. If the command does not finish within the given --run-timeout, it will be marked as succeeded. If the command finishes before the timeout, it will be marked as failed.")
|
||||
cliflag.StringVarP(cmd.Flags(), &runExpectOutput, "run-expect-output", "", "CODER_LOADTEST_RUN_EXPECT_OUTPUT", "", "Expect the command to output the given string (on a single line). If the command does not output the given string, it will be marked as failed.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &runLogOutput, "run-log-output", "", "CODER_LOADTEST_RUN_LOG_OUTPUT", false, "Log the output of the command to the test logs. This should be left off unless you expect small amounts of output. Large amounts of output will cause high memory usage.")
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &connectURL, "connect-url", "", "CODER_LOADTEST_CONNECT_URL", "", "URL to connect to inside the the workspace over WireGuard. If not specified, no connections will be made over WireGuard.")
|
||||
cliflag.StringVarP(cmd.Flags(), &connectMode, "connect-mode", "", "CODER_LOADTEST_CONNECT_MODE", "derp", "Mode to use for connecting to the workspace. Can be 'derp' or 'direct'.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectHold, "connect-hold", "", "CODER_LOADTEST_CONNECT_HOLD", 30*time.Second, "How long to hold the WireGuard connection open for.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectInterval, "connect-interval", "", "CODER_LOADTEST_CONNECT_INTERVAL", time.Second, "How long to wait between making requests to the --connect-url once the connection is established.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectTimeout, "connect-timeout", "", "CODER_LOADTEST_CONNECT_TIMEOUT", 5*time.Second, "Timeout for each request to the --connect-url.")
|
||||
|
||||
tracingFlags.attach(cmd)
|
||||
strategy.attach(cmd)
|
||||
cleanupStrategy.attach(cmd)
|
||||
output.attach(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
runner harness.Runnable
|
||||
|
||||
span trace.Span
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &runnableTraceWrapper{}
|
||||
var _ harness.Cleanable = &runnableTraceWrapper{}
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName, trace.WithNewRoot())
|
||||
defer span.End()
|
||||
r.span = span
|
||||
|
||||
traceID := "unknown trace ID"
|
||||
spanID := "unknown span ID"
|
||||
if span.SpanContext().HasTraceID() {
|
||||
traceID = span.SpanContext().TraceID().String()
|
||||
}
|
||||
if span.SpanContext().HasSpanID() {
|
||||
spanID = span.SpanContext().SpanID().String()
|
||||
}
|
||||
_, _ = fmt.Fprintf(logs, "Trace ID: %s\n", traceID)
|
||||
_, _ = fmt.Fprintf(logs, "Span ID: %s\n\n", spanID)
|
||||
|
||||
// Make a separate span for the run itself so the sub-spans are grouped
|
||||
// neatly. The cleanup span is also a child of the above span so this is
|
||||
// important for readability.
|
||||
ctx2, span2 := r.tracer.Start(ctx, r.spanName+" run")
|
||||
defer span2.End()
|
||||
return r.runner.Run(ctx2, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error {
|
||||
c, ok := r.runner.(harness.Cleanable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.span != nil {
|
||||
ctx = trace.ContextWithSpanContext(ctx, r.span.SpanContext())
|
||||
}
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName+" cleanup")
|
||||
defer span.End()
|
||||
|
||||
return c.Cleanup(ctx, id)
|
||||
}
|
||||
|
||||
// newScaleTestUser returns a random username and email address that can be used
|
||||
// for scale testing. The returned username is prefixed with "scaletest-" and
|
||||
// the returned email address is suffixed with "@scaletest.local".
|
||||
func newScaleTestUser(id string) (username string, email string, err error) {
|
||||
randStr, err := cryptorand.String(8)
|
||||
return fmt.Sprintf("scaletest-%s-%s", randStr, id), fmt.Sprintf("%s-%s@scaletest.local", randStr, id), err
|
||||
}
|
||||
|
||||
// newScaleTestWorkspace returns a random workspace name that can be used for
|
||||
// scale testing. The returned workspace name is prefixed with "scaletest-" and
|
||||
// suffixed with the given id.
|
||||
func newScaleTestWorkspace(id string) (name string, err error) {
|
||||
randStr, err := cryptorand.String(8)
|
||||
return fmt.Sprintf("scaletest-%s-%s", randStr, id), err
|
||||
}
|
||||
|
||||
func isScaleTestUser(user codersdk.User) bool {
|
||||
return strings.HasSuffix(user.Email, "@scaletest.local")
|
||||
}
|
||||
|
||||
func isScaleTestWorkspace(workspace codersdk.Workspace) bool {
|
||||
if !strings.HasPrefix(workspace.OwnerName, "scaletest-") {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(workspace.Name, "scaletest-")
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/scaletest/harness"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestScaleTest(t *testing.T) {
|
||||
t.Skipf("This test is flakey. See https://github.com/coder/coder/issues/4942")
|
||||
t.Parallel()
|
||||
|
||||
// This test does a create-workspaces scale test with --no-cleanup, checks
|
||||
// that the created resources are OK, and then runs a cleanup.
|
||||
t.Run("WorkspaceBuildNoCleanup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
// Write a parameters file.
|
||||
tDir := t.TempDir()
|
||||
paramsFile := filepath.Join(tDir, "params.yaml")
|
||||
outputFile := filepath.Join(tDir, "output.json")
|
||||
|
||||
f, err := os.Create(paramsFile)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(`---
|
||||
param1: foo
|
||||
param2: true
|
||||
param3: 1
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
err = f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, root := clitest.New(t, "scaletest", "create-workspaces",
|
||||
"--count", "2",
|
||||
"--template", template.Name,
|
||||
"--parameters-file", paramsFile,
|
||||
"--parameter", "param1=bar",
|
||||
"--parameter", "param4=baz",
|
||||
"--no-cleanup",
|
||||
// This flag is important for tests because agents will never be
|
||||
// started.
|
||||
"--no-wait-for-agents",
|
||||
// Run and connect flags cannot be tested because they require an
|
||||
// agent.
|
||||
"--concurrency", "2",
|
||||
"--timeout", "30s",
|
||||
"--job-timeout", "15s",
|
||||
"--cleanup-concurrency", "1",
|
||||
"--cleanup-timeout", "30s",
|
||||
"--cleanup-job-timeout", "15s",
|
||||
"--output", "text",
|
||||
"--output", "json:"+outputFile,
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
cancelFunc()
|
||||
<-done
|
||||
|
||||
// Recreate the context.
|
||||
ctx, cancelFunc = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
// Verify the output file.
|
||||
f, err = os.Open(outputFile)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
var res harness.Results
|
||||
err = json.NewDecoder(f).Decode(&res)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, 2, res.TotalRuns)
|
||||
require.EqualValues(t, 2, res.TotalPass)
|
||||
|
||||
// Find the workspaces and users and check that they are what we expect.
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces.Workspaces, 2)
|
||||
|
||||
seenUsers := map[string]struct{}{}
|
||||
for _, w := range workspaces.Workspaces {
|
||||
// Sadly we can't verify params as the API doesn't seem to return
|
||||
// them.
|
||||
|
||||
// Verify that the user is a unique scaletest user.
|
||||
u, err := client.User(ctx, w.OwnerID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := seenUsers[u.ID.String()]
|
||||
require.False(t, ok, "user has more than one workspace")
|
||||
seenUsers[u.ID.String()] = struct{}{}
|
||||
|
||||
require.Contains(t, u.Username, "scaletest-")
|
||||
require.Contains(t, u.Email, "scaletest")
|
||||
}
|
||||
|
||||
require.Len(t, seenUsers, len(workspaces.Workspaces))
|
||||
|
||||
// Check that there are exactly 3 users.
|
||||
users, err := client.Users(ctx, codersdk.UsersRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, len(seenUsers)+1)
|
||||
|
||||
// Cleanup.
|
||||
cmd, root = clitest.New(t, "scaletest", "cleanup",
|
||||
"--cleanup-concurrency", "1",
|
||||
"--cleanup-timeout", "30s",
|
||||
"--cleanup-job-timeout", "15s",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty = ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
done = make(chan any)
|
||||
go func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
cancelFunc()
|
||||
<-done
|
||||
|
||||
// Recreate the context (again).
|
||||
ctx, cancelFunc = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
// Verify that the workspaces are gone.
|
||||
workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces.Workspaces, 0)
|
||||
|
||||
// Verify that the users are gone.
|
||||
users, err = client.Users(ctx, codersdk.UsersRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, 1)
|
||||
})
|
||||
}
|
||||
+294
-118
@@ -81,10 +81,46 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
Use: "server",
|
||||
Short: "Start a Coder server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Main command context for managing cancellation of running
|
||||
// services.
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
go dumpHandler(ctx)
|
||||
|
||||
cfg, err := deployment.Config(cmd.Flags(), vip)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting deployment config: %w", err)
|
||||
}
|
||||
|
||||
// Validate bind addresses.
|
||||
if cfg.Address.Value != "" {
|
||||
cmd.PrintErr(cliui.Styles.Warn.Render("WARN:") + " --address and -a are deprecated, please use --http-address and --tls-address instead")
|
||||
if cfg.TLS.Enable.Value {
|
||||
cfg.HTTPAddress.Value = ""
|
||||
cfg.TLS.Address.Value = cfg.Address.Value
|
||||
} else {
|
||||
cfg.HTTPAddress.Value = cfg.Address.Value
|
||||
cfg.TLS.Address.Value = ""
|
||||
}
|
||||
}
|
||||
if cfg.TLS.Enable.Value && cfg.TLS.Address.Value == "" {
|
||||
return xerrors.Errorf("TLS address must be set if TLS is enabled")
|
||||
}
|
||||
if !cfg.TLS.Enable.Value && cfg.HTTPAddress.Value == "" {
|
||||
return xerrors.Errorf("either HTTP or TLS must be enabled")
|
||||
}
|
||||
|
||||
// Disable rate limits if the `--dangerous-disable-rate-limits` flag
|
||||
// was specified.
|
||||
loginRateLimit := 60
|
||||
filesRateLimit := 12
|
||||
if cfg.RateLimit.DisableAll.Value {
|
||||
cfg.RateLimit.API.Value = -1
|
||||
loginRateLimit = -1
|
||||
filesRateLimit = -1
|
||||
}
|
||||
|
||||
printLogo(cmd)
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
|
||||
@@ -94,11 +130,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
logger = logger.AppendSinks(tracing.SlogSink{})
|
||||
}
|
||||
|
||||
// Main command context for managing cancellation
|
||||
// of running services.
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
// Register signals early on so that graceful shutdown can't
|
||||
// be interrupted by additional signals. Note that we avoid
|
||||
// shadowing cancel() (from above) here because notifyStop()
|
||||
@@ -186,14 +217,54 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}()
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", cfg.Address.Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", cfg.Address.Value, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
var (
|
||||
httpListener net.Listener
|
||||
httpURL *url.URL
|
||||
)
|
||||
if cfg.HTTPAddress.Value != "" {
|
||||
httpListener, err = net.Listen("tcp", cfg.HTTPAddress.Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", cfg.HTTPAddress.Value, err)
|
||||
}
|
||||
defer httpListener.Close()
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
listenAddrStr := httpListener.Addr().String()
|
||||
// For some reason if 0.0.0.0:x is provided as the http address,
|
||||
// httpListener.Addr().String() likes to return it as an ipv6
|
||||
// address (i.e. [::]:x). If the input ip is 0.0.0.0, try to
|
||||
// coerce the output back to ipv4 to make it less confusing.
|
||||
if strings.Contains(cfg.HTTPAddress.Value, "0.0.0.0") {
|
||||
listenAddrStr = strings.ReplaceAll(listenAddrStr, "[::]", "0.0.0.0")
|
||||
}
|
||||
|
||||
// We want to print out the address the user supplied, not the
|
||||
// loopback device.
|
||||
cmd.Println("Started HTTP listener at", (&url.URL{Scheme: "http", Host: listenAddrStr}).String())
|
||||
|
||||
// Set the http URL we want to use when connecting to ourselves.
|
||||
tcpAddr, tcpAddrValid := httpListener.Addr().(*net.TCPAddr)
|
||||
if !tcpAddrValid {
|
||||
return xerrors.Errorf("invalid TCP address type %T", httpListener.Addr())
|
||||
}
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
httpURL = &url.URL{
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tlsConfig *tls.Config
|
||||
httpsListener net.Listener
|
||||
httpsURL *url.URL
|
||||
)
|
||||
if cfg.TLS.Enable.Value {
|
||||
if cfg.TLS.Address.Value == "" {
|
||||
return xerrors.New("tls address must be set if tls is enabled")
|
||||
}
|
||||
|
||||
tlsConfig, err = configureTLS(
|
||||
cfg.TLS.MinVersion.Value,
|
||||
cfg.TLS.ClientAuth.Value,
|
||||
@@ -204,25 +275,63 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure tls: %w", err)
|
||||
}
|
||||
listener = tls.NewListener(listener, tlsConfig)
|
||||
httpsListenerInner, err := net.Listen("tcp", cfg.TLS.Address.Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", cfg.TLS.Address.Value, err)
|
||||
}
|
||||
defer httpsListenerInner.Close()
|
||||
|
||||
httpsListener = tls.NewListener(httpsListenerInner, tlsConfig)
|
||||
defer httpsListener.Close()
|
||||
|
||||
listenAddrStr := httpsListener.Addr().String()
|
||||
// For some reason if 0.0.0.0:x is provided as the https
|
||||
// address, httpsListener.Addr().String() likes to return it as
|
||||
// an ipv6 address (i.e. [::]:x). If the input ip is 0.0.0.0,
|
||||
// try to coerce the output back to ipv4 to make it less
|
||||
// confusing.
|
||||
if strings.Contains(cfg.HTTPAddress.Value, "0.0.0.0") {
|
||||
listenAddrStr = strings.ReplaceAll(listenAddrStr, "[::]", "0.0.0.0")
|
||||
}
|
||||
|
||||
// We want to print out the address the user supplied, not the
|
||||
// loopback device.
|
||||
cmd.Println("Started TLS/HTTPS listener at", (&url.URL{Scheme: "https", Host: listenAddrStr}).String())
|
||||
|
||||
// Set the https URL we want to use when connecting to
|
||||
// ourselves.
|
||||
tcpAddr, tcpAddrValid := httpsListener.Addr().(*net.TCPAddr)
|
||||
if !tcpAddrValid {
|
||||
return xerrors.Errorf("invalid TCP address type %T", httpsListener.Addr())
|
||||
}
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
httpsURL = &url.URL{
|
||||
Scheme: "https",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
}
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
if !valid {
|
||||
return xerrors.New("must be listening on tcp")
|
||||
// Sanity check that at least one listener was started.
|
||||
if httpListener == nil && httpsListener == nil {
|
||||
return xerrors.New("must listen on at least one address")
|
||||
}
|
||||
// If just a port is specified, assume localhost.
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
|
||||
// Prefer HTTP because it's less prone to TLS errors over localhost.
|
||||
localURL := httpsURL
|
||||
if httpURL != nil {
|
||||
localURL = httpURL
|
||||
}
|
||||
// If no access URL is specified, fallback to the
|
||||
// bounds URL.
|
||||
localURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
if cfg.TLS.Enable.Value {
|
||||
localURL.Scheme = "https"
|
||||
|
||||
ctx, httpClient, err := configureHTTPClient(
|
||||
ctx,
|
||||
cfg.TLS.ClientCertFile.Value,
|
||||
cfg.TLS.ClientKeyFile.Value,
|
||||
cfg.TLS.ClientCAFile.Value,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure http client: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -279,6 +388,15 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
cmd.Printf("%s The access URL %s %s, this may cause unexpected problems when creating workspaces. Generate a unique *.try.coder.app URL by not specifying an access URL.\n", cliui.Styles.Warn.Render("Warning:"), cliui.Styles.Field.Render(accessURLParsed.String()), reason)
|
||||
}
|
||||
|
||||
// Redirect from the HTTP listener to the access URL if:
|
||||
// 1. The redirect flag is enabled.
|
||||
// 2. HTTP listening is enabled (obviously).
|
||||
// 3. TLS is enabled (otherwise they're likely using a reverse proxy
|
||||
// which can do this instead).
|
||||
// 4. The access URL has been set manually (not a tunnel).
|
||||
// 5. The access URL is HTTPS.
|
||||
shouldRedirectHTTPToAccessURL := cfg.TLS.RedirectHTTP.Value && cfg.HTTPAddress.Value != "" && cfg.TLS.Enable.Value && tunnel == nil && accessURLParsed.Scheme == "https"
|
||||
|
||||
// A newline is added before for visibility in terminal output.
|
||||
cmd.Printf("\nView the Web UI: %s\n", accessURLParsed.String())
|
||||
|
||||
@@ -293,27 +411,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
return xerrors.Errorf("parse ssh keygen algorithm %s: %w", cfg.SSHKeygenAlgorithm.Value, err)
|
||||
}
|
||||
|
||||
// Validate provided auto-import templates.
|
||||
var (
|
||||
validatedAutoImportTemplates = make([]coderd.AutoImportTemplate, len(cfg.AutoImportTemplates.Value))
|
||||
seenValidatedAutoImportTemplates = make(map[coderd.AutoImportTemplate]struct{}, len(cfg.AutoImportTemplates.Value))
|
||||
)
|
||||
for i, autoImportTemplate := range cfg.AutoImportTemplates.Value {
|
||||
var v coderd.AutoImportTemplate
|
||||
switch autoImportTemplate {
|
||||
case "kubernetes":
|
||||
v = coderd.AutoImportTemplateKubernetes
|
||||
default:
|
||||
return xerrors.Errorf("auto import template %q is not supported", autoImportTemplate)
|
||||
}
|
||||
|
||||
if _, ok := seenValidatedAutoImportTemplates[v]; ok {
|
||||
return xerrors.Errorf("auto import template %q is specified more than once", v)
|
||||
}
|
||||
seenValidatedAutoImportTemplates[v] = struct{}{}
|
||||
validatedAutoImportTemplates[i] = v
|
||||
}
|
||||
|
||||
defaultRegion := &tailcfg.DERPRegion{
|
||||
EmbeddedRelay: true,
|
||||
RegionID: cfg.DERP.Server.RegionID.Value,
|
||||
@@ -371,12 +468,14 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
SSHKeygenAlgorithm: sshKeygenAlgorithm,
|
||||
TracerProvider: tracerProvider,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
AutoImportTemplates: validatedAutoImportTemplates,
|
||||
MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value,
|
||||
AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value,
|
||||
DeploymentConfig: cfg,
|
||||
PrometheusRegistry: prometheus.NewRegistry(),
|
||||
APIRateLimit: cfg.APIRateLimit.Value,
|
||||
APIRateLimit: cfg.RateLimit.API.Value,
|
||||
LoginRateLimit: loginRateLimit,
|
||||
FilesRateLimit: filesRateLimit,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
options.TLSCertificates = tlsConfig.Certificates
|
||||
@@ -424,11 +523,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
return xerrors.Errorf("OIDC issuer URL must be set!")
|
||||
}
|
||||
|
||||
ctx, err := handleOauth2ClientCertificates(ctx, cfg)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure oidc client certificates: %w", err)
|
||||
}
|
||||
|
||||
if cfg.OIDC.IgnoreEmailVerified.Value {
|
||||
logger.Warn(ctx, "coder will not check email_verified for OIDC logins")
|
||||
}
|
||||
@@ -452,8 +546,9 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
Verifier: oidcProvider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDC.ClientID.Value,
|
||||
}),
|
||||
EmailDomain: cfg.OIDC.EmailDomain.Value,
|
||||
AllowSignups: cfg.OIDC.AllowSignups.Value,
|
||||
EmailDomain: cfg.OIDC.EmailDomain.Value,
|
||||
AllowSignups: cfg.OIDC.AllowSignups.Value,
|
||||
UsernameField: cfg.OIDC.UsernameField.Value,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +562,15 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer pingCancel()
|
||||
|
||||
err = sqlDB.PingContext(pingCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the PostgreSQL version is >=13.0.0!
|
||||
version, err := sqlDB.QueryContext(ctx, "SHOW server_version;")
|
||||
if err != nil {
|
||||
@@ -487,10 +591,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}
|
||||
logger.Debug(ctx, "connected to postgresql", slog.F("version", versionStr))
|
||||
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
err = migrations.Up(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("migrate up: %w", err)
|
||||
@@ -604,6 +704,10 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
), cfg.Prometheus.Address.Value, "prometheus")()
|
||||
}
|
||||
|
||||
if cfg.Swagger.Enable.Value {
|
||||
options.SwaggerEndpoint = cfg.Swagger.Enable.Value
|
||||
}
|
||||
|
||||
// We use a separate coderAPICloser so the Enterprise API
|
||||
// can have it's own close functions. This is cleaner
|
||||
// than abstracting the Coder API itself.
|
||||
@@ -613,16 +717,22 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}
|
||||
|
||||
client := codersdk.New(localURL)
|
||||
if cfg.TLS.Enable.Value {
|
||||
// Secure transport isn't needed for locally communicating!
|
||||
if localURL.Scheme == "https" && isLocalhost(localURL.Hostname()) {
|
||||
// The certificate will likely be self-signed or for a different
|
||||
// hostname, so we need to skip verification.
|
||||
client.HTTPClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
|
||||
// This is helpful for tests, but can be silently ignored.
|
||||
// Coder may be ran as users that don't have permission to write in the homedir,
|
||||
// such as via the systemd service.
|
||||
_ = config.URL().Write(client.URL.String())
|
||||
|
||||
// Since errCh only has one buffered slot, all routines
|
||||
// sending on it must be wrapped in a select/default to
|
||||
@@ -651,40 +761,65 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
shutdownConnsCtx, shutdownConns := context.WithCancel(ctx)
|
||||
defer shutdownConns()
|
||||
|
||||
// ReadHeaderTimeout is purposefully not enabled. It caused some issues with
|
||||
// websockets over the dev tunnel.
|
||||
// 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
|
||||
if shouldRedirectHTTPToAccessURL {
|
||||
handler = redirectHTTPToAccessURL(handler, accessURLParsed)
|
||||
}
|
||||
|
||||
// ReadHeaderTimeout is purposefully not enabled. It caused some
|
||||
// issues with websockets over the dev tunnel.
|
||||
// See: https://github.com/coder/coder/pull/3730
|
||||
//nolint:gosec
|
||||
server := &http.Server{
|
||||
// These errors are typically noise like "TLS: EOF". Vault does similar:
|
||||
httpServer := &http.Server{
|
||||
// These errors are typically noise like "TLS: EOF". Vault does
|
||||
// similar:
|
||||
// https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
Handler: coderAPI.RootHandler,
|
||||
Handler: handler,
|
||||
BaseContext: func(_ net.Listener) context.Context {
|
||||
return shutdownConnsCtx
|
||||
},
|
||||
}
|
||||
defer func() {
|
||||
_ = shutdownWithTimeout(server.Shutdown, 5*time.Second)
|
||||
_ = shutdownWithTimeout(httpServer.Shutdown, 5*time.Second)
|
||||
}()
|
||||
|
||||
eg := errgroup.Group{}
|
||||
eg.Go(func() error {
|
||||
// Make sure to close the tunnel listener if we exit so the
|
||||
// errgroup doesn't wait forever!
|
||||
if tunnel != nil {
|
||||
defer tunnel.Listener.Close()
|
||||
// We call this in the routine so we can kill the other listeners if
|
||||
// one of them fails.
|
||||
closeListenersNow := func() {
|
||||
if httpListener != nil {
|
||||
_ = httpListener.Close()
|
||||
}
|
||||
if httpsListener != nil {
|
||||
_ = httpsListener.Close()
|
||||
}
|
||||
if tunnel != nil {
|
||||
_ = tunnel.Listener.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return server.Serve(listener)
|
||||
})
|
||||
if tunnel != nil {
|
||||
eg := errgroup.Group{}
|
||||
if httpListener != nil {
|
||||
eg.Go(func() error {
|
||||
defer listener.Close()
|
||||
|
||||
return server.Serve(tunnel.Listener)
|
||||
defer closeListenersNow()
|
||||
return httpServer.Serve(httpListener)
|
||||
})
|
||||
}
|
||||
if httpsListener != nil {
|
||||
eg.Go(func() error {
|
||||
defer closeListenersNow()
|
||||
return httpServer.Serve(httpsListener)
|
||||
})
|
||||
}
|
||||
if tunnel != nil {
|
||||
eg.Go(func() error {
|
||||
defer closeListenersNow()
|
||||
return httpServer.Serve(tunnel.Listener)
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case errCh <- eg.Wait():
|
||||
@@ -693,9 +828,10 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}()
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(ctx)
|
||||
if !hasFirstUser && err == nil {
|
||||
cmd.Println()
|
||||
cmd.Println("Get started by creating the first user (in a new terminal):")
|
||||
if err != nil {
|
||||
cmd.Println("\nFailed to check for the first user: " + err.Error())
|
||||
} else if !hasFirstUser {
|
||||
cmd.Println("\nGet started by creating the first user (in a new terminal):")
|
||||
cmd.Println(cliui.Styles.Code.Render("coder login " + accessURLParsed.String()))
|
||||
}
|
||||
|
||||
@@ -712,11 +848,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
autobuildExecutor := executor.New(ctx, options.Database, logger, autobuildPoller.C)
|
||||
autobuildExecutor.Run()
|
||||
|
||||
// This is helpful for tests, but can be silently ignored.
|
||||
// Coder may be ran as users that don't have permission to write in the homedir,
|
||||
// such as via the systemd service.
|
||||
_ = config.URL().Write(client.URL.String())
|
||||
|
||||
// Currently there is no way to ask the server to shut
|
||||
// itself down, so any exit signal will result in a non-zero
|
||||
// exit of the server.
|
||||
@@ -753,7 +884,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
// in-flight requests, give in-flight requests 5 seconds to
|
||||
// complete.
|
||||
cmd.Println("Shutting down API server...")
|
||||
err = shutdownWithTimeout(server.Shutdown, 3*time.Second)
|
||||
err = shutdownWithTimeout(httpServer.Shutdown, 3*time.Second)
|
||||
if err != nil {
|
||||
cmd.Printf("API server shutdown took longer than 3s: %s\n", err)
|
||||
} else {
|
||||
@@ -817,7 +948,8 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
var pgRawURL bool
|
||||
postgresBuiltinURLCmd := &cobra.Command{
|
||||
Use: "postgres-builtin-url",
|
||||
Short: "Output the connection URL for the built-in PostgreSQL deployment.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -826,37 +958,49 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "psql %q\n", url)
|
||||
if pgRawURL {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", cliui.Styles.Code.Render(fmt.Sprintf("psql %q", url)))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
}
|
||||
postgresBuiltinServeCmd := &cobra.Command{
|
||||
Use: "postgres-builtin-serve",
|
||||
Short: "Run the built-in PostgreSQL deployment.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
cfg := createConfig(cmd)
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
url, closePg, err := startBuiltinPostgres(cmd.Context(), cfg, logger)
|
||||
ctx, cancel := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer cancel()
|
||||
|
||||
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = closePg() }()
|
||||
|
||||
cmd.Println(cliui.Styles.Code.Render("psql \"" + url + "\""))
|
||||
if pgRawURL {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", cliui.Styles.Code.Render(fmt.Sprintf("psql %q", url)))
|
||||
}
|
||||
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
defer signal.Stop(stopChan)
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
|
||||
<-stopChan
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
postgresBuiltinURLCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
|
||||
postgresBuiltinServeCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
|
||||
|
||||
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd)
|
||||
|
||||
deployment.AttachFlags(root.Flags(), vip, false)
|
||||
|
||||
@@ -1088,19 +1232,27 @@ func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
err = configureCAPool(tlsClientCAFile, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
|
||||
if tlsClientCAFile != "" {
|
||||
caPool := x509.NewCertPool()
|
||||
data, err := os.ReadFile(tlsClientCAFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read %q: %w", tlsClientCAFile, err)
|
||||
return xerrors.Errorf("read %q: %w", tlsClientCAFile, err)
|
||||
}
|
||||
if !caPool.AppendCertsFromPEM(data) {
|
||||
return nil, xerrors.Errorf("failed to parse CA certificate in tls-client-ca-file")
|
||||
return xerrors.Errorf("failed to parse CA certificate in tls-client-ca-file")
|
||||
}
|
||||
tlsConfig.ClientCAs = caPool
|
||||
}
|
||||
|
||||
return tlsConfig, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
|
||||
@@ -1293,7 +1445,7 @@ func startBuiltinPostgres(ctx context.Context, cfg config.Root, logger slog.Logg
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres port: %w", err)
|
||||
}
|
||||
pgPort, err := strconv.Atoi(pgPortRaw)
|
||||
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
|
||||
}
|
||||
@@ -1319,20 +1471,44 @@ func startBuiltinPostgres(ctx context.Context, cfg config.Root, logger slog.Logg
|
||||
return connectionURL, ep.Stop, nil
|
||||
}
|
||||
|
||||
func handleOauth2ClientCertificates(ctx context.Context, cfg *codersdk.DeploymentConfig) (context.Context, error) {
|
||||
if cfg.TLS.ClientCertFile.Value != "" && cfg.TLS.ClientKeyFile.Value != "" {
|
||||
certificates, err := loadCertificates([]string{cfg.TLS.ClientCertFile.Value}, []string{cfg.TLS.ClientKeyFile.Value})
|
||||
func configureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile string, tlsClientCAFile string) (context.Context, *http.Client, error) {
|
||||
if clientCertFile != "" && clientKeyFile != "" {
|
||||
certificates, err := loadCertificates([]string{clientCertFile}, []string{clientKeyFile})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return ctx, nil, err
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
|
||||
tlsClientConfig := &tls.Config{ //nolint:gosec
|
||||
Certificates: certificates,
|
||||
}
|
||||
err = configureCAPool(tlsClientCAFile, tlsClientConfig)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{ //nolint:gosec
|
||||
Certificates: certificates,
|
||||
},
|
||||
TLSClientConfig: tlsClientConfig,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return context.WithValue(ctx, oauth2.HTTPClient, httpClient), httpClient, nil
|
||||
}
|
||||
return ctx, nil
|
||||
return ctx, &http.Client{}, nil
|
||||
}
|
||||
|
||||
func redirectHTTPToAccessURL(handler http.Handler, accessURL *url.URL) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil {
|
||||
http.Redirect(w, r, accessURL.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isLocalhost returns true if the host points to the local machine. Intended to
|
||||
// be called with `u.Hostname()`.
|
||||
func isLocalhost(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
+419
-19
@@ -55,7 +55,7 @@ func TestServer(t *testing.T) {
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -89,7 +89,7 @@ func TestServer(t *testing.T) {
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -118,6 +118,19 @@ func TestServer(t *testing.T) {
|
||||
|
||||
pty.ExpectMatch("psql")
|
||||
})
|
||||
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
got := pty.ReadLine()
|
||||
if !strings.HasPrefix(got, "postgres://") {
|
||||
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
// Validate that a warning is printed that it may not be externally
|
||||
// reachable.
|
||||
@@ -129,7 +142,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://localhost:3000/",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -161,7 +174,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://foobarbaz.mydomain",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -191,7 +204,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -220,7 +233,7 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -236,9 +249,10 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-min-version", "tls9",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -253,9 +267,10 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-client-auth", "something",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -310,7 +325,7 @@ func TestServer(t *testing.T) {
|
||||
args := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
}
|
||||
@@ -331,9 +346,10 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -371,9 +387,10 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", cert1Path,
|
||||
"--tls-key-file", key1Path,
|
||||
"--tls-cert-file", cert2Path,
|
||||
@@ -443,6 +460,389 @@ func TestServer(t *testing.T) {
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLSAndHTTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-redirect-http-to-https=false",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// We can't use waitAccessURL as it will only return the HTTP URL.
|
||||
const httpLinePrefix = "Started HTTP listener at "
|
||||
pty.ExpectMatch(httpLinePrefix)
|
||||
httpLine := pty.ReadLine()
|
||||
httpAddr := strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
|
||||
require.NotEmpty(t, httpAddr)
|
||||
const tlsLinePrefix = "Started TLS/HTTPS listener at "
|
||||
pty.ExpectMatch(tlsLinePrefix)
|
||||
tlsLine := pty.ReadLine()
|
||||
tlsAddr := strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
|
||||
// Verify HTTP
|
||||
httpURL, err := url.Parse(httpAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(httpURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify TLS
|
||||
tlsURL, err := url.Parse(tlsAddr)
|
||||
require.NoError(t, err)
|
||||
client = codersdk.New(tlsURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLSRedirect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
httpListener bool
|
||||
tlsListener bool
|
||||
accessURL string
|
||||
// Empty string means no redirect.
|
||||
expectRedirect string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
httpListener: true,
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "NoTLSListener",
|
||||
httpListener: true,
|
||||
tlsListener: false,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "NoHTTPListener",
|
||||
httpListener: false,
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
httpListenAddr := ""
|
||||
if c.httpListener {
|
||||
httpListenAddr = ":0"
|
||||
}
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
flags := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--cache-dir", t.TempDir(),
|
||||
"--http-address", httpListenAddr,
|
||||
}
|
||||
if c.tlsListener {
|
||||
flags = append(flags,
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
)
|
||||
}
|
||||
if c.accessURL != "" {
|
||||
flags = append(flags, "--access-url", c.accessURL)
|
||||
}
|
||||
|
||||
root, _ := clitest.New(t, flags...)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
var (
|
||||
httpAddr string
|
||||
tlsAddr string
|
||||
)
|
||||
// We can't use waitAccessURL as it will only return the HTTP URL.
|
||||
if c.httpListener {
|
||||
const httpLinePrefix = "Started HTTP listener at "
|
||||
pty.ExpectMatch(httpLinePrefix)
|
||||
httpLine := pty.ReadLine()
|
||||
httpAddr = strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
|
||||
require.NotEmpty(t, httpAddr)
|
||||
}
|
||||
if c.tlsListener {
|
||||
const tlsLinePrefix = "Started TLS/HTTPS listener at "
|
||||
pty.ExpectMatch(tlsLinePrefix)
|
||||
tlsLine := pty.ReadLine()
|
||||
tlsAddr = strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
}
|
||||
|
||||
// Verify HTTP redirects (or not)
|
||||
if c.httpListener {
|
||||
httpURL, err := url.Parse(httpAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(httpURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if c.expectRedirect == "" {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
} else {
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Equal(t, c.expectRedirect, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Verify TLS
|
||||
if c.tlsListener {
|
||||
tlsURL, err := url.Parse(tlsAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(tlsURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CanListenUnspecifiedv4", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "0.0.0.0:0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Started HTTP listener at http://0.0.0.0:")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("CanListenUnspecifiedv6", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "[::]:0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Started HTTP listener at http://[::]:")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("NoAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "",
|
||||
"--tls-enable=false",
|
||||
"--tls-address", "",
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "either HTTP or TLS must be enabled")
|
||||
})
|
||||
|
||||
t.Run("NoTLSAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--tls-enable=true",
|
||||
"--tls-address", "",
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "TLS address must be set if TLS is enabled")
|
||||
})
|
||||
|
||||
// DeprecatedAddress is a test for the deprecated --address flag. If
|
||||
// specified, --http-address and --tls-address are both ignored, a warning
|
||||
// is printed, and the server will either be HTTP-only or TLS-only depending
|
||||
// on if --tls-enable is set.
|
||||
t.Run("DeprecatedAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("HTTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("--address and -a are deprecated")
|
||||
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "http", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("--address and -a are deprecated")
|
||||
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "https", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
})
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
//nolint:paralleltest
|
||||
t.Run("Shutdown", func(t *testing.T) {
|
||||
@@ -456,7 +856,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--provisioner-daemons", "1",
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -483,7 +883,7 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--trace=true",
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -521,7 +921,7 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--telemetry",
|
||||
"--telemetry-url", server.URL,
|
||||
@@ -552,7 +952,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--provisioner-daemons", "1",
|
||||
"--prometheus-enable",
|
||||
@@ -605,7 +1005,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--oauth2-github-allow-everyone",
|
||||
"--oauth2-github-client-id", "fake",
|
||||
@@ -646,7 +1046,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
@@ -674,7 +1074,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--api-rate-limit", val,
|
||||
)
|
||||
@@ -702,7 +1102,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--api-rate-limit", "-1",
|
||||
)
|
||||
|
||||
+2
-2
@@ -71,7 +71,7 @@ func speedtest() *cobra.Command {
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
dur, err := conn.Ping(ctx)
|
||||
dur, p2p, err := conn.Ping(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func speedtest() *cobra.Command {
|
||||
continue
|
||||
}
|
||||
peer := status.Peer[status.Peers()[0]]
|
||||
if peer.CurAddr == "" && direct {
|
||||
if !p2p && direct {
|
||||
cmd.Printf("Waiting for a direct connection... (%dms via %s)\n", dur.Milliseconds(), peer.Relay)
|
||||
continue
|
||||
}
|
||||
|
||||
+209
-4
@@ -1,12 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
@@ -39,6 +43,7 @@ func ssh() *cobra.Command {
|
||||
stdio bool
|
||||
shuffle bool
|
||||
forwardAgent bool
|
||||
forwardGPG bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
)
|
||||
@@ -138,7 +143,7 @@ func ssh() *cobra.Command {
|
||||
if forwardAgent && identityAgent != "" {
|
||||
err = gosshagent.ForwardToRemote(sshClient, identityAgent)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward agent failed: %w", err)
|
||||
return xerrors.Errorf("forward agent: %w", err)
|
||||
}
|
||||
err = gosshagent.RequestAgentForwarding(sshSession)
|
||||
if err != nil {
|
||||
@@ -146,6 +151,22 @@ func ssh() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if forwardGPG {
|
||||
if workspaceAgent.OperatingSystem == "windows" {
|
||||
return xerrors.New("GPG forwarding is not supported for Windows workspaces")
|
||||
}
|
||||
|
||||
err = uploadGPGKeys(ctx, sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("upload GPG public keys and ownertrust to workspace: %w", err)
|
||||
}
|
||||
closer, err := forwardGPGAgent(ctx, cmd.ErrOrStderr(), sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward GPG socket: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
stdoutFile, validOut := cmd.OutOrStdout().(*os.File)
|
||||
stdinFile, validIn := cmd.InOrStdin().(*os.File)
|
||||
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
@@ -199,10 +220,12 @@ func ssh() *cobra.Command {
|
||||
_ = sshSession.WindowChange(height, width)
|
||||
}
|
||||
}
|
||||
|
||||
err = sshSession.Wait()
|
||||
if err != nil {
|
||||
// If the connection drops unexpectedly, we get an ExitMissingError but no other
|
||||
// error details, so try to at least give the user a better message
|
||||
// If the connection drops unexpectedly, we get an
|
||||
// ExitMissingError but no other error details, so try to at
|
||||
// least give the user a better message
|
||||
if errors.Is(err, &gossh.ExitMissingError{}) {
|
||||
return xerrors.New("SSH connection ended unexpectedly")
|
||||
}
|
||||
@@ -216,6 +239,7 @@ func ssh() *cobra.Command {
|
||||
cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace")
|
||||
_ = cmd.Flags().MarkHidden("shuffle")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardAgent, "forward-agent", "A", "CODER_SSH_FORWARD_AGENT", false, "Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
||||
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
||||
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
||||
return cmd
|
||||
@@ -232,7 +256,7 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
|
||||
)
|
||||
if shuffle {
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
Owner: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
@@ -364,3 +388,184 @@ func verifyWorkspaceOutdated(client *codersdk.Client, workspace codersdk.Workspa
|
||||
func buildWorkspaceLink(serverURL *url.URL, workspace codersdk.Workspace) *url.URL {
|
||||
return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerName, workspace.Name)})
|
||||
}
|
||||
|
||||
// runLocal runs a command on the local machine.
|
||||
func runLocal(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var stderr []byte
|
||||
if exitErr := new(exec.ExitError); errors.As(err, &exitErr) {
|
||||
stderr = exitErr.Stderr
|
||||
}
|
||||
|
||||
return out, xerrors.Errorf(
|
||||
"`%s %s` failed: stderr: %s\n\nstdout: %s\n\n%w",
|
||||
name,
|
||||
strings.Join(args, " "),
|
||||
bytes.TrimSpace(stderr),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runRemoteSSH runs a command on a remote machine/workspace via SSH.
|
||||
func runRemoteSSH(sshClient *gossh.Client, stdin io.Reader, cmd string) ([]byte, error) {
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create SSH session")
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
stderr := bytes.NewBuffer(nil)
|
||||
sess.Stdin = stdin
|
||||
sess.Stderr = stderr
|
||||
|
||||
out, err := sess.Output(cmd)
|
||||
if err != nil {
|
||||
return out, xerrors.Errorf(
|
||||
"`%s` failed: stderr: %s\n\nstdout: %s:\n\n%w",
|
||||
cmd,
|
||||
bytes.TrimSpace(stderr.Bytes()),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uploadGPGKeys(ctx context.Context, sshClient *gossh.Client) error {
|
||||
// Check if the agent is running in the workspace already.
|
||||
//
|
||||
// Note: we don't support windows in the workspace for GPG forwarding so
|
||||
// using shell commands is fine.
|
||||
//
|
||||
// Note: we sleep after killing the agent because it doesn't always die
|
||||
// immediately.
|
||||
agentSocketBytes, err := runRemoteSSH(sshClient, nil, `
|
||||
set -eux
|
||||
agent_socket=$(gpgconf --list-dir agent-socket)
|
||||
echo "$agent_socket"
|
||||
if [ -S "$agent_socket" ]; then
|
||||
echo "agent socket exists, attempting to kill it" >&2
|
||||
gpgconf --kill gpg-agent
|
||||
rm -f "$agent_socket"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
test ! -S "$agent_socket"
|
||||
`)
|
||||
agentSocket := strings.TrimSpace(string(agentSocketBytes))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("check if agent socket is running (check if %q exists): %w", agentSocket, err)
|
||||
}
|
||||
if agentSocket == "" {
|
||||
return xerrors.Errorf("agent socket path is empty, check the output of `gpgconf --list-dir agent-socket`")
|
||||
}
|
||||
|
||||
// Read the user's public keys and ownertrust from GPG.
|
||||
pubKeyExport, err := runLocal(ctx, nil, "gpg", "--armor", "--export")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local public keys from GPG: %w", err)
|
||||
}
|
||||
ownerTrustExport, err := runLocal(ctx, nil, "gpg", "--export-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local ownertrust from GPG: %w", err)
|
||||
}
|
||||
|
||||
// Import the public keys and ownertrust into the workspace.
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(pubKeyExport), "gpg --import")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import public keys into workspace: %w", err)
|
||||
}
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(ownerTrustExport), "gpg --import-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import ownertrust into workspace: %w", err)
|
||||
}
|
||||
|
||||
// Kill the agent in the workspace if it was started by one of the above
|
||||
// commands.
|
||||
_, err = runRemoteSSH(sshClient, nil, fmt.Sprintf("gpgconf --kill gpg-agent && rm -f %q", agentSocket))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("kill existing agent in workspace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func localGPGExtraSocket(ctx context.Context) (string, error) {
|
||||
localSocket, err := runLocal(ctx, nil, "gpgconf", "--list-dir", "agent-extra-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get local GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(localSocket)), nil
|
||||
}
|
||||
|
||||
func remoteGPGAgentSocket(sshClient *gossh.Client) (string, error) {
|
||||
remoteSocket, err := runRemoteSSH(sshClient, nil, "gpgconf --list-dir agent-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get remote GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(remoteSocket)), nil
|
||||
}
|
||||
|
||||
// cookieAddr is a special net.Addr accepted by sshForward() which includes a
|
||||
// cookie which is written to the connection before forwarding.
|
||||
type cookieAddr struct {
|
||||
net.Addr
|
||||
cookie []byte
|
||||
}
|
||||
|
||||
// sshForwardRemote starts forwarding connections from a remote listener to a
|
||||
// local address via SSH in a goroutine.
|
||||
//
|
||||
// Accepts a `cookieAddr` as the local address.
|
||||
func sshForwardRemote(ctx context.Context, stderr io.Writer, sshClient *gossh.Client, localAddr, remoteAddr net.Addr) (io.Closer, error) {
|
||||
listener, err := sshClient.Listen(remoteAddr.Network(), remoteAddr.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on remote SSH address %s: %w", remoteAddr.String(), err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
remoteConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Accept SSH listener connection: %+v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer remoteConn.Close()
|
||||
|
||||
localConn, err := net.Dial(localAddr.Network(), localAddr.String())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Dial local address %s: %+v\n", localAddr.String(), err)
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
|
||||
if c, ok := localAddr.(cookieAddr); ok {
|
||||
_, err = localConn.Write(c.cookie)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Write cookie to local connection: %+v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
agent.Bicopy(ctx, localConn, remoteConn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
@@ -20,3 +23,26 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
|
||||
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
|
||||
localSocket, err := localGPGExtraSocket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remoteSocket, err := remoteGPGAgentSocket(sshClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localAddr := &net.UnixAddr{
|
||||
Name: localSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
remoteAddr := &net.UnixAddr{
|
||||
Name: remoteSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
|
||||
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
|
||||
}
|
||||
|
||||
+250
-1
@@ -1,15 +1,20 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +32,7 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -65,6 +71,8 @@ func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.A
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err := client.Workspace(context.Background(), workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return client, workspace, agentToken
|
||||
}
|
||||
@@ -224,7 +232,7 @@ func TestSSH(t *testing.T) {
|
||||
})
|
||||
|
||||
// Start up ssh agent listening on unix socket.
|
||||
tmpdir := t.TempDir()
|
||||
tmpdir := tempDirUnixSocket(t)
|
||||
agentSock := filepath.Join(tmpdir, "agent.sock")
|
||||
l, err := net.Listen("unix", agentSock)
|
||||
require.NoError(t, err)
|
||||
@@ -281,6 +289,224 @@ func TestSSH(t *testing.T) {
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
//nolint:paralleltest // This test uses t.Setenv.
|
||||
t.Run("ForwardGPG", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// While GPG forwarding from a Windows client works, we currently do
|
||||
// not support forwarding to a Windows workspace. Our tests use the
|
||||
// same platform for the "client" and "workspace" as they run in the
|
||||
// same process.
|
||||
t.Skip("Test not supported on windows")
|
||||
}
|
||||
|
||||
// This key is for dean@coder.com.
|
||||
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
||||
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBF6SWkEBEADB8sAhBaT36VQ6HEhAmtKexLldu1HUdXNw16rdF+1wiBzSFfJN
|
||||
aPeX4Y9iFIZgC2wU0wOjJ04BpioyOLtJngbThI5WpeoQ/1yQZOpnDaCMPPLp+uJ+
|
||||
Gy4tMZYWQq21PukrFm3XDRGKjVN58QN6uCPb1S/YzteP8Epmq590GYIYLiAHnMt6
|
||||
5iyxIFhXj/fq5Fddp2+efI7QWvNl2wTNnCaTziOSKYcbNmQpn9gy0WvKktWYtB8E
|
||||
JJtWES0DzgCnDpm/hYx79Wkb+F7qY54y2uauDx+z97QXrON47lsIyGm8/T59ZfSd
|
||||
/yrBqDLHYrHlt9RkFpAnBzO402y2eHsKTB6/EAHv9H2apxahyJlcxGbE5QE+fOJk
|
||||
LdPlako0cSljz0g9Icesr2nZL0MhWwLnwk7DHkg/PUUijkbuR/TD9dti2/yOTFrf
|
||||
Y7DdZpoZ0ZkcGu9lMh2vOTWc96RNCyIZfE5WNDKKo+u5Txzndsc/qIgKohwDSxTC
|
||||
3hAulG5Wt05UeyHBEAAvGV2szG88VsGwd1juqXAbEzk+kLQzNyoQX188/4V4X+MV
|
||||
pY9Wz7JudmQpB/3+YTcA/ziK/+wu3c2wNlr7gMZYMOwDWTLfW64nux7zHWDytrP0
|
||||
HfgJIgqP7F7SnChpTFdb1hr1WDox99ZG+/eDkwxnuXYWm9xx5/crqQ0POQARAQAB
|
||||
tClEZWFuIFNoZWF0aGVyICh3b3JrIGtleSkgPGRlYW5AY29kZXIuY29tPokCVAQT
|
||||
AQgAPhYhBHvfugzH9allN8gGxCe8YzXrURfxBQJeklpBAhsDBQkJZgGABQsJCAcC
|
||||
BhUKCQgLAgQWAgMBAh4BAheAAAoJECe8YzXrURfxIVkP/3UJMzvIjTNF63WiK4xk
|
||||
TXlBbPKodnzUmAJ+8DVXmJMJpNsSI2czw6eFUXMcrT3JMlviOXhRWMLHr2FsQhyS
|
||||
AJOQo0x9z7nntPIkvj96ihCdgRn7VN1WzaMwOOesGPr57StWLE84bg9/R0aSsxtX
|
||||
LgfBCyNkv6FFlruhnw8+JdZJEjvIXQ9swvwD6L68ZLWIWcdnj/CjQmnmgFA+O4UO
|
||||
SFXMUjklbrq8mJ0sAPUUATJK0SOTyqkZPkhqjlTZa8p0XoJF25trhwLhzDi4GPR6
|
||||
SK/9SkqB/go9ZwkNZOjs2tP7eMExy4zQ21MFH09JMKQB7H5CG8GwdMwz4+VKc9aP
|
||||
y9Ncova/p7Y8kJ7oQPWhACJT1jMP6620oC2N/7wwS0Vtc6E9LoPrfXC2TtvOA9qx
|
||||
aOf6riWSjo8BEcXDuMtlW4g6IQFNd0+wcgcKrAd+vPLZnG4rtYL0Etdd1ymBT4pi
|
||||
5E5uT8oUT9rLHX+2tD/E8SE5PzsaKEOJKzcOB8ESb3YBGic7+VvX/AuJuSFsuWnZ
|
||||
FqAUENqfdz6+0dEJe1pfWyje+Q+o7B7u+ffMT4dOQOC8NfHFnz1kU+DA3VDE6xsu
|
||||
3YN1L8KlYON92s9VWDA8VuvmU2d9pq5ysUeg133ftDSwj3X+5GYcBv4VFcSRCBW5
|
||||
w0hDpMDun1t8xcXdo1LQ4R4NuQINBF6SWkEBEADF4Nrhlqc5M3Sz9sNHDJZR68zb
|
||||
4CjkoOpYwsKj/ZCukzRCGKpT5Agn0zOycUjbAyCZVjREeIRRURyAhfpOmZY5yF6b
|
||||
PD93+04OzWk1AaDRmMfvi1Crn/WUEVHIbDaisxDzNuAJgLrt93I/lOz06GczhCb6
|
||||
sPBeKuaXCLl/5LSwTahGWsweeSCmfyrYsOc11T+SjdyWXWXEpzFNNIhvqiEoJCw3
|
||||
IcdktTBJYuHsN4jh5kVemi/ttqRN3z7rBMKR1sPG3ux1MfCfSTSCeZLTN9eVvqm9
|
||||
ne8brk8ZC6sdwlZ9IofPbmSaAh+F5Kfcnd3KjmyQ63t+8plpJ2YH3Fx6IwTwVEQ8
|
||||
Ii3WQInTpBSPqf0EwnzRBvhYeKusRpcmX3JSmosLbd5uhvJdgotzuwZYzgay/6DL
|
||||
OlwElZ//ecXNhU8iYmx1BwNuquvGcGVpkP5eaaT6O9qDznB7TT0xztfAK0LaAuRJ
|
||||
HOFCc8iiHtQ4o0OkRhg/0KkUGBU5Iw5SIDimkgwJMtD3ZiYOqLaXS6kmmVw2u6YD
|
||||
LB8rTpegz/tcX+4uyfnIZ28JCOYFTeaDT4FixFW2hrfo/VJzMI5IIv9XAAmtAiEU
|
||||
f+CY2BT6kg9NkQuke0p4/W8yTaScapYZa5I2bzFpJJyzh1TKE6x3qcbBs9vVX+6E
|
||||
vK4FflNwu9WSWojO2wARAQABiQI8BBgBCAAmFiEEe9+6DMf1qWU3yAbEJ7xjNetR
|
||||
F/EFAl6SWkECGwwFCQlmAYAACgkQJ7xjNetRF/FpnQ//SIYePQzhvWj9drnT2krG
|
||||
dUGSxCN0pA2UQZNkreAaKmyxn2/6xEdxYSz0iUEk+I0HKay+NLCxJ5PDoDBypFtM
|
||||
f0yOnbWRObhim8HmED4JRw678G4hRU7KEN0L/9SUYlsBNbgr1xYM/CUX/Ih9NT+P
|
||||
eApxs2VgjKii6m81nfBCFpWSxAs+TOnbshp8dlDZk9kxjFH9+h1ffgZjntqeyiWe
|
||||
F1UE1Wh32MbJdtc2Y3mrA6i+7+3OXmqMHoiG1obhISgdpaCJ/ub3ywnAmeXSiAKE
|
||||
IuS6CriR71Wqv8LMQ8kPM8On9Q26d1dsKKBnlFop9oexxf1AFsbbf9gkcgb+uNno
|
||||
1Qr/R6l2H1TcV1gmiyQLzVnkgLRORosLvSlFrisrsLv9uTYYgcGvwKiU/o3PTdQg
|
||||
fv0D7LB+a3C9KsCBFjihW3bTOcHKX2sAWEQXZMtKGf5aNTBmWQ+eKWUGpudXIvLE
|
||||
od5lgfk9p8T1R50KDieG/+2X95zxFSYBoPRAfp7JNT7h+TZ55qUmQXZGI1VqhWiq
|
||||
b6y/yqfI17JCm4oWpXYbgeruLuye2c/ptDc3S3d26hbWYiWKVT4bLtUGR0wuE6lS
|
||||
DK0u4LK+mnrYfIvRDYJGx18/nbLpR+ivWLIssJT2Jyyj8w9+hk10XkODySNjHCxj
|
||||
p7KeSZdlk47pMBGOfnvEmoQ=
|
||||
=OxHv
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
gpgPath, err := exec.LookPath("gpg")
|
||||
if err != nil {
|
||||
t.Skip("gpg not found")
|
||||
}
|
||||
gpgConfPath, err := exec.LookPath("gpgconf")
|
||||
if err != nil {
|
||||
t.Skip("gpgconf not found")
|
||||
}
|
||||
gpgAgentPath, err := exec.LookPath("gpg-agent")
|
||||
if err != nil {
|
||||
t.Skip("gpg-agent not found")
|
||||
}
|
||||
|
||||
// Setup GPG home directory on the "client".
|
||||
gnupgHomeClient := tempDirUnixSocket(t)
|
||||
t.Setenv("GNUPGHOME", gnupgHomeClient)
|
||||
|
||||
// Get the agent extra socket path.
|
||||
var (
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
)
|
||||
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
|
||||
extraSocketPath := strings.TrimSpace(stdout.String())
|
||||
|
||||
// Generate private key non-interactively.
|
||||
genKeyScript := `
|
||||
Key-Type: 1
|
||||
Key-Length: 2048
|
||||
Subkey-Type: 1
|
||||
Subkey-Length: 2048
|
||||
Name-Real: Coder Test
|
||||
Name-Email: test@coder.com
|
||||
Expire-Date: 0
|
||||
%no-protection
|
||||
`
|
||||
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
|
||||
c.Stdin = strings.NewReader(genKeyScript)
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, "generate key failed: %s", out)
|
||||
|
||||
// Import a random public key.
|
||||
stdin := strings.NewReader(randPublicKey + "\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import key failed: %s", out)
|
||||
|
||||
// Set ultimate trust on imported key.
|
||||
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import ownertrust failed: %s", out)
|
||||
|
||||
// Start the GPG agent.
|
||||
agentCmd := exec.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
|
||||
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
|
||||
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
|
||||
require.NoError(t, err, "launch agent failed")
|
||||
defer func() {
|
||||
_ = agentProc.Kill()
|
||||
_ = agentPTY.Close()
|
||||
}()
|
||||
|
||||
// Get the agent socket path in the "workspace".
|
||||
gnupgHomeWorkspace := tempDirUnixSocket(t)
|
||||
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
|
||||
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
|
||||
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
|
||||
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GNUPGHOME": gnupgHomeWorkspace,
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
"--forward-gpg",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
// Prevent the test from hanging if the asserts below kill the test
|
||||
// early. This will cause the command to exit with an error, which will
|
||||
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
|
||||
// Without this, the test will hang forever on failure, preventing the
|
||||
// real error from being printed.
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pty.WriteLine("echo hello 'world'")
|
||||
pty.ExpectMatch("hello world")
|
||||
|
||||
// Check the GNUPGHOME was correctly inherited via shell.
|
||||
pty.WriteLine("env && echo env-''-command-done")
|
||||
match := pty.ExpectMatch("env--command-done")
|
||||
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
|
||||
|
||||
// Get the agent extra socket path in the "workspace" via shell.
|
||||
pty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
|
||||
pty.ExpectMatch(workspaceAgentSocketPath)
|
||||
pty.ExpectMatch("gpgconf--agentsocket-command-done")
|
||||
|
||||
// List the keys in the "workspace".
|
||||
pty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
|
||||
listKeysOutput := pty.ExpectMatch("gpg--listkeys-command-done")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
|
||||
|
||||
// Try to sign something. This demonstrates that the forwarding is
|
||||
// working as expected, since the workspace doesn't have access to the
|
||||
// private key directly and must use the forwarded agent.
|
||||
pty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
|
||||
pty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
|
||||
pty.ExpectMatch("Hash:")
|
||||
pty.ExpectMatch("hello world")
|
||||
pty.ExpectMatch("gpg--sign-command-done")
|
||||
|
||||
// And we're done.
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
|
||||
// tGoContext runs fn in a goroutine passing a context that will be
|
||||
@@ -354,3 +580,26 @@ func (*stdioConn) SetReadDeadline(_ time.Time) error {
|
||||
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tempDirUnixSocket returns a temporary directory that can safely hold unix
|
||||
// sockets (probably).
|
||||
//
|
||||
// During tests on darwin we hit the max path length limit for unix sockets
|
||||
// pretty easily in the default location, so this function uses /tmp instead to
|
||||
// get shorter paths.
|
||||
func tempDirUnixSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "darwin" {
|
||||
testName := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("coder-test-%s-", testName))
|
||||
require.NoError(t, err, "create temp dir for gpg test")
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(dir)
|
||||
assert.NoError(t, err, "remove temp dir", dir)
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
@@ -25,3 +32,74 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
|
||||
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
|
||||
// Read TCP port and cookie from extra socket file. A gpg-agent socket
|
||||
// file looks like the following:
|
||||
//
|
||||
// 49955
|
||||
// abcdefghijklmnop
|
||||
//
|
||||
// The first line is the TCP port that gpg-agent is listening on, and
|
||||
// the second line is a 16 byte cookie that MUST be sent as the first
|
||||
// bytes of any connection to this port (otherwise the connection is
|
||||
// closed by gpg-agent).
|
||||
localSocket, err := localGPGExtraSocket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(localSocket)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("open gpg-agent-extra socket file %q: %w", localSocket, err)
|
||||
}
|
||||
|
||||
// Scan lines from file to get port and cookie.
|
||||
var (
|
||||
port uint16
|
||||
cookie []byte
|
||||
scanner = bufio.NewScanner(f)
|
||||
)
|
||||
for i := 0; scanner.Scan(); i++ {
|
||||
switch i {
|
||||
case 0:
|
||||
port64, err := strconv.ParseUint(scanner.Text(), 10, 16)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 1: convert string to integer: %w", localSocket, err)
|
||||
}
|
||||
port = uint16(port64)
|
||||
|
||||
case 1:
|
||||
cookie = scanner.Bytes()
|
||||
if len(cookie) != 16 {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 2: expected 16 bytes, got %v bytes", localSocket, len(cookie))
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: file contains more than 2 lines", localSocket)
|
||||
}
|
||||
}
|
||||
|
||||
err = scanner.Err()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file: %q: %w", localSocket, err)
|
||||
}
|
||||
|
||||
remoteSocket, err := remoteGPGAgentSocket(sshClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localAddr := cookieAddr{
|
||||
Addr: &net.TCPAddr{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Port: int(port),
|
||||
},
|
||||
cookie: cookie,
|
||||
}
|
||||
remoteAddr := &net.UnixAddr{
|
||||
Name: remoteSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
|
||||
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
|
||||
}
|
||||
|
||||
@@ -187,14 +187,25 @@ func TestTemplateCreate(t *testing.T) {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{
|
||||
match: "Create and upload",
|
||||
write: "yes",
|
||||
},
|
||||
{
|
||||
match: "Enter a value:",
|
||||
write: "bingo",
|
||||
},
|
||||
{
|
||||
match: "Confirm create?",
|
||||
write: "yes",
|
||||
},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.EqualError(t, <-execDone, "Parameter value absent in parameter file for \"region\"!")
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("Recreate template with same name (create, delete, create)", func(t *testing.T) {
|
||||
|
||||
@@ -117,6 +117,7 @@ func templatePush() *cobra.Command {
|
||||
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
||||
cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
|
||||
cmd.Flags().StringVarP(&versionName, "name", "", "", "Specify a name for the new template version. It will be automatically generated if not provided.")
|
||||
cmd.Flags().StringArrayVarP(&provisionerTags, "provisioner-tag", "", []string{}, "Specify a set of tags to target provisioner daemons.")
|
||||
cmd.Flags().BoolVar(&alwaysPrompt, "always-prompt", false, "Always prompt all parameters. Does not pull parameter values from active template version")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
// This is for testing!
|
||||
|
||||
Vendored
+2
@@ -23,6 +23,7 @@ Commands:
|
||||
port-forward Forward ports from machine to a workspace
|
||||
publickey Output your Coder public key used for Git operations
|
||||
reset-password Directly connect to the database to reset a user's password
|
||||
scaletest Run a scale test against the Coder API
|
||||
server Start a Coder server
|
||||
state Manually manage Terraform state to fix broken workspaces
|
||||
templates Manage templates
|
||||
@@ -35,6 +36,7 @@ Workspace Commands:
|
||||
create Create a workspace
|
||||
delete Delete a workspace
|
||||
list List workspaces
|
||||
rename Rename a workspace
|
||||
schedule Schedule automated start and stop times for workspaces
|
||||
show Display details of a workspace's resources and agents
|
||||
speedtest Run upload and download tests from your machine to a workspace
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
Add an SSH Host entry for your workspaces "ssh coder.workspace"
|
||||
|
||||
Usage:
|
||||
coder config-ssh [flags]
|
||||
|
||||
Get Started:
|
||||
- You can use -o (or --ssh-option) so set SSH options to be used for all your
|
||||
workspaces:
|
||||
|
||||
[;m$ coder config-ssh -o ForwardAgent=yes[0m
|
||||
|
||||
- You can use --dry-run (or -n) to see the changes that would be made:
|
||||
|
||||
[;m$ coder config-ssh --dry-run[0m
|
||||
|
||||
Flags:
|
||||
-n, --dry-run Perform a trial run with no changes made, showing a diff at
|
||||
the end.
|
||||
-h, --help help for config-ssh
|
||||
--ssh-config-file string Specifies the path to an SSH config.
|
||||
Consumes $CODER_SSH_CONFIG_FILE (default "~/.ssh/config")
|
||||
-o, --ssh-option stringArray Specifies additional SSH options to embed in each host stanza.
|
||||
--use-previous-options Specifies whether or not to keep options from previous run of
|
||||
config-ssh.
|
||||
Consumes $CODER_SSH_USE_PREVIOUS_OPTIONS
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
Create a workspace
|
||||
|
||||
Usage:
|
||||
coder create [name] [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for create
|
||||
--parameter-file string Specify a file path with parameter values.
|
||||
Consumes $CODER_PARAMETER_FILE
|
||||
--start-at coder schedule start --help Specify the workspace autostart schedule. Check
|
||||
coder schedule start --help for the syntax.
|
||||
Consumes $CODER_WORKSPACE_START_AT
|
||||
--stop-after duration Specify a duration after which the workspace
|
||||
should shut down (e.g. 8h).
|
||||
Consumes $CODER_WORKSPACE_STOP_AFTER (default
|
||||
8h0m0s)
|
||||
-t, --template string Specify a template name.
|
||||
Consumes $CODER_TEMPLATE_NAME
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
Delete a workspace
|
||||
|
||||
Usage:
|
||||
coder delete <workspace> [flags]
|
||||
|
||||
Aliases:
|
||||
delete, rm
|
||||
|
||||
Flags:
|
||||
-h, --help help for delete
|
||||
--orphan Delete a workspace without deleting its resources. This can delete a
|
||||
workspace in a broken state, but may also lead to unaccounted cloud resources.
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
Checkout and install a dotfiles repository from a Git URL
|
||||
|
||||
Usage:
|
||||
coder dotfiles [git_repo_url] [flags]
|
||||
|
||||
Get Started:
|
||||
- Check out and install a dotfiles repository without prompts:
|
||||
|
||||
[;m$ coder dotfiles --yes git@github.com:example/dotfiles.git[0m
|
||||
|
||||
Flags:
|
||||
-h, --help help for dotfiles
|
||||
--symlink-dir string Specifies the directory for the dotfiles symlink destinations. If
|
||||
empty will use $HOME.
|
||||
Consumes $CODER_SYMLINK_DIR
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
List workspaces
|
||||
|
||||
Usage:
|
||||
coder list [flags]
|
||||
|
||||
Aliases:
|
||||
list, ls
|
||||
|
||||
Flags:
|
||||
-a, --all Specifies whether all workspaces will be listed or not.
|
||||
-c, --column stringArray Specify a column to filter in the table. Available columns are:
|
||||
workspace, template, status, last_built, outdated, starts_at,
|
||||
stops_after
|
||||
-h, --help help for list
|
||||
--search string Search for a workspace with a query. (default "owner:me")
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
Authenticate with Coder deployment
|
||||
|
||||
Usage:
|
||||
coder login <url> [flags]
|
||||
|
||||
Flags:
|
||||
--first-user-email string Specifies an email address to use if creating the first
|
||||
user for the deployment.
|
||||
Consumes $CODER_FIRST_USER_EMAIL
|
||||
--first-user-password string Specifies a password to use if creating the first user
|
||||
for the deployment.
|
||||
Consumes $CODER_FIRST_USER_PASSWORD
|
||||
--first-user-trial Specifies whether a trial license should be provisioned
|
||||
for the Coder deployment or not.
|
||||
Consumes $CODER_FIRST_USER_TRIAL
|
||||
--first-user-username string Specifies a username to use if creating the first user
|
||||
for the deployment.
|
||||
Consumes $CODER_FIRST_USER_USERNAME
|
||||
-h, --help help for login
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
Unauthenticate your local session
|
||||
|
||||
Usage:
|
||||
coder logout [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for logout
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
Forward ports from machine to a workspace
|
||||
|
||||
Usage:
|
||||
coder port-forward <workspace> [flags]
|
||||
|
||||
Aliases:
|
||||
port-forward, tunnel
|
||||
|
||||
Get Started:
|
||||
- Port forward a single TCP port from 1234 in the workspace to port 5678 on
|
||||
your local machine:
|
||||
|
||||
[;m$ coder port-forward <workspace> --tcp 5678:1234[0m
|
||||
|
||||
- Port forward a single UDP port from port 9000 to port 9000 on your local
|
||||
machine:
|
||||
|
||||
[;m$ coder port-forward <workspace> --udp 9000[0m
|
||||
|
||||
- Port forward multiple TCP ports and a UDP port:
|
||||
|
||||
[;m$ coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53[0m
|
||||
|
||||
- Port forward multiple ports (TCP or UDP) in condensed syntax:
|
||||
|
||||
[;m$ coder port-forward <workspace> --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012[0m
|
||||
|
||||
Flags:
|
||||
-h, --help help for port-forward
|
||||
-p, --tcp stringArray Forward TCP port(s) from the workspace to the local machine.
|
||||
Consumes $CODER_PORT_FORWARD_TCP
|
||||
--udp stringArray Forward UDP port(s) from the workspace to the local machine. The UDP
|
||||
connection has TCP-like semantics to support stateful UDP protocols.
|
||||
Consumes $CODER_PORT_FORWARD_UDP
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
Output your Coder public key used for Git operations
|
||||
|
||||
Usage:
|
||||
coder publickey [flags]
|
||||
|
||||
Aliases:
|
||||
publickey, pubkey
|
||||
|
||||
Flags:
|
||||
-h, --help help for publickey
|
||||
--reset Regenerate your public key. This will require updating the key on any services
|
||||
it's registered with.
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
Rename a workspace
|
||||
|
||||
Usage:
|
||||
coder rename <workspace> <new name> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for rename
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
Directly connect to the database to reset a user's password
|
||||
|
||||
Usage:
|
||||
coder reset-password <username> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for reset-password
|
||||
--postgres-url string URL of a PostgreSQL database to connect to.
|
||||
Consumes $CODER_PG_CONNECTION_URL
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
Perform scale tests against the Coder server.
|
||||
|
||||
Usage:
|
||||
coder scaletest [flags]
|
||||
|
||||
coder scaletest [command]
|
||||
|
||||
Commands:
|
||||
cleanup Cleanup any orphaned scaletest resources
|
||||
create-workspaces Creates many workspaces and waits for them to be ready
|
||||
|
||||
Flags:
|
||||
-h, --help help for scaletest
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
|
||||
Use "coder scaletest [command] --help" for more information about a command.
|
||||
@@ -0,0 +1,32 @@
|
||||
Cleanup scaletest workspaces, then cleanup scaletest users. The strategy flags will apply to each stage of the cleanup process.
|
||||
|
||||
Usage:
|
||||
coder scaletest cleanup [flags]
|
||||
|
||||
Flags:
|
||||
--cleanup-concurrency int Number of concurrent cleanup jobs to run. 0 means
|
||||
unlimited.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_CONCURRENCY (default 1)
|
||||
--cleanup-job-timeout duration Timeout per job. Jobs may take longer to complete under
|
||||
higher concurrency limits.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_JOB_TIMEOUT (default 5m0s)
|
||||
--cleanup-timeout duration Timeout for the entire cleanup run. 0 means unlimited.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_TIMEOUT (default 30m0s)
|
||||
-h, --help help for cleanup
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
@@ -0,0 +1,131 @@
|
||||
Creates many users, then creates a workspace for each user and waits for them finish building and fully come online. Optionally runs a command inside each workspace, and connects to the workspace over WireGuard.
|
||||
|
||||
It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.
|
||||
|
||||
Usage:
|
||||
coder scaletest create-workspaces [flags]
|
||||
|
||||
Flags:
|
||||
--cleanup-concurrency int Number of concurrent cleanup jobs to run. 0 means
|
||||
unlimited.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_CONCURRENCY
|
||||
(default 1)
|
||||
--cleanup-job-timeout duration Timeout per job. Jobs may take longer to complete
|
||||
under higher concurrency limits.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_JOB_TIMEOUT
|
||||
(default 5m0s)
|
||||
--cleanup-timeout duration Timeout for the entire cleanup run. 0 means
|
||||
unlimited.
|
||||
Consumes $CODER_LOADTEST_CLEANUP_TIMEOUT (default
|
||||
30m0s)
|
||||
--concurrency int Number of concurrent jobs to run. 0 means
|
||||
unlimited.
|
||||
Consumes $CODER_LOADTEST_CONCURRENCY (default 1)
|
||||
--connect-hold duration How long to hold the WireGuard connection open
|
||||
for.
|
||||
Consumes $CODER_LOADTEST_CONNECT_HOLD (default 30s)
|
||||
--connect-interval duration How long to wait between making requests to the
|
||||
--connect-url once the connection is established.
|
||||
Consumes $CODER_LOADTEST_CONNECT_INTERVAL (default 1s)
|
||||
--connect-mode string Mode to use for connecting to the workspace. Can
|
||||
be 'derp' or 'direct'.
|
||||
Consumes $CODER_LOADTEST_CONNECT_MODE (default "derp")
|
||||
--connect-timeout duration Timeout for each request to the --connect-url.
|
||||
Consumes $CODER_LOADTEST_CONNECT_TIMEOUT (default 5s)
|
||||
--connect-url string URL to connect to inside the the workspace over
|
||||
WireGuard. If not specified, no connections will
|
||||
be made over WireGuard.
|
||||
Consumes $CODER_LOADTEST_CONNECT_URL
|
||||
-c, --count int Required: Number of workspaces to create.
|
||||
Consumes $CODER_LOADTEST_COUNT (default 1)
|
||||
-h, --help help for create-workspaces
|
||||
--job-timeout duration Timeout per job. Jobs may take longer to complete
|
||||
under higher concurrency limits.
|
||||
Consumes $CODER_LOADTEST_JOB_TIMEOUT (default 5m0s)
|
||||
--no-cleanup coder scaletest cleanup Do not clean up resources after the test
|
||||
completes. You can cleanup manually using coder
|
||||
scaletest cleanup.
|
||||
Consumes $CODER_LOADTEST_NO_CLEANUP
|
||||
--no-plan Skip the dry-run step to plan the workspace
|
||||
creation. This step ensures that the given
|
||||
parameters are valid for the given template.
|
||||
Consumes $CODER_LOADTEST_NO_PLAN
|
||||
--no-wait-for-agents Do not wait for agents to start before marking
|
||||
the test as succeeded. This can be useful if you
|
||||
are running the test against a template that does
|
||||
not start the agent quickly.
|
||||
Consumes $CODER_LOADTEST_NO_WAIT_FOR_AGENTS
|
||||
--output stringArray Output format specs in the format
|
||||
"<format>[:<path>]". Not specifying a path will
|
||||
default to stdout. Available formats: text, json.
|
||||
Consumes $CODER_SCALETEST_OUTPUTS (default [text])
|
||||
--parameter stringArray Parameters to use for each workspace. Can be
|
||||
specified multiple times. Overrides any existing
|
||||
parameters with the same name from
|
||||
--parameters-file. Format: key=value.
|
||||
Consumes $CODER_LOADTEST_PARAMETERS
|
||||
--parameters-file string Path to a YAML file containing the parameters to
|
||||
use for each workspace.
|
||||
Consumes $CODER_LOADTEST_PARAMETERS_FILE
|
||||
--run-command string Command to run inside each workspace using
|
||||
reconnecting-pty (i.e. web terminal protocol). If
|
||||
not specified, no command will be run.
|
||||
Consumes $CODER_LOADTEST_RUN_COMMAND
|
||||
--run-expect-output string Expect the command to output the given string (on
|
||||
a single line). If the command does not output
|
||||
the given string, it will be marked as failed.
|
||||
Consumes $CODER_LOADTEST_RUN_EXPECT_OUTPUT
|
||||
--run-expect-timeout Expect the command to timeout. If the command
|
||||
does not finish within the given --run-timeout,
|
||||
it will be marked as succeeded. If the command
|
||||
finishes before the timeout, it will be marked as
|
||||
failed.
|
||||
Consumes $CODER_LOADTEST_RUN_EXPECT_TIMEOUT
|
||||
--run-log-output Log the output of the command to the test logs.
|
||||
This should be left off unless you expect small
|
||||
amounts of output. Large amounts of output will
|
||||
cause high memory usage.
|
||||
Consumes $CODER_LOADTEST_RUN_LOG_OUTPUT
|
||||
--run-timeout duration Timeout for the command to complete.
|
||||
Consumes $CODER_LOADTEST_RUN_TIMEOUT (default 5s)
|
||||
-t, --template string Required: Name or ID of the template to use for
|
||||
workspaces.
|
||||
Consumes $CODER_LOADTEST_TEMPLATE
|
||||
--timeout duration Timeout for the entire test run. 0 means
|
||||
unlimited.
|
||||
Consumes $CODER_LOADTEST_TIMEOUT (default 30m0s)
|
||||
--trace Whether application tracing data is collected. It
|
||||
exports to a backend configured by environment
|
||||
variables. See:
|
||||
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.
|
||||
Consumes $CODER_LOADTEST_TRACE
|
||||
--trace-coder Whether opentelemetry traces are sent to Coder.
|
||||
We recommend keeping this disabled unless we
|
||||
advise you to enable it.
|
||||
Consumes $CODER_LOADTEST_TRACE_CODER
|
||||
--trace-honeycomb-api-key string Enables trace exporting to Honeycomb.io using the
|
||||
provided API key.
|
||||
Consumes $CODER_LOADTEST_TRACE_HONEYCOMB_API_KEY
|
||||
--trace-propagate Enables trace propagation to the Coder backend,
|
||||
which will be used to correlate server-side spans
|
||||
with client-side spans. Only enable this if the
|
||||
server is configured with the exact same tracing
|
||||
configuration as the client.
|
||||
Consumes $CODER_LOADTEST_TRACE_PROPAGATE
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
Schedule automated start and stop times for workspaces
|
||||
|
||||
Usage:
|
||||
coder schedule { show | start | stop | override } <workspace> [flags]
|
||||
|
||||
coder schedule [command]
|
||||
|
||||
Commands:
|
||||
override-stop Edit stop time of active workspace
|
||||
show Show workspace schedule
|
||||
start Edit workspace start schedule
|
||||
stop Edit workspace stop schedule
|
||||
|
||||
Flags:
|
||||
-h, --help help for schedule
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
|
||||
Use "coder schedule [command] --help" for more information about a command.
|
||||
@@ -0,0 +1,30 @@
|
||||
Override the stop time of a currently running workspace instance.
|
||||
* The new stop time is calculated from *now*.
|
||||
* The new stop time must be at least 30 minutes in the future.
|
||||
* The workspace template may restrict the maximum workspace runtime.
|
||||
|
||||
Usage:
|
||||
coder schedule override-stop <workspace-name> <duration from now> [flags]
|
||||
|
||||
Get Started:
|
||||
[;m$ coder schedule override-stop my-workspace 90m[0m
|
||||
|
||||
Flags:
|
||||
-h, --help help for override-stop
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
Shows the following information for the given workspace:
|
||||
* The automatic start schedule
|
||||
* The next scheduled start time
|
||||
* The duration after which it will stop
|
||||
* The next scheduled stop time
|
||||
|
||||
Usage:
|
||||
coder schedule show <workspace-name> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for show
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
Schedules a workspace to regularly start at a specific time.
|
||||
Schedule format: <start-time> [day-of-week] [location].
|
||||
* Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm.
|
||||
* Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri.
|
||||
Aliases such as @daily are not supported.
|
||||
Default: * (every day)
|
||||
* Location (optional) must be a valid location in the IANA timezone database.
|
||||
If omitted, we will fall back to either the TZ environment variable or /etc/localtime.
|
||||
You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right.
|
||||
|
||||
Usage:
|
||||
coder schedule start <workspace-name> { <start-time> [day-of-week] [location] | manual } [flags]
|
||||
|
||||
Get Started:
|
||||
- Set the workspace to start at 9:30am (in Dublin) from Monday to Friday:
|
||||
|
||||
[;m$ coder schedule start my-workspace 9:30AM Mon-Fri Europe/Dublin[0m
|
||||
|
||||
Flags:
|
||||
-h, --help help for start
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
Schedules a workspace to stop after a given duration has elapsed.
|
||||
* Workspace runtime is measured from the time that the workspace build completed.
|
||||
* The minimum scheduled stop time is 1 minute.
|
||||
* The workspace template may place restrictions on the maximum shutdown time.
|
||||
* Changes to workspace schedules only take effect upon the next build of the workspace,
|
||||
and do not affect a running instance of a workspace.
|
||||
|
||||
When enabling scheduled stop, enter a duration in one of the following formats:
|
||||
* 3h2m (3 hours and two minutes)
|
||||
* 3h (3 hours)
|
||||
* 2m (2 minutes)
|
||||
* 2 (2 minutes)
|
||||
|
||||
Usage:
|
||||
coder schedule stop <workspace-name> { <duration> | manual } [flags]
|
||||
|
||||
Get Started:
|
||||
[;m$ coder schedule stop my-workspace 2h30m[0m
|
||||
|
||||
Flags:
|
||||
-h, --help help for stop
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+31
-7
@@ -14,16 +14,14 @@ Flags:
|
||||
This must be accessible by all
|
||||
provisioned workspaces.
|
||||
Consumes $CODER_ACCESS_URL
|
||||
-a, --address string Bind address of the server.
|
||||
Consumes $CODER_ADDRESS (default
|
||||
"127.0.0.1:3000")
|
||||
--api-rate-limit int Maximum number of requests per minute
|
||||
allowed to the API per user, or per IP
|
||||
address for unauthenticated users.
|
||||
Negative values mean no rate limit. Some
|
||||
API endpoints are always rate limited
|
||||
regardless of this value to prevent
|
||||
denial-of-service attacks.
|
||||
API endpoints have separate strict rate
|
||||
limits regardless of this value to
|
||||
prevent denial-of-service or brute force
|
||||
attacks.
|
||||
Consumes $CODER_API_RATE_LIMIT (default 512)
|
||||
--cache-dir string The directory to cache temporary files.
|
||||
If unspecified and $CACHE_DIRECTORY is
|
||||
@@ -31,6 +29,9 @@ Flags:
|
||||
with systemd.
|
||||
Consumes $CODER_CACHE_DIRECTORY (default
|
||||
"/tmp/coder-cli-test-cache")
|
||||
--dangerous-disable-rate-limits Disables all rate limits. This is not
|
||||
recommended in production.
|
||||
Consumes $CODER_RATE_LIMIT_DISABLE_ALL
|
||||
--derp-config-path string Path to read a DERP mapping from. See:
|
||||
https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
Consumes $CODER_DERP_CONFIG_PATH
|
||||
@@ -65,6 +66,14 @@ Flags:
|
||||
production.
|
||||
Consumes $CODER_EXPERIMENTAL
|
||||
-h, --help help for server
|
||||
--http-address string HTTP bind address of the server. Unset to
|
||||
disable the HTTP endpoint.
|
||||
Consumes $CODER_HTTP_ADDRESS (default
|
||||
"127.0.0.1:3000")
|
||||
--max-token-lifetime duration The maximum lifetime duration for any
|
||||
user creating a token.
|
||||
Consumes $CODER_MAX_TOKEN_LIFETIME
|
||||
(default 720h0m0s)
|
||||
--oauth2-github-allow-everyone Allow all logins, setting this option
|
||||
means allowed orgs and teams must be
|
||||
empty.
|
||||
@@ -107,6 +116,9 @@ Flags:
|
||||
OIDC.
|
||||
Consumes $CODER_OIDC_SCOPES (default
|
||||
[openid,profile,email])
|
||||
--oidc-username-field string OIDC claim field to use as the username.
|
||||
Consumes $CODER_OIDC_USERNAME_FIELD
|
||||
(default "preferred_username")
|
||||
--postgres-url string URL of a PostgreSQL database. If empty,
|
||||
PostgreSQL binaries will be downloaded
|
||||
from Maven
|
||||
@@ -164,6 +176,8 @@ Flags:
|
||||
"ecdsa", or "rsa4096".
|
||||
Consumes $CODER_SSH_KEYGEN_ALGORITHM
|
||||
(default "ed25519")
|
||||
--swagger-enable Expose the swagger endpoint via /swagger.
|
||||
Consumes $CODER_SWAGGER_ENABLE
|
||||
--telemetry Whether telemetry is enabled or not.
|
||||
Coder collects anonymized usage data to
|
||||
help improve our product.
|
||||
@@ -174,6 +188,9 @@ Flags:
|
||||
product. Disabling telemetry also
|
||||
disables this option.
|
||||
Consumes $CODER_TELEMETRY_TRACE
|
||||
--tls-address string HTTPS bind address of the server.
|
||||
Consumes $CODER_TLS_ADDRESS (default
|
||||
"127.0.0.1:3443")
|
||||
--tls-cert-file strings Path to each certificate for TLS. It
|
||||
requires a PEM-encoded file. To configure
|
||||
the listener to use a CA certificate,
|
||||
@@ -188,7 +205,7 @@ Flags:
|
||||
"verify-if-given", or
|
||||
"require-and-verify".
|
||||
Consumes $CODER_TLS_CLIENT_AUTH (default
|
||||
"request")
|
||||
"none")
|
||||
--tls-client-ca-file string PEM-encoded Certificate Authority file
|
||||
used for checking the authenticity of
|
||||
client
|
||||
@@ -212,6 +229,13 @@ Flags:
|
||||
"tls12" or "tls13"
|
||||
Consumes $CODER_TLS_MIN_VERSION (default
|
||||
"tls12")
|
||||
--tls-redirect-http-to-https Whether HTTP requests will be redirected
|
||||
to the access URL (if it's a https URL
|
||||
and TLS is enabled). Requests to local IP
|
||||
addresses are never redirected regardless
|
||||
of this setting.
|
||||
Consumes $CODER_TLS_REDIRECT_HTTP
|
||||
(default true)
|
||||
--trace Whether application tracing data is
|
||||
collected. It exports to a backend
|
||||
configured by environment variables. See:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
Run the built-in PostgreSQL deployment.
|
||||
|
||||
Usage:
|
||||
coder server postgres-builtin-serve [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for postgres-builtin-serve
|
||||
--raw-url Output the raw connection URL instead of a psql command.
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
@@ -0,0 +1,25 @@
|
||||
Output the connection URL for the built-in PostgreSQL deployment.
|
||||
|
||||
Usage:
|
||||
coder server postgres-builtin-url [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for postgres-builtin-url
|
||||
--raw-url Output the raw connection URL instead of a psql command.
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
Display details of a workspace's resources and agents
|
||||
|
||||
Usage:
|
||||
coder show <workspace> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for show
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
Run upload and download tests from your machine to a workspace
|
||||
|
||||
Usage:
|
||||
coder speedtest <workspace> [flags]
|
||||
|
||||
Flags:
|
||||
-d, --direct Specifies whether to wait for a direct connection before testing speed.
|
||||
-h, --help help for speedtest
|
||||
-r, --reverse Specifies whether to run in reverse mode where the client receives and
|
||||
the server sends.
|
||||
-t, --time duration Specifies the duration to monitor traffic. (default 5s)
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
Start a shell into a workspace
|
||||
|
||||
Usage:
|
||||
coder ssh <workspace> [flags]
|
||||
|
||||
Flags:
|
||||
-A, --forward-agent Specifies whether to forward the SSH agent
|
||||
specified in $SSH_AUTH_SOCK.
|
||||
Consumes $CODER_SSH_FORWARD_AGENT
|
||||
-G, --forward-gpg Specifies whether to forward the GPG agent.
|
||||
Unsupported on Windows workspaces, but supports all
|
||||
clients. Requires gnupg (gpg, gpgconf) on both the
|
||||
client and workspace. The GPG agent must already be
|
||||
running locally and will not be started for you. If
|
||||
a GPG agent is already running in the workspace, it
|
||||
will be attempted to be killed.
|
||||
Consumes $CODER_SSH_FORWARD_GPG
|
||||
-h, --help help for ssh
|
||||
--identity-agent string Specifies which identity agent to use (overrides
|
||||
$SSH_AUTH_SOCK), forward agent must also be
|
||||
enabled.
|
||||
Consumes $CODER_SSH_IDENTITY_AGENT
|
||||
--stdio Specifies whether to emit SSH output over
|
||||
stdin/stdout.
|
||||
Consumes $CODER_SSH_STDIO
|
||||
--workspace-poll-interval duration Specifies how often to poll for workspace automated
|
||||
shutdown.
|
||||
Consumes $CODER_WORKSPACE_POLL_INTERVAL (default 1m0s)
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
Start a workspace
|
||||
|
||||
Usage:
|
||||
coder start <workspace> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for start
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
Manually manage Terraform state to fix broken workspaces
|
||||
|
||||
Usage:
|
||||
coder state [flags]
|
||||
|
||||
coder state [command]
|
||||
|
||||
Commands:
|
||||
pull
|
||||
push
|
||||
|
||||
Flags:
|
||||
-h, --help help for state
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
|
||||
Use "coder state [command] --help" for more information about a command.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
Usage:
|
||||
coder state pull <workspace> [file] [flags]
|
||||
|
||||
Flags:
|
||||
-b, --build int Specify a workspace build to target by name.
|
||||
-h, --help help for pull
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
Usage:
|
||||
coder state push <workspace> <file> [flags]
|
||||
|
||||
Flags:
|
||||
-b, --build int Specify a workspace build to target by name.
|
||||
-h, --help help for push
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
Stop a workspace
|
||||
|
||||
Usage:
|
||||
coder stop <workspace> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for stop
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
Templates are written in standard Terraform and describe the infrastructure for workspaces
|
||||
|
||||
Usage:
|
||||
coder templates [flags]
|
||||
|
||||
coder templates [command]
|
||||
|
||||
Aliases:
|
||||
templates, template
|
||||
|
||||
Get Started:
|
||||
- Create a template for developers to create workspaces:
|
||||
|
||||
[;m$ coder templates create[0m
|
||||
|
||||
- Make changes to your template, and plan the changes:
|
||||
|
||||
[;m$ coder templates plan my-template[0m
|
||||
|
||||
- Push an update to the template. Your developers can update their workspaces:
|
||||
|
||||
[;m$ coder templates push my-template[0m
|
||||
|
||||
Commands:
|
||||
create Create a template from the current directory or as specified by flag
|
||||
delete Delete templates
|
||||
edit Edit the metadata of a template by name.
|
||||
init Get started with a templated template.
|
||||
list List all the templates available for the organization
|
||||
plan Plan a template push from the current directory
|
||||
pull Download the latest version of a template to a path.
|
||||
push Push a new template version from the current directory or as specified by flag
|
||||
versions Manage different versions of the specified template
|
||||
|
||||
Flags:
|
||||
-h, --help help for templates
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
|
||||
Use "coder templates [command] --help" for more information about a command.
|
||||
@@ -0,0 +1,31 @@
|
||||
Create a template from the current directory or as specified by flag
|
||||
|
||||
Usage:
|
||||
coder templates create [name] [flags]
|
||||
|
||||
Flags:
|
||||
--default-ttl duration Specify a default TTL for workspaces created from this
|
||||
template. (default 24h0m0s)
|
||||
-d, --directory string Specify the directory to create from (default
|
||||
"/tmp/coder-cli-test-workdir")
|
||||
-h, --help help for create
|
||||
--parameter-file string Specify a file path with parameter values.
|
||||
--provisioner-tag stringArray Specify a set of tags to target provisioner daemons.
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
@@ -0,0 +1,25 @@
|
||||
Delete templates
|
||||
|
||||
Usage:
|
||||
coder templates delete [name...] [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for delete
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
Edit the metadata of a template by name.
|
||||
|
||||
Usage:
|
||||
coder templates edit <template> [flags]
|
||||
|
||||
Flags:
|
||||
--allow-user-cancel-workspace-jobs Allow users to cancel in-progress workspace jobs.
|
||||
(default true)
|
||||
--default-ttl duration Edit the template default time before shutdown -
|
||||
workspaces created from this template to this value.
|
||||
--description string Edit the template description
|
||||
--display-name string Edit the template display name
|
||||
-h, --help help for edit
|
||||
--icon string Edit the template icon path
|
||||
--name string Edit the template name
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
Get started with a templated template.
|
||||
|
||||
Usage:
|
||||
coder templates init [directory] [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for init
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
List all the templates available for the organization
|
||||
|
||||
Usage:
|
||||
coder templates list [flags]
|
||||
|
||||
Aliases:
|
||||
list, ls
|
||||
|
||||
Flags:
|
||||
-c, --column stringArray Specify a column to filter in the table. (default
|
||||
[name,last_updated,used_by])
|
||||
-h, --help help for list
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
Plan a template push from the current directory
|
||||
|
||||
Usage:
|
||||
coder templates plan <directory> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for plan
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
Download the latest version of a template to a path.
|
||||
|
||||
Usage:
|
||||
coder templates pull <name> [destination] [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for pull
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
Push a new template version from the current directory or as specified by flag
|
||||
|
||||
Usage:
|
||||
coder templates push [template] [flags]
|
||||
|
||||
Flags:
|
||||
--always-prompt Always prompt all parameters. Does not pull parameter
|
||||
values from active template version
|
||||
-d, --directory string Specify the directory to create from (default
|
||||
"/tmp/coder-cli-test-workdir")
|
||||
-h, --help help for push
|
||||
--name string Specify a name for the new template version. It will be
|
||||
automatically generated if not provided.
|
||||
--parameter-file string Specify a file path with parameter values.
|
||||
--provisioner-tag stringArray Specify a set of tags to target provisioner daemons.
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
@@ -0,0 +1,39 @@
|
||||
Manage different versions of the specified template
|
||||
|
||||
Usage:
|
||||
coder templates versions [flags]
|
||||
|
||||
coder templates versions [command]
|
||||
|
||||
Aliases:
|
||||
versions, version
|
||||
|
||||
Get Started:
|
||||
- List versions of a specific template:
|
||||
|
||||
[;m$ coder templates versions list my-template[0m
|
||||
|
||||
Commands:
|
||||
list List all the versions of the specified template
|
||||
|
||||
Flags:
|
||||
-h, --help help for versions
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
|
||||
Use "coder templates versions [command] --help" for more information about a command.
|
||||
@@ -0,0 +1,24 @@
|
||||
List all the versions of the specified template
|
||||
|
||||
Usage:
|
||||
coder templates versions list <template> [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for list
|
||||
|
||||
Global Flags:
|
||||
--global-config coder Path to the global coder config directory.
|
||||
Consumes $CODER_CONFIG_DIR (default "/tmp/coder-cli-test-config")
|
||||
--header stringArray HTTP headers added to all requests. Provide as "Key=Value".
|
||||
Consumes $CODER_HEADER
|
||||
--no-feature-warning Suppress warnings about unlicensed features.
|
||||
Consumes $CODER_NO_FEATURE_WARNING
|
||||
--no-version-warning Suppress warning when client and server versions do not match.
|
||||
Consumes $CODER_NO_VERSION_WARNING
|
||||
--token string Specify an authentication token. For security reasons setting
|
||||
CODER_SESSION_TOKEN is preferred.
|
||||
Consumes $CODER_SESSION_TOKEN
|
||||
--url string URL to a deployment.
|
||||
Consumes $CODER_URL
|
||||
-v, --verbose Enable verbose output.
|
||||
Consumes $CODER_VERBOSE
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user