Compare commits
471 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f75f6b8cc | |||
| 2bb9b4ac80 | |||
| f05609b4da | |||
| 19ae411f05 | |||
| a79f4a095d | |||
| 7a864bdb28 | |||
| e161c45b47 | |||
| a69137b1f7 | |||
| d5af536ea2 | |||
| 2c309194e9 | |||
| 8360357834 | |||
| 909fbb6d2c | |||
| 4c799798c6 | |||
| 779c6549b4 | |||
| 81c29c018a | |||
| fdad136905 | |||
| 9c22c51d3b | |||
| 26876dc734 | |||
| 99306642bb | |||
| 22cc6a3fb6 | |||
| 786ad8d8b1 | |||
| 3b7b96ac28 | |||
| f0f39b4892 | |||
| 84da6056b2 | |||
| 4cbbd1376d | |||
| fac7c02eeb | |||
| 5e4931efaf | |||
| 5e60879fb8 | |||
| 6e3330a03f | |||
| 15c862fcb5 | |||
| 80bde1e2c9 | |||
| 860e2829c5 | |||
| cde7ff8a2d | |||
| 51f17b1820 | |||
| 41ae01d2e9 | |||
| 5df7872661 | |||
| 6fb8aff6d0 | |||
| ebdfdc749d | |||
| 1c4e1d8ded | |||
| 2d0a69ba47 | |||
| 733f58c76d | |||
| a54de6093b | |||
| 2157bff13f | |||
| d355783faa | |||
| a064678b8a | |||
| a56df46d0f | |||
| c0c83f17b2 | |||
| b171cb562c | |||
| 2dbe00ae44 | |||
| 6189035e98 | |||
| 77afdf71dc | |||
| 32fbd10a1f | |||
| ab9cba9396 | |||
| 4432cd08d6 | |||
| e6da7afd33 | |||
| 6f3f7f2937 | |||
| af59e2bcfa | |||
| 22f6400ea5 | |||
| b46d0d693f | |||
| 049984ce7f | |||
| 4493649d7e | |||
| 4827d9edb8 | |||
| d803bb76d5 | |||
| 2ed0eafd75 | |||
| fe725f76bb | |||
| 1617268859 | |||
| 8dba66c535 | |||
| 7a1731b620 | |||
| d60ec3e4bf | |||
| 5655ec6862 | |||
| 0ccab0c420 | |||
| b5e5959649 | |||
| 3da33d23a4 | |||
| e17ed9f5e6 | |||
| 33f2c8fef5 | |||
| f6da0a6945 | |||
| 1dc477819e | |||
| f24547ecb1 | |||
| 691495d761 | |||
| f6effdb63e | |||
| bde4ffebe5 | |||
| c63dcf13c2 | |||
| a5f3f02ef8 | |||
| e7ebcb54dc | |||
| c82e38e2d8 | |||
| 4155b085b7 | |||
| bed37b4208 | |||
| 135a4d87f1 | |||
| b86bce8494 | |||
| e3ae664a29 | |||
| dd9e1f3d3f | |||
| 71a893764e | |||
| b81d8464df | |||
| d9e22d74ba | |||
| 3724d81413 | |||
| 46fe59f5e7 | |||
| 060eeed5c3 | |||
| bdddc3e7ae | |||
| d6947aeaca | |||
| b45c445255 | |||
| a655f03a1e | |||
| 4fe221a700 | |||
| 968d7e4dc5 | |||
| e70b3f2973 | |||
| 5931d12d4b | |||
| 90bc5d5b5f | |||
| a5e386e54b | |||
| f096915c27 | |||
| a422cc00e8 | |||
| 77fd34be94 | |||
| b359dbbd8b | |||
| 571f5d0e02 | |||
| 2c2bbcc019 | |||
| cf9abe3a6c | |||
| 2285a5e8a0 | |||
| a750b1948b | |||
| 95ff29c2be | |||
| dffd7953bc | |||
| 6c90701a73 | |||
| aab9e3a0f7 | |||
| fd2f9dc176 | |||
| 381d6674ca | |||
| 8b424f03c2 | |||
| f60f06e2c6 | |||
| 2384e9c565 | |||
| c16b93847a | |||
| 2478012827 | |||
| 41e52310bf | |||
| 5fe4819669 | |||
| a5e8911d67 | |||
| ea7e55fcf9 | |||
| 9ff313a260 | |||
| a70e722e7f | |||
| 2fab310ca4 | |||
| 496138b086 | |||
| 026b1cd2a4 | |||
| 4df1031f8b | |||
| 7b49517c18 | |||
| 5f089cb5eb | |||
| 5d9263f050 | |||
| e6426d477f | |||
| f5242be0d1 | |||
| 944c9f6307 | |||
| be00e2541c | |||
| 92c5be971c | |||
| c4b70f3ae1 | |||
| 57ad53c850 | |||
| f545586320 | |||
| e8e61250a6 | |||
| 445811b0e0 | |||
| b9b402cd0c | |||
| e27f7accd7 | |||
| ab1f6ce090 | |||
| 271d68c862 | |||
| 01ebfdc9dd | |||
| c9f3acabd3 | |||
| 8ef0306c08 | |||
| 936bd5b231 | |||
| bca6244c4e | |||
| 61dcf643e8 | |||
| e6f5623627 | |||
| f9ae105a26 | |||
| d5e2454b1b | |||
| 52ace4b207 | |||
| 89bf8dd169 | |||
| 4a6fc40949 | |||
| c162c0f284 | |||
| 69fce0488e | |||
| 480f3b6e43 | |||
| aa53b86a2d | |||
| ea4a845248 | |||
| ac4adabb0a | |||
| 5290d5b14a | |||
| 9c1d67e192 | |||
| e6a3ce7180 | |||
| b31b0fd189 | |||
| 88b5d42967 | |||
| f4d6afb01d | |||
| fa5b6125a9 | |||
| 23176bf036 | |||
| cf8d4029fb | |||
| 91ef8d90d5 | |||
| 896158c352 | |||
| f5db4bc8be | |||
| d5d9cc8d8a | |||
| 3980f15340 | |||
| 4eaa2d63b0 | |||
| 12314d7dc5 | |||
| b423218615 | |||
| b4a1c32ed3 | |||
| 0d08065488 | |||
| ce36a84dd5 | |||
| a911ddaa7b | |||
| 7ad87505c8 | |||
| e49f41652f | |||
| 8487127f5c | |||
| 33c6260efb | |||
| eaf1b95e70 | |||
| 2312bc4a6e | |||
| 7880b941b8 | |||
| a1212014df | |||
| 90c4d5d28a | |||
| 981cac5e28 | |||
| 8a5760a2fe | |||
| e0d48e7d79 | |||
| a753703e47 | |||
| cf93fbd39a | |||
| b20cb993bd | |||
| e663eaad96 | |||
| 3d58e6912a | |||
| e2bea2d20f | |||
| cc694a55bc | |||
| 52ecd35c8f | |||
| b0a16150a3 | |||
| 5c54d8b8cd | |||
| 496beae807 | |||
| bfc8a1094b | |||
| 721957dee3 | |||
| 43a441fe63 | |||
| dd8eab5675 | |||
| 16d8cc4176 | |||
| e7b8318b87 | |||
| 233492b75d | |||
| 5da4b5358a | |||
| 98011570be | |||
| 8735f51047 | |||
| 1cd5f38cb0 | |||
| 8830ddfd56 | |||
| 08412fd1af | |||
| b678309fc9 | |||
| de66f0d540 | |||
| 5c5ddc6b23 | |||
| 78ede50be8 | |||
| 322a4d93e1 | |||
| 36384aa3c1 | |||
| bef9e72078 | |||
| f65c7ca6b3 | |||
| 1213162163 | |||
| 26c69525d1 | |||
| 138887de7e | |||
| dbfeb5630c | |||
| c3731a1be0 | |||
| 443e2180fa | |||
| 882832cc51 | |||
| d2ae16dd22 | |||
| ba8dd496c3 | |||
| bbb208e29c | |||
| 6a245ab1cc | |||
| 73afdd7c09 | |||
| 8afdf24d10 | |||
| f67acac2b7 | |||
| 37628c8b5b | |||
| b045734b6a | |||
| 0e58772f5b | |||
| 918c37c358 | |||
| 8819f798f8 | |||
| 546a8931aa | |||
| b91b4533d8 | |||
| ff69c0e70f | |||
| a0a959c7a5 | |||
| 341b7caff6 | |||
| 320cd3f3bc | |||
| 8e5aefb841 | |||
| 9c563af459 | |||
| 08cce81ac8 | |||
| f0df0686f9 | |||
| 2ed70c7af9 | |||
| 36e97e3fa1 | |||
| 9e346b3251 | |||
| 1f3b7b658f | |||
| bd8437b679 | |||
| a040bcc0cf | |||
| 0374af23b2 | |||
| b42e2ae81f | |||
| 45eb26d5d0 | |||
| 41145a6842 | |||
| 6b68fbbf18 | |||
| 56b996532f | |||
| 47c3d72294 | |||
| 537b9df357 | |||
| 2117eb4f31 | |||
| 6ed4e21e8b | |||
| 6252f782d8 | |||
| 501cfa9e8d | |||
| ea1b03f7c9 | |||
| a13614e93d | |||
| 28b2bbd095 | |||
| c6fb469655 | |||
| 99f5f44482 | |||
| 35d4766810 | |||
| 53c456a442 | |||
| b19d644162 | |||
| c377cd0fa9 | |||
| f0eddbaab4 | |||
| e37bff6a85 | |||
| 63956eafbf | |||
| 7f5dcc3d6c | |||
| 1b0560ceb4 | |||
| 985fac642e | |||
| 145d101512 | |||
| 77e71f3ca4 | |||
| db7877012c | |||
| 6ebadabe4e | |||
| 70fd78673d | |||
| bbc1a9a1d8 | |||
| 592ce3b118 | |||
| 5f7cce775b | |||
| 4420985fad | |||
| e558a252e7 | |||
| b55cb0cc73 | |||
| f3bbf627a3 | |||
| 1d777c41f2 | |||
| 8ae28a321e | |||
| 8db87c6bae | |||
| cd7b36d41a | |||
| eb48341696 | |||
| e821b98918 | |||
| 0cf713869b | |||
| f76ef98a32 | |||
| f91a0d8c37 | |||
| de16e29566 | |||
| d6543c042f | |||
| dad242a788 | |||
| 54cc587dad | |||
| 967d25fdf7 | |||
| deebfcbd53 | |||
| dcab87358e | |||
| 1229fda1a6 | |||
| 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 |
+34
-34
@@ -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,25 +39,25 @@ 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
|
||||
ENV GO_VERSION=1.20
|
||||
ENV GOPATH=$HOME/go-packages
|
||||
ENV GOROOT=$HOME/go
|
||||
ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Generated files
|
||||
coderd/apidoc/docs.go linguist-generated=true
|
||||
coderd/apidoc/swagger.json linguist-generated=true
|
||||
coderd/database/dump.sql linguist-generated=true
|
||||
peerbroker/proto/*.go linguist-generated=true
|
||||
provisionerd/proto/*.go linguist-generated=true
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
site/ @coder/frontend
|
||||
docs/ @coder/docs
|
||||
README.md @coder/docs
|
||||
ADOPTERS.md @coder/docs
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
name: coder
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -28,22 +28,73 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
typos:
|
||||
runs-on: ubuntu-latest
|
||||
lint:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: typos-action
|
||||
uses: crate-ci/typos@v1.13.3
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Install Go!
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.20"
|
||||
|
||||
# Check for any typos!
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.13.9
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
- name: Fix Helper
|
||||
- name: Fix the typos
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
echo "::notice:: you can automatically fix typos from your CLI:
|
||||
cargo install typos-cli
|
||||
typos -c .github/workflows/typos.toml -w"
|
||||
|
||||
# Check for Go linting errors!
|
||||
- name: Lint Go
|
||||
uses: golangci/golangci-lint-action@v3.3.1
|
||||
with:
|
||||
version: v1.51.0
|
||||
|
||||
- name: Lint shell scripts
|
||||
uses: ludeeus/action-shellcheck@2.0.0
|
||||
env:
|
||||
SHELLCHECK_OPTS: --external-sources
|
||||
with:
|
||||
ignore: node_modules
|
||||
|
||||
# Lint our dashboard!
|
||||
- name: Cache node_modules
|
||||
id: cache-node
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
- name: Lint TypeScript
|
||||
run: yarn lint
|
||||
working-directory: site
|
||||
|
||||
# Make sure the Helm chart is linted!
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: v3.9.2
|
||||
- name: Lint Helm chart
|
||||
run: |
|
||||
cd helm
|
||||
make lint
|
||||
|
||||
# Ensure AGPL and Enterprise are separated!
|
||||
- name: Check for AGPL code importing Enterprise...
|
||||
run: ./scripts/check_enterprise_imports.sh
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -70,108 +121,16 @@ jobs:
|
||||
- 'site/**'
|
||||
k8s:
|
||||
- 'helm/**'
|
||||
- Dockerfile
|
||||
- scripts/Dockerfile
|
||||
- scripts/Dockerfile.base
|
||||
- scripts/helm.sh
|
||||
- id: debug
|
||||
run: |
|
||||
echo "${{ toJSON(steps.filter )}}"
|
||||
|
||||
# Debug step
|
||||
debug-inputs:
|
||||
needs:
|
||||
- changes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: log
|
||||
run: |
|
||||
echo "${{ toJSON(needs) }}"
|
||||
|
||||
style-lint-golangci:
|
||||
name: style/lint/golangci
|
||||
timeout-minutes: 5
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3.3.1
|
||||
with:
|
||||
version: v1.48.0
|
||||
|
||||
check-enterprise-imports:
|
||||
name: check/enterprise-imports
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check imports of enterprise code
|
||||
run: ./scripts/check_enterprise_imports.sh
|
||||
|
||||
style-lint-shellcheck:
|
||||
name: style/lint/shellcheck
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@1.1.0
|
||||
env:
|
||||
SHELLCHECK_OPTS: --external-sources
|
||||
with:
|
||||
ignore: node_modules
|
||||
|
||||
style-lint-typescript:
|
||||
name: "style/lint/typescript"
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- name: "yarn lint"
|
||||
run: yarn lint
|
||||
working-directory: site
|
||||
|
||||
style-lint-k8s:
|
||||
name: "style/lint/k8s"
|
||||
timeout-minutes: 5
|
||||
needs: changes
|
||||
if: needs.changes.outputs.k8s == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: v3.9.2
|
||||
|
||||
- name: cd helm && make lint
|
||||
run: |
|
||||
cd helm
|
||||
make lint
|
||||
|
||||
gen:
|
||||
name: "style/gen"
|
||||
timeout-minutes: 8
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
steps:
|
||||
@@ -193,35 +152,37 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOCACHE }}
|
||||
key: ${{ github.job }}-go-build-${{ hashFiles('**/go.sum', '**/**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
key: ${{ github.job }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install sqlc
|
||||
run: |
|
||||
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.13.0/sqlc_1.13.0_linux_amd64.tar.gz | sudo tar -C /usr/bin -xz sqlc
|
||||
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.16.0/sqlc_1.16.0_linux_amd64.tar.gz | sudo tar -C /usr/bin -xz sqlc
|
||||
- name: Install protoc-gen-go
|
||||
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- name: Install protoc-gen-go-drpc
|
||||
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: |
|
||||
@@ -241,8 +202,7 @@ jobs:
|
||||
- name: Check for unstaged files
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
style-fmt:
|
||||
name: "style/fmt"
|
||||
fmt:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -278,8 +238,7 @@ jobs:
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
test-go:
|
||||
name: "test/go"
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -292,33 +251,36 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
# Sadly the new "set output" syntax (of writing env vars to
|
||||
# $GITHUB_OUTPUT) does not work on both powershell and bash so we use the
|
||||
# deprecated syntax here.
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
echo "::set-output name=GOCACHE::$(go env GOCACHE)"
|
||||
echo "::set-output name=GOMODCACHE::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOCACHE }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.**', '**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
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:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.8.2
|
||||
tag: v1.9.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -333,35 +295,13 @@ jobs:
|
||||
# prevents test caching, so we disable it on alternate operating
|
||||
# systems.
|
||||
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
|
||||
echo ::set-output name=cover::true
|
||||
echo "cover=true" >> $GITHUB_OUTPUT
|
||||
export COVERAGE_FLAGS='-covermode=atomic -coverprofile="gotests.coverage" -coverpkg=./...'
|
||||
else
|
||||
echo ::set-output name=cover::false
|
||||
echo "cover=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
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()
|
||||
with:
|
||||
name: gotestsum-debug-${{ matrix.os }}.json
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=5m -short -failfast $COVERAGE_FLAGS
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
@@ -382,9 +322,8 @@ jobs:
|
||||
files: ./gotests.coverage
|
||||
flags: unittest-go-${{ matrix.os }}
|
||||
|
||||
test-go-postgres:
|
||||
name: "test/go/postgres"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
test-go-psql:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
@@ -395,33 +334,33 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOCACHE }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum', '**/**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
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:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.8.2
|
||||
tag: v1.9.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -430,30 +369,7 @@ jobs:
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
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()
|
||||
with:
|
||||
name: gotestsum-debug-postgres.json
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
@@ -472,11 +388,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./gotests.coverage
|
||||
flags: unittest-go-postgres-${{ matrix.os }}
|
||||
flags: unittest-go-postgres-linux
|
||||
|
||||
deploy:
|
||||
name: "deploy"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
if: |
|
||||
@@ -497,28 +413,28 @@ 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:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOCACHE }}
|
||||
key: ${{ runner.os }}-release-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Cache Node
|
||||
@@ -572,8 +488,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
test-js:
|
||||
name: "test/js"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -596,7 +511,7 @@ jobs:
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- run: yarn test:coverage
|
||||
- run: yarn test:ci
|
||||
working-directory: site
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
@@ -612,16 +527,11 @@ jobs:
|
||||
flags: unittest-js
|
||||
|
||||
test-e2e:
|
||||
name: "test/e2e/${{ matrix.os }}"
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -636,7 +546,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -650,19 +560,19 @@ jobs:
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOCACHE }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Build
|
||||
@@ -673,9 +583,6 @@ jobs:
|
||||
- run: yarn playwright:install
|
||||
working-directory: site
|
||||
|
||||
- run: yarn playwright:install-deps
|
||||
working-directory: site
|
||||
|
||||
- run: yarn playwright:test
|
||||
env:
|
||||
DEBUG: pw:api
|
||||
@@ -733,23 +640,3 @@ jobs:
|
||||
buildScriptName: "storybook:build"
|
||||
projectToken: 695c25b6cb65
|
||||
workingDir: "./site"
|
||||
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:
|
||||
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:
|
||||
use-quiet-mode: yes
|
||||
use-verbose-mode: yes
|
||||
check-modified-files-only: yes
|
||||
base-branch: main
|
||||
config-file: .github/workflows/mlc_config.json
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened,closed,synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.2.1
|
||||
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 }}
|
||||
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'
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: dependabot*
|
||||
@@ -0,0 +1,169 @@
|
||||
name: contrib
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- closed
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
|
||||
# Only run one instance per PR to ensure in-order execution.
|
||||
concurrency: pr-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
# Dependabot is annoying, but this makes it a bit less so.
|
||||
auto-approve:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request_target'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: hmarr/auto-approve-action@v3
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cla
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.2.1
|
||||
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 }}
|
||||
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"
|
||||
# branch should not be protected
|
||||
branch: "main"
|
||||
allowlist: dependabot*
|
||||
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request_target'
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
requireScope: false
|
||||
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
# Depend on lint so that title is Conventional Commits-compatible.
|
||||
needs: [title]
|
||||
# Skip tagging for draft PRs.
|
||||
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
# This script ensures PR title and labels are in sync:
|
||||
#
|
||||
# When release/breaking label is:
|
||||
# - Added, rename PR title to include ! (e.g. feat!:)
|
||||
# - Removed, rename PR title to strip ! (e.g. feat:)
|
||||
#
|
||||
# When title is:
|
||||
# - Renamed (+!), add the release/breaking label
|
||||
# - Renamed (-!), remove the release/breaking label
|
||||
script: |
|
||||
const releaseLabels = {
|
||||
breaking: "release/breaking",
|
||||
}
|
||||
|
||||
const { action, changes, label, pull_request } = context.payload
|
||||
const { title } = pull_request
|
||||
const labels = pull_request.labels.map((label) => label.name)
|
||||
const isBreakingTitle = isBreaking(title)
|
||||
|
||||
// Debug information.
|
||||
console.log("Action: %s", action)
|
||||
console.log("Title: %s", title)
|
||||
console.log("Labels: %s", labels.join(", "))
|
||||
|
||||
const params = {
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
}
|
||||
|
||||
if (action === "opened" || action === "reopened") {
|
||||
if (isBreakingTitle && !labels.includes(releaseLabels.breaking)) {
|
||||
console.log('Add "%s" label', releaseLabels.breaking)
|
||||
await github.rest.issues.addLabels({
|
||||
...params,
|
||||
labels: [releaseLabels.breaking],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "edited" && changes.title) {
|
||||
if (isBreakingTitle && !labels.includes(releaseLabels.breaking)) {
|
||||
console.log('Add "%s" label', releaseLabels.breaking)
|
||||
await github.rest.issues.addLabels({
|
||||
...params,
|
||||
labels: [releaseLabels.breaking],
|
||||
})
|
||||
}
|
||||
|
||||
if (!isBreakingTitle && labels.includes(releaseLabels.breaking)) {
|
||||
const wasBreakingTitle = isBreaking(changes.title.from)
|
||||
if (wasBreakingTitle) {
|
||||
console.log('Remove "%s" label', releaseLabels.breaking)
|
||||
await github.rest.issues.removeLabel({
|
||||
...params,
|
||||
name: releaseLabels.breaking,
|
||||
})
|
||||
} else {
|
||||
console.log('Rename title from "%s" to "%s"', title, toBreaking(title))
|
||||
await github.rest.issues.update({
|
||||
...params,
|
||||
title: toBreaking(title),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "labeled") {
|
||||
if (label.name === releaseLabels.breaking && !isBreakingTitle) {
|
||||
console.log('Rename title from "%s" to "%s"', title, toBreaking(title))
|
||||
await github.rest.issues.update({
|
||||
...params,
|
||||
title: toBreaking(title),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "unlabeled") {
|
||||
if (label.name === releaseLabels.breaking && isBreakingTitle) {
|
||||
console.log('Rename title from "%s" to "%s"', title, fromBreaking(title))
|
||||
await github.rest.issues.update({
|
||||
...params,
|
||||
title: fromBreaking(title),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isBreaking(t) {
|
||||
return t.split(" ")[0].endsWith("!:")
|
||||
}
|
||||
|
||||
function toBreaking(t) {
|
||||
const parts = t.split(" ")
|
||||
return [parts[0].replace(/:$/, "!:"), ...parts.slice(1)].join(" ")
|
||||
}
|
||||
|
||||
function fromBreaking(t) {
|
||||
const parts = t.split(" ")
|
||||
return [parts[0].replace(/!:$/, ":"), ...parts.slice(1)].join(" ")
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# Dependabot is annoying, but this makes it a bit less so.
|
||||
name: Auto Approve Dependabot
|
||||
|
||||
on: pull_request_target
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: hmarr/auto-approve-action@v3
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
@@ -0,0 +1,60 @@
|
||||
name: docker-base
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- scripts/Dockerfile.base
|
||||
- scripts/Dockerfile
|
||||
|
||||
schedule:
|
||||
# Run every week at 09:43 on Monday, Wednesday and Friday. We build this
|
||||
# frequently to ensure that packages are up-to-date.
|
||||
- cron: "43 9 * * 1,3,5"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
# Necessary for depot.dev authentication.
|
||||
id-token: write
|
||||
|
||||
# Avoid running multiple jobs for the same commit.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-docker-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create empty base-build-context directory
|
||||
run: mkdir base-build-context
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
# This uses OIDC authentication, so no auth variables are required.
|
||||
- name: Build base Docker image via depot.dev
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
file: scripts/Dockerfile.base
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/coder/coder-base:latest
|
||||
@@ -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
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
tag=${{ steps.branch-name.outputs.current_branch }}
|
||||
# Replace / with --, e.g. user/feature => user--feature.
|
||||
tag=${tag//\//--}
|
||||
echo "::set-output name=tag::${tag}"
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: "{{defaultContext}}:dogfood"
|
||||
push: true
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: "Install latest Coder"
|
||||
run: |
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Submit Packages
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [release]
|
||||
types:
|
||||
- completed
|
||||
env:
|
||||
CODER_VERSION: "${{ github.event.release.tag_name }}"
|
||||
|
||||
jobs:
|
||||
winget:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Install wingetcreate
|
||||
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
|
||||
|
||||
$installer_url = $release_assets.assets | `
|
||||
Where-Object name -Match ".*_windows_amd64_installer.exe$" | `
|
||||
Select -ExpandProperty url
|
||||
|
||||
echo "Installer URL: $installer_url"
|
||||
|
||||
.\wingetcreate.exe update Coder.Coder `
|
||||
--submit `
|
||||
--version "${{ steps.version.outputs.version }}" `
|
||||
--urls "$installer_url" `
|
||||
--token "${{ secrets.CDRCI_GITHUB_TOKEN }}"
|
||||
|
||||
- name: Comment on PR
|
||||
run: |
|
||||
# find the PR that wingetcreate just made
|
||||
$pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${{ steps.version.outputs.version }}" --limit 1 --json number | `
|
||||
ConvertFrom-Json`
|
||||
$pr_number = $pr_list[0].number
|
||||
|
||||
gh pr comment --repo microsoft/winget-pkgs "$pr_number" --body "🤖 cc: @deansheather"
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
+196
-20
@@ -1,19 +1,16 @@
|
||||
# GitHub release workflow.
|
||||
name: release
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
snapshot:
|
||||
description: Force a dev version to be generated, implies dry_run.
|
||||
type: boolean
|
||||
required: true
|
||||
dry_run:
|
||||
description: Perform a dry-run release.
|
||||
description: Perform a dry-run release (devel). Note that ref must be an annotated tag when run without dry-run.
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
# Required to publish a release
|
||||
@@ -23,15 +20,24 @@ 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_DRY_RUN: ${{ inputs.dry_run }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
name: Build and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -45,6 +51,38 @@ jobs:
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Print version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(./scripts/version.sh)"
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
# Speed up future version.sh calls.
|
||||
echo "CODER_FORCE_VERSION=$version" >> $GITHUB_ENV
|
||||
echo "$version"
|
||||
|
||||
- name: Create release notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# We always have to set this since there might be commits on
|
||||
# main that didn't have a PR.
|
||||
CODER_IGNORE_MISSING_COMMIT_METADATA: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref=HEAD
|
||||
old_version="$(git describe --abbrev=0 "$ref^1")"
|
||||
version="$(./scripts/version.sh)"
|
||||
|
||||
# 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: Show release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -54,7 +92,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
@@ -75,17 +113,17 @@ jobs:
|
||||
set -euo pipefail
|
||||
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.18.1/nfpm_amd64.deb
|
||||
sudo dpkg -i /tmp/nfpm.deb
|
||||
rm /tmp/nfpm.deb
|
||||
|
||||
- name: Install rcodesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Install a prebuilt binary of rcodesign for linux amd64. Once the
|
||||
# following PR is merged and released upstream, we can download
|
||||
# directly from GitHub releases instead:
|
||||
# https://github.com/indygreg/PyOxidizer/pull/635
|
||||
wget -O /tmp/rcodesign https://cdn.discordapp.com/attachments/283356472258199552/1016767245717872700/rcodesign
|
||||
sudo install --mode 755 /tmp/rcodesign /usr/local/bin/rcodesign
|
||||
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz
|
||||
sudo tar -xzf /tmp/rcodesign.tar.gz \
|
||||
-C /usr/bin \
|
||||
--strip-components=1 \
|
||||
apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign
|
||||
rm /tmp/rcodesign.tar.gz
|
||||
|
||||
- name: Setup Apple Developer certificate and API key
|
||||
run: |
|
||||
@@ -123,6 +161,39 @@ jobs:
|
||||
- name: Delete Apple Developer certificate and API key
|
||||
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
|
||||
|
||||
- name: Determine base image tag
|
||||
id: image-base-tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${CODER_RELEASE:-}" != *t* ]] || [[ "${CODER_DRY_RUN:-}" == *t* ]]; then
|
||||
# Empty value means use the default and avoid building a fresh one.
|
||||
echo "tag=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create empty base-build-context directory
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
run: mkdir base-build-context
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
# This uses OIDC authentication, so no auth variables are required.
|
||||
- name: Build base Docker image via depot.dev
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
file: scripts/Dockerfile.base
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-base-tag.outputs.tag }}
|
||||
|
||||
- name: Build Linux Docker images
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
@@ -151,14 +222,25 @@ jobs:
|
||||
--target "$(./scripts/image_tag.sh --version latest)" \
|
||||
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
|
||||
fi
|
||||
env:
|
||||
CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }}
|
||||
|
||||
- name: ls build
|
||||
run: ls -lh build
|
||||
|
||||
- name: Publish release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
publish_args=()
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
publish_args+=(--dry-run)
|
||||
fi
|
||||
declare -p publish_args
|
||||
|
||||
./scripts/release/publish.sh \
|
||||
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
|
||||
"${publish_args[@]}" \
|
||||
--release-notes-file "$CODER_RELEASE_NOTES_FILE" \
|
||||
./build/*_installer.exe \
|
||||
./build/*.zip \
|
||||
./build/*.tar.gz \
|
||||
@@ -168,6 +250,7 @@ jobs:
|
||||
./build/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v1
|
||||
@@ -176,9 +259,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 +273,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
|
||||
@@ -202,3 +287,94 @@ jobs:
|
||||
./build/*.deb
|
||||
./build/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
- name: Start Packer builds
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
event-type: coder-release
|
||||
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
|
||||
|
||||
publish-winget:
|
||||
name: Publish to winget-pkgs
|
||||
runs-on: windows-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 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
|
||||
# question is only a lightweight tag and not a full annotated tag. This
|
||||
# command seems to fix it.
|
||||
# https://github.com/actions/checkout/issues/290
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Install wingetcreate
|
||||
run: |
|
||||
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
|
||||
- name: Submit updated manifest to winget-pkgs
|
||||
run: |
|
||||
# The package version is the same as the tag minus the leading "v".
|
||||
# The version in this output already has the leading "v" removed but
|
||||
# we do it again to be safe.
|
||||
$version = "${{ needs.release.outputs.version }}".Trim('v')
|
||||
|
||||
$release_assets = gh release view --repo coder/coder "v${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}"
|
||||
echo "Package version: ${version}"
|
||||
|
||||
# Bail if dry-run.
|
||||
if ($env:CODER_DRY_RUN -match "t") {
|
||||
echo "Skipping submission due to dry-run."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 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 "${version}" `
|
||||
--urls "${installer_url}|X64" `
|
||||
--token "$env:WINGET_GH_TOKEN"
|
||||
|
||||
env:
|
||||
# For gh CLI:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# For wingetcreate. We need a real token since we're pushing a commit
|
||||
# to GitHub and then making a PR in a different repo.
|
||||
WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
# Find the PR that wingetcreate just made.
|
||||
$version = "${{ needs.release.outputs.version }}".Trim('v')
|
||||
$pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${version}" --limit 1 --json number | `
|
||||
ConvertFrom-Json
|
||||
$pr_number = $pr_list[0].number
|
||||
|
||||
gh pr comment --repo microsoft/winget-pkgs "${pr_number}" --body "🤖 cc: @deansheather @matifali"
|
||||
|
||||
env:
|
||||
# For gh CLI. We need a real token since we're commenting on a PR in a
|
||||
# different repo.
|
||||
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
name: "security"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
schedule:
|
||||
# Run every week at 10:24 on Thursday.
|
||||
- cron: "24 10 * * 4"
|
||||
|
||||
# Cancel in-progress runs for pull requests when developers push
|
||||
# additional changes
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-security
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
# Workaround to prevent CodeQL from building the dashboard.
|
||||
- name: Remove Makefile
|
||||
run: |
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
trivy:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.30.6
|
||||
|
||||
- name: Build Coder linux amd64 Docker image
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="$(./scripts/version.sh)"
|
||||
image_job="build/coder_${version}_linux_amd64.tag"
|
||||
|
||||
# This environment variable force make to not build packages and
|
||||
# archives (which the Docker image depends on due to technical reasons
|
||||
# related to concurrent FS writes).
|
||||
export DOCKER_IMAGE_NO_PREREQUISITES=true
|
||||
# This environment variables forces scripts/build_docker.sh to build
|
||||
# the base image tag locally instead of using the cached version from
|
||||
# the registry.
|
||||
export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
|
||||
|
||||
make -j "$image_job"
|
||||
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@9ab158e8597f3b310480b9a69402b419bc03dbd5
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
retention-days: 7
|
||||
@@ -1,35 +1,48 @@
|
||||
name: Stale Issue Cron
|
||||
name: Stale Issue and Branch Cleanup
|
||||
on:
|
||||
schedule:
|
||||
# Every day at midnight
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
stale:
|
||||
issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
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"
|
||||
days-before-stale: 90
|
||||
# Pull Requests become stale more quickly due to merge conflicts.
|
||||
# Also, we promote minimizing WIP.
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 3
|
||||
stale-pr-message: >
|
||||
This Pull Request is becoming stale. In order to minimize WIP,
|
||||
This Pull Request is becoming stale. In order to minimize WIP,
|
||||
prevent merge conflicts and keep the tracker readable, I'm going
|
||||
close to this PR in 3 days if there isn't more activity.
|
||||
stale-issue-message: >
|
||||
This issue is becoming stale. In order to keep the tracker readable
|
||||
and actionable, I'm going close to this issue in 7 days if there
|
||||
and actionable, I'm going close to this issue in 7 days if there
|
||||
isn't more activity.
|
||||
# Upped from 30 since we have a big tracker and was hitting the limit.
|
||||
operations-per-run: 60
|
||||
# Start with the oldest issues, always.
|
||||
ascending: true
|
||||
branches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Run delete-old-branches-action
|
||||
uses: beatlabs/delete-old-branches-action@v0.0.9
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
date: "6 months ago"
|
||||
dry_run: false
|
||||
delete_tags: false
|
||||
# extra_protected_branch_regex: ^(foo|bar)$
|
||||
exclude_open_pr_branches: true
|
||||
|
||||
@@ -3,8 +3,10 @@ alog = "alog"
|
||||
Jetbrains = "JetBrains"
|
||||
IST = "IST"
|
||||
MacOS = "macOS"
|
||||
AKS = "AKS"
|
||||
|
||||
[default.extend-words]
|
||||
AKS = "AKS"
|
||||
# do as sudo replacement
|
||||
doas = "doas"
|
||||
darcula = "darcula"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Welcome
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: wow-actions/welcome@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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!
|
||||
FIRST_PR_MERGED: |
|
||||
🎉 Thanks for the contribution @{{ author }}! Yo @coder/docs @{{ author }}'s first contribution has been merged! 👀👀👀
|
||||
+29
-33
@@ -1,55 +1,51 @@
|
||||
###############################################################################
|
||||
# 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/states/*.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/
|
||||
site/out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
*.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,66 @@
|
||||
# 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/states/*.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
|
||||
*.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",
|
||||
|
||||
Vendored
+16
-1
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
@@ -117,6 +119,7 @@
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"tbody",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
@@ -128,6 +131,7 @@
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tparallel",
|
||||
@@ -180,6 +184,10 @@
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"scripts/metricsdocgen/metrics": true,
|
||||
"docs/api/*.md": true
|
||||
},
|
||||
// Ensure files always have a newline.
|
||||
"files.insertFinalNewline": true,
|
||||
"go.lintTool": "golangci-lint",
|
||||
@@ -198,5 +206,12 @@
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib"
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib",
|
||||
"grammarly.selectors": [
|
||||
{
|
||||
"language": "markdown",
|
||||
"scheme": "file",
|
||||
"pattern": "docs/contributing/frontend.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
# Adopters
|
||||
|
||||
[](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=adopters.md) [](https://twitter.com/coderhq)
|
||||
|
||||
🦩 _If you're using Coder in your organization, please try to add your company name to this list. It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. You can do this by by editing this file and contributing your changes via a pull-request on GitHub._
|
||||
|
||||
> 👋 _If you are considering using Coder in your organization please introduce yourself via https://coder.com/demo_ 🙇🏻♂️
|
||||
|
||||
| Organization | Contact | Description of Use |
|
||||
| ------------------------------ | --------------------------------------- | ------------------------------ |
|
||||
| [Coder](https://www.coder.com) | [@coderhq](https://twitter.com/coderhq) | Coder builds coder with 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 := \
|
||||
@@ -85,6 +92,19 @@ CODER_FAT_NOVERSION_BINARIES := $(addprefix build/coder_,$(OS_ARCHES))
|
||||
CODER_ALL_NOVERSION_IMAGES := $(foreach arch, $(DOCKER_ARCHES), build/coder_linux_$(arch).tag) build/coder_linux.tag
|
||||
CODER_ALL_NOVERSION_IMAGES_PUSHED := $(addprefix push/, $(CODER_ALL_NOVERSION_IMAGES))
|
||||
|
||||
# If callers are only building Docker images and not the packages and archives,
|
||||
# we can skip those prerequisites as they are not actually required and only
|
||||
# specified to avoid concurrent write failures.
|
||||
ifdef DOCKER_IMAGE_NO_PREREQUISITES
|
||||
CODER_ARCH_IMAGE_PREREQUISITES :=
|
||||
else
|
||||
CODER_ARCH_IMAGE_PREREQUISITES := \
|
||||
build/coder_$(VERSION)_%.apk \
|
||||
build/coder_$(VERSION)_%.deb \
|
||||
build/coder_$(VERSION)_%.rpm \
|
||||
build/coder_$(VERSION)_%.tar.gz
|
||||
endif
|
||||
|
||||
|
||||
clean:
|
||||
rm -rf build site/out
|
||||
@@ -114,6 +134,9 @@ build/coder-slim_$(VERSION).tar: build/coder-slim_$(VERSION)_checksums.sha1 $(CO
|
||||
tar cf "../../../build/$(@F)" coder-*
|
||||
popd
|
||||
|
||||
# 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 "$<" "$@"
|
||||
|
||||
@@ -125,9 +148,6 @@ build/coder-slim_$(VERSION).tar.zst: build/coder-slim_$(VERSION).tar
|
||||
-o "build/coder-slim_$(VERSION).tar.zst" \
|
||||
"build/coder-slim_$(VERSION).tar"
|
||||
|
||||
# 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.
|
||||
#
|
||||
@@ -289,13 +309,7 @@ $(CODER_ALL_NOVERSION_IMAGES_PUSHED): push/build/coder_%: push/build/coder_$(VER
|
||||
#
|
||||
# Images need to run after the archives and packages are built, otherwise they
|
||||
# cause errors like "file changed as we read it".
|
||||
$(CODER_ARCH_IMAGES): build/coder_$(VERSION)_%.tag: \
|
||||
build/coder_$(VERSION)_% \
|
||||
build/coder_$(VERSION)_%.apk \
|
||||
build/coder_$(VERSION)_%.deb \
|
||||
build/coder_$(VERSION)_%.rpm \
|
||||
build/coder_$(VERSION)_%.tar.gz
|
||||
|
||||
$(CODER_ARCH_IMAGES): build/coder_$(VERSION)_%.tag: build/coder_$(VERSION)_% $(CODER_ARCH_IMAGE_PREREQUISITES)
|
||||
$(get-mode-os-arch-ext)
|
||||
|
||||
image_tag="$$(./scripts/image_tag.sh --arch "$$arch" --version "$(VERSION)")"
|
||||
@@ -341,7 +355,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
|
||||
@@ -354,17 +368,23 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
|
||||
cp "$<" "$$output_file"
|
||||
.PHONY: install
|
||||
|
||||
fmt: fmt/prettier fmt/terraform fmt/shfmt
|
||||
fmt: fmt/prettier fmt/terraform fmt/shfmt fmt/go
|
||||
.PHONY: fmt
|
||||
|
||||
fmt/go:
|
||||
# VS Code users should check out
|
||||
# https://github.com/mvdan/gofumpt#visual-studio-code
|
||||
go run mvdan.cc/gofumpt@v0.4.0 -w -l .
|
||||
.PHONY: fmt/go
|
||||
|
||||
fmt/prettier:
|
||||
echo "--- 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
|
||||
|
||||
@@ -403,13 +423,36 @@ gen: \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli/coder.md \
|
||||
docs/admin/audit-logs.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 \
|
||||
docs/cli/coder.md \
|
||||
docs/admin/audit-logs.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
|
||||
@@ -447,7 +490,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
|
||||
@@ -455,18 +498,91 @@ 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
|
||||
|
||||
docs/cli/coder.md: scripts/clidocgen/main.go $(GO_SRC_FILES) docs/manifest.json
|
||||
BASE_PATH="." go run scripts/clidocgen/main.go
|
||||
cd site
|
||||
yarn run format:write:only ../docs/cli/*.md ../docs/manifest.json
|
||||
|
||||
docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go
|
||||
go run scripts/auditdocgen/main.go
|
||||
cd site
|
||||
yarn run format:write:only ../docs/admin/audit-logs.md
|
||||
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) .swaggo docs/manifest.json
|
||||
./scripts/apidocgen/generate.sh
|
||||
yarn run --cwd=site 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 ./...
|
||||
gotestsum -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
# When updating -timeout for this test, keep in sync with
|
||||
@@ -476,7 +592,6 @@ test-postgres: test-clean test-postgres-docker
|
||||
# more consistent execution.
|
||||
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
|
||||
--junitfile="gotests.xml" \
|
||||
--jsonfile="gotestsum.json" \
|
||||
--packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
|
||||
-parallel=4 \
|
||||
@@ -501,7 +616,8 @@ test-postgres-docker:
|
||||
-c max_connections=1000 \
|
||||
-c fsync=off \
|
||||
-c synchronous_commit=off \
|
||||
-c full_page_writes=off
|
||||
-c full_page_writes=off \
|
||||
-c log_statement=all
|
||||
while ! pg_isready -h 127.0.0.1
|
||||
do
|
||||
echo "$(date) - waiting for database to start"
|
||||
|
||||
@@ -1,66 +1,74 @@
|
||||
# Coder
|
||||
<div align="center">
|
||||
<a href="https://coder.com#gh-light-mode-only">
|
||||
<img src="./docs/images/logo-black.png" style="width: 128px">
|
||||
</a>
|
||||
<a href="https://coder.com#gh-dark-mode-only">
|
||||
<img src="./docs/images/logo-white.png" style="width: 128px">
|
||||
</a>
|
||||
|
||||
[](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
|
||||
<h1>
|
||||
Self-Hosted Remote Development Environments
|
||||
</h1>
|
||||
|
||||
<a href="https://coder.com#gh-light-mode-only">
|
||||
<img src="./docs/images/banner-black.png" style="width: 650px">
|
||||
</a>
|
||||
<a href="https://coder.com#gh-dark-mode-only">
|
||||
<img src="./docs/images/banner-white.png" style="width: 650px">
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
|
||||
|
||||
[](https://discord.gg/coder)
|
||||
[](https://codecov.io/gh/coder/coder)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://twitter.com/coderhq)
|
||||
[](https://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder)
|
||||
[](./LICENSE)
|
||||
|
||||
Software development on your infrastructure. Offload your team's development from local workstations to cloud servers. Onboard developers in minutes. Build, test and compile at the speed of the cloud. Keep your source code and data behind your firewall.
|
||||
</div>
|
||||
|
||||
> "By leveraging Terraform, Coder lets developers run any IDE on any compute platform including on-prem, AWS, Azure, GCP, DigitalOcean, Kubernetes, Docker, and more, with workspaces running on Linux, Windows, or Mac." - **Kevin Fishner Chief of Staff at [HashiCorp](https://hashicorp.com/)**
|
||||
[Coder](https://coder.com) enables organizations to set up development environments in the cloud. Environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
|
||||
|
||||
- Define development environments in Terraform
|
||||
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
|
||||
- Automatically shutdown idle resources to save on costs
|
||||
- Onboard developers in seconds instead of days
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/hero-image.png">
|
||||
</p>
|
||||
|
||||
**Manage less**
|
||||
## Quickstart
|
||||
|
||||
- Ensure your entire team is using the same tools and resources
|
||||
- Rollout critical updates to your developers with one command
|
||||
- Automatically shut down expensive cloud resources
|
||||
- Keep your source code and data behind your firewall
|
||||
The most convenient way to try Coder is to install it on your local machine and experiment with provisioning development environments using Docker (works on Linux, macOS, and Windows).
|
||||
|
||||
**Code more**
|
||||
```
|
||||
# First, install Coder
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
|
||||
- Build and test faster
|
||||
- Leveraging cloud CPUs, RAM, network speeds, etc.
|
||||
- Access your environment from any place on any client (even an iPad)
|
||||
- Onboard instantly then stay up to date continuously
|
||||
# Start the Coder server (caches data in ~/.cache/coder)
|
||||
coder server
|
||||
|
||||
## Recommended Reading
|
||||
# Navigate to http://localhost:3000 to create your initial user
|
||||
# Create a Docker template, and provision a workspace
|
||||
```
|
||||
|
||||
- [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)
|
||||
- [Laptop development is dead: why remote development is the future](https://medium.com/@elliotgraebert/laptop-development-is-dead-why-remote-development-is-the-future-f92ce103fd13)
|
||||
- [Learn how Palantir improved build times by 78% with coder](https://blog.palantir.com/the-benefits-of-remote-ephemeral-workspaces-1a1251ed6e53).
|
||||
- [A software development environment is not just a container](https://coder.com/blog/not-a-container?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [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).
|
||||
|
||||
## Getting Started
|
||||
## Install
|
||||
|
||||
The easiest way to install Coder is to use our
|
||||
[install script](https://github.com/coder/coder/blob/main/install.sh) for Linux
|
||||
and macOS. For Windows, use the latest `..._installer.exe` file from GitHub
|
||||
Releases.
|
||||
|
||||
To install, run:
|
||||
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
```
|
||||
|
||||
You can preview what occurs during the install process:
|
||||
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
You can modify the installation process by including flags. Run the help command for reference:
|
||||
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh -s -- --help
|
||||
```
|
||||
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. You can modify the installation process by including flags. Run the install script with `--help` for reference.
|
||||
|
||||
> See [install](docs/install) for additional methods.
|
||||
|
||||
@@ -74,52 +82,44 @@ coder server
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
> <sup>1</sup> The embedded database is great for trying out Coder with small deployments, but do consider using an external database for increased assurance and control.
|
||||
> <sup>1</sup> For production deployments, set up an external PostgreSQL instance for reliability.
|
||||
|
||||
Use `coder --help` to get a complete list of flags and environment variables. Use our [quickstart guide](https://coder.com/docs/coder-oss/latest/quickstart) for a full walkthrough.
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [quickstart guide](https://coder.com/docs/v2/latest/quickstart) for a full walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit our docs [here](https://coder.com/docs/coder-oss).
|
||||
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
|
||||
|
||||
## Templates
|
||||
|
||||
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?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/space-on-premises-installation.html#overview)) | 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: 14/12/2022_
|
||||
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
|
||||
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
|
||||
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
|
||||
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
|
||||
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
|
||||
|
||||
## Community and Support
|
||||
|
||||
Join our community on [Discord](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) and [Twitter](https://twitter.com/coderhq)!
|
||||
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
|
||||
|
||||
[Suggest improvements and report problems](https://github.com/coder/coder/issues/new/choose)
|
||||
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
|
||||
|
||||
## Contributing
|
||||
|
||||
If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact.
|
||||
|
||||
Read the [contributing docs](https://coder.com/docs/coder-oss/latest/CONTRIBUTING).
|
||||
Contributions are welcome! Read the [contributing docs](https://coder.com/docs/v2/latest/CONTRIBUTING) to get started.
|
||||
|
||||
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
|
||||
|
||||
## Related
|
||||
|
||||
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
|
||||
|
||||
### Official
|
||||
|
||||
- [**VS Code Extension**](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote): Open any Coder workspace in VS Code with a single click
|
||||
- [**JetBrains Gateway Extension**](https://plugins.jetbrains.com/plugin/19620-coder): Open any Coder workspace in JetBrains Gateway with a single click
|
||||
- [**Self-Hosted VS Code Extension Marketplace**](https://github.com/coder/code-marketplace): A private extension marketplace that works in restricted or airgapped networks integrating with [code-server](https://github.com/coder/code-server).
|
||||
|
||||
### Community
|
||||
|
||||
- [**Provision Coder with Terraform**](https://github.com/ElliotG/coder-oss-tf): Provision Coder on Google GKE, Azure AKS, AWS EKS, DigitalOcean DOKS, IBMCloud K8s, OVHCloud K8s, and Scaleway K8s Kapsule with Terraform
|
||||
- [**Coder GitHub Action**](https://github.com/marketplace/actions/update-coder-template): A GitHub Action that updates Coder templates
|
||||
- [**Various Templates**](./examples/templates/community-templates.md): Hetzner Cloud, Docker in Docker, and other templates the community has built.
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Coder Security
|
||||
|
||||
Coder welcomes feedback from security researchers and the general public
|
||||
to help improve our security. If you believe you have discovered a vulnerability,
|
||||
privacy issue, exposed data, or other security issues in any of our assets, we
|
||||
want to hear from you. This policy outlines steps for reporting vulnerabilities
|
||||
to us, what we expect, what you can expect from us.
|
||||
|
||||
You can see the pretty version [here](https://coder.com/security/policy)
|
||||
|
||||
# Why Coder's security matters
|
||||
|
||||
If an attacker could fully compromise a Coder installation, they could spin
|
||||
up expensive workstations, steal valuable credentials, or steal proprietary
|
||||
source code. We take this risk very seriously and employ routine pen testing,
|
||||
vulnerability scanning, and code reviews. We also welcome the contributions
|
||||
from the community that helped make this product possible.
|
||||
|
||||
# Where should I report security issues?
|
||||
|
||||
Please report security issues to security@coder.com, providing
|
||||
all relevant information. The more details you provide, the easier it will be
|
||||
for us to triage and fix the issue.
|
||||
|
||||
# Out of Scope
|
||||
|
||||
Our primary concern is around an abuse of the Coder application that allows
|
||||
an attacker to gain access to another users workspace, or spin up unwanted
|
||||
workspaces.
|
||||
|
||||
- DOS/DDOS attacks affecting availability --> While we do support rate limiting
|
||||
of requests, we primarily leave this to the owner of the Coder installation. Our
|
||||
rationale is that a DOS attack only affecting availability is not a valuable
|
||||
target for attackers.
|
||||
- Abuse of a compromised user credential --> If a user credential is compromised
|
||||
outside of the Coder ecosystem, then we consider it beyond the scope of our application.
|
||||
However, if an unprivileged user could escalate their permissions or gain access
|
||||
to another workspace, that is a cause for concern.
|
||||
- Vulnerabilities in third party systems --> Vulnerabilities discovered in
|
||||
out-of-scope systems should be reported to the appropriate vendor or applicable authority.
|
||||
|
||||
# Our Commitments
|
||||
|
||||
When working with us, according to this policy, you can expect us to:
|
||||
|
||||
- Respond to your report promptly, and work with you to understand and validate your report;
|
||||
- Strive to keep you informed about the progress of a vulnerability as it is processed;
|
||||
- Work to remediate discovered vulnerabilities in a timely manner, within our operational constraints; and
|
||||
- Extend Safe Harbor for your vulnerability research that is related to this policy.
|
||||
|
||||
# Our Expectations
|
||||
|
||||
In participating in our vulnerability disclosure program in good faith, we ask that you:
|
||||
|
||||
- Play by the rules, including following this policy and any other relevant agreements.
|
||||
If there is any inconsistency between this policy and any other applicable terms, the
|
||||
terms of this policy will prevail;
|
||||
- Report any vulnerability you’ve discovered promptly;
|
||||
- Avoid violating the privacy of others, disrupting our systems, destroying data, and/or
|
||||
harming user experience;
|
||||
- Use only the Official Channels to discuss vulnerability information with us;
|
||||
- Provide us a reasonable amount of time (at least 90 days from the initial report) to
|
||||
resolve the issue before you disclose it publicly;
|
||||
- Perform testing only on in-scope systems, and respect systems and activities which
|
||||
are out-of-scope;
|
||||
- If a vulnerability provides unintended access to data: Limit the amount of data you
|
||||
access to the minimum required for effectively demonstrating a Proof of Concept; and
|
||||
cease testing and submit a report immediately if you encounter any user data during testing,
|
||||
such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI),
|
||||
credit card data, or proprietary information;
|
||||
- You should only interact with test accounts you own or with explicit permission from
|
||||
- the account holder; and
|
||||
- Do not engage in extortion.
|
||||
+286
-83
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/tailnet"
|
||||
"github.com/coder/retry"
|
||||
@@ -59,6 +60,7 @@ const (
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ExchangeToken func(ctx context.Context) (string, error)
|
||||
Client Client
|
||||
@@ -68,11 +70,12 @@ type Options struct {
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
WorkspaceAgentMetadata(ctx context.Context) (codersdk.WorkspaceAgentMetadata, error)
|
||||
ListenWorkspaceAgent(ctx context.Context) (net.Conn, error)
|
||||
AgentReportStats(ctx context.Context, log slog.Logger, stats func() *codersdk.AgentStats) (io.Closer, error)
|
||||
PostWorkspaceAgentAppHealth(ctx context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error
|
||||
PostWorkspaceAgentVersion(ctx context.Context, version string) error
|
||||
Metadata(ctx context.Context) (agentsdk.Metadata, error)
|
||||
Listen(ctx context.Context) (net.Conn, error)
|
||||
ReportStats(ctx context.Context, log slog.Logger, statsChan <-chan *agentsdk.Stats, setInterval func(time.Duration)) (io.Closer, error)
|
||||
PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error
|
||||
PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error
|
||||
PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error
|
||||
}
|
||||
|
||||
func New(options Options) io.Closer {
|
||||
@@ -85,6 +88,12 @@ func New(options Options) io.Closer {
|
||||
if options.TempDir == "" {
|
||||
options.TempDir = os.TempDir()
|
||||
}
|
||||
if options.LogDir == "" {
|
||||
if options.TempDir != os.TempDir() {
|
||||
options.Logger.Debug(context.Background(), "log dir not set, using temp dir", slog.F("temp_dir", options.TempDir))
|
||||
}
|
||||
options.LogDir = options.TempDir
|
||||
}
|
||||
if options.ExchangeToken == nil {
|
||||
options.ExchangeToken = func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
@@ -100,7 +109,10 @@ func New(options Options) io.Closer {
|
||||
client: options.Client,
|
||||
exchangeToken: options.ExchangeToken,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
connStatsChan: make(chan *agentsdk.Stats, 1),
|
||||
}
|
||||
a.init(ctx)
|
||||
return a
|
||||
@@ -111,6 +123,7 @@ type agent struct {
|
||||
client Client
|
||||
exchangeToken func(ctx context.Context) (string, error)
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
|
||||
reconnectingPTYs sync.Map
|
||||
@@ -127,7 +140,12 @@ type agent struct {
|
||||
sessionToken atomic.Pointer[string]
|
||||
sshServer *ssh.Server
|
||||
|
||||
network *tailnet.Conn
|
||||
lifecycleUpdate chan struct{}
|
||||
lifecycleMu sync.Mutex // Protects following.
|
||||
lifecycleState codersdk.WorkspaceAgentLifecycle
|
||||
|
||||
network *tailnet.Conn
|
||||
connStatsChan chan *agentsdk.Stats
|
||||
}
|
||||
|
||||
// runLoop attempts to start the agent in a retry loop.
|
||||
@@ -135,6 +153,8 @@ type agent struct {
|
||||
// may be happening, but regardless after the intermittent
|
||||
// failure, you'll want the agent to reconnect.
|
||||
func (a *agent) runLoop(ctx context.Context) {
|
||||
go a.reportLifecycleLoop(ctx)
|
||||
|
||||
for retrier := retry.New(100*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
a.logger.Info(ctx, "running loop")
|
||||
err := a.run(ctx)
|
||||
@@ -156,6 +176,58 @@ func (a *agent) runLoop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// reportLifecycleLoop reports the current lifecycle state once.
|
||||
// Only the latest state is reported, intermediate states may be
|
||||
// lost if the agent can't communicate with the API.
|
||||
func (a *agent) reportLifecycleLoop(ctx context.Context) {
|
||||
var lastReported codersdk.WorkspaceAgentLifecycle
|
||||
for {
|
||||
select {
|
||||
case <-a.lifecycleUpdate:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
for r := retry.New(time.Second, 15*time.Second); r.Wait(ctx); {
|
||||
a.lifecycleMu.Lock()
|
||||
state := a.lifecycleState
|
||||
a.lifecycleMu.Unlock()
|
||||
|
||||
if state == lastReported {
|
||||
break
|
||||
}
|
||||
|
||||
a.logger.Debug(ctx, "post lifecycle state", slog.F("state", state))
|
||||
|
||||
err := a.client.PostLifecycle(ctx, agentsdk.PostLifecycleRequest{
|
||||
State: state,
|
||||
})
|
||||
if err == nil {
|
||||
lastReported = state
|
||||
break
|
||||
}
|
||||
if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
// If we fail to report the state we probably shouldn't exit, log only.
|
||||
a.logger.Error(ctx, "post state", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) setLifecycle(ctx context.Context, state codersdk.WorkspaceAgentLifecycle) {
|
||||
a.lifecycleMu.Lock()
|
||||
defer a.lifecycleMu.Unlock()
|
||||
|
||||
a.logger.Debug(ctx, "set lifecycle state", slog.F("state", state), slog.F("previous", a.lifecycleState))
|
||||
|
||||
a.lifecycleState = state
|
||||
select {
|
||||
case a.lifecycleUpdate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) run(ctx context.Context) error {
|
||||
// This allows the agent to refresh it's token if necessary.
|
||||
// For instance identity this is required, since the instance
|
||||
@@ -166,43 +238,97 @@ func (a *agent) run(ctx context.Context) error {
|
||||
}
|
||||
a.sessionToken.Store(&sessionToken)
|
||||
|
||||
err = a.client.PostWorkspaceAgentVersion(ctx, buildinfo.Version())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace agent version: %w", err)
|
||||
}
|
||||
|
||||
metadata, err := a.client.WorkspaceAgentMetadata(ctx)
|
||||
metadata, err := a.client.Metadata(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch metadata: %w", err)
|
||||
}
|
||||
a.logger.Info(ctx, "fetched metadata")
|
||||
|
||||
// Expand the directory and send it back to coderd so external
|
||||
// applications that rely on the directory can use it.
|
||||
//
|
||||
// An example is VS Code Remote, which must know the directory
|
||||
// before initializing a connection.
|
||||
metadata.Directory, err = expandDirectory(metadata.Directory)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("expand directory: %w", err)
|
||||
}
|
||||
err = a.client.PostStartup(ctx, agentsdk.PostStartupRequest{
|
||||
Version: buildinfo.Version(),
|
||||
ExpandedDirectory: metadata.Directory,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace agent version: %w", err)
|
||||
}
|
||||
|
||||
oldMetadata := a.metadata.Swap(metadata)
|
||||
|
||||
// The startup script should only execute on the first run!
|
||||
if oldMetadata == nil {
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStarting)
|
||||
|
||||
// Perform overrides early so that Git auth can work even if users
|
||||
// connect to a workspace that is not yet ready. We don't run this
|
||||
// concurrently with the startup script to avoid conflicts between
|
||||
// them.
|
||||
if metadata.GitAuthConfigs > 0 {
|
||||
// If this fails, we should consider surfacing the error in the
|
||||
// startup log and setting the lifecycle state to be "start_error"
|
||||
// (after startup script completion), but for now we'll just log it.
|
||||
err := gitauth.OverrideVSCodeConfigs(a.filesystem)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "failed to override vscode git auth configs", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
scriptDone := make(chan error, 1)
|
||||
scriptStart := time.Now()
|
||||
err := a.trackConnGoroutine(func() {
|
||||
defer close(scriptDone)
|
||||
scriptDone <- a.runStartupScript(ctx, metadata.StartupScript)
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("track startup script: %w", err)
|
||||
}
|
||||
go func() {
|
||||
err := a.runStartupScript(ctx, metadata.StartupScript)
|
||||
var timeout <-chan time.Time
|
||||
// If timeout is zero, an older version of the coder
|
||||
// provider was used. Otherwise a timeout is always > 0.
|
||||
if metadata.StartupScriptTimeout > 0 {
|
||||
t := time.NewTimer(metadata.StartupScriptTimeout)
|
||||
defer t.Stop()
|
||||
timeout = t.C
|
||||
}
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-scriptDone:
|
||||
case <-timeout:
|
||||
a.logger.Warn(ctx, "startup script timed out")
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
err = <-scriptDone // The script can still complete after a timeout.
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
execTime := time.Since(scriptStart)
|
||||
lifecycleStatus := codersdk.WorkspaceAgentLifecycleReady
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
|
||||
a.logger.Warn(ctx, "startup script failed", slog.F("execution_time", execTime), slog.Error(err))
|
||||
lifecycleStatus = codersdk.WorkspaceAgentLifecycleStartError
|
||||
} else {
|
||||
a.logger.Info(ctx, "startup script completed", slog.F("execution_time", execTime))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if metadata.GitAuthConfigs > 0 {
|
||||
err = gitauth.OverrideVSCodeConfigs(a.filesystem)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("override vscode configuration for git auth: %w", err)
|
||||
}
|
||||
a.setLifecycle(ctx, lifecycleStatus)
|
||||
}()
|
||||
}
|
||||
|
||||
// This automatically closes when the context ends!
|
||||
appReporterCtx, appReporterCtxCancel := context.WithCancel(ctx)
|
||||
defer appReporterCtxCancel()
|
||||
go NewWorkspaceAppHealthReporter(
|
||||
a.logger, metadata.Apps, a.client.PostWorkspaceAgentAppHealth)(appReporterCtx)
|
||||
a.logger, metadata.Apps, a.client.PostAppHealth)(appReporterCtx)
|
||||
|
||||
a.logger.Debug(ctx, "running tailnet with derpmap", slog.F("derpmap", metadata.DERPMap))
|
||||
|
||||
@@ -227,11 +353,20 @@ func (a *agent) run(ctx context.Context) error {
|
||||
return xerrors.New("agent is closed")
|
||||
}
|
||||
|
||||
setStatInterval := func(d time.Duration) {
|
||||
network.SetConnStatsCallback(d, 2048,
|
||||
func(_, _ time.Time, virtual, _ map[netlogtype.Connection]netlogtype.Counts) {
|
||||
select {
|
||||
case a.connStatsChan <- convertAgentStats(virtual):
|
||||
default:
|
||||
a.logger.Warn(ctx, "network stat dropped")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Report statistics from the created network.
|
||||
cl, err := a.client.AgentReportStats(ctx, a.logger, func() *codersdk.AgentStats {
|
||||
stats := network.ExtractTrafficStats()
|
||||
return convertAgentStats(stats)
|
||||
})
|
||||
cl, err := a.client.ReportStats(ctx, a.logger, a.connStatsChan, setStatInterval)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
} else {
|
||||
@@ -275,10 +410,9 @@ func (a *agent) trackConnGoroutine(fn func()) error {
|
||||
|
||||
func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_ *tailnet.Conn, err error) {
|
||||
network, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.TailnetIP, 128)},
|
||||
DERPMap: derpMap,
|
||||
Logger: a.logger.Named("tailnet"),
|
||||
EnableTrafficStats: true,
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128)},
|
||||
DERPMap: derpMap,
|
||||
Logger: a.logger.Named("tailnet"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
@@ -289,7 +423,7 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
}
|
||||
}()
|
||||
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSSHPort))
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentSSHPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on the ssh port: %w", err)
|
||||
}
|
||||
@@ -299,18 +433,33 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
}
|
||||
}()
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
conn, err := sshListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
break
|
||||
}
|
||||
go a.sshServer.HandleConn(conn)
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-closed:
|
||||
case <-a.closed:
|
||||
_ = conn.Close()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
defer close(closed)
|
||||
a.sshServer.HandleConn(conn)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetReconnectingPTYPort))
|
||||
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentReconnectingPTYPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen for reconnecting pty: %w", err)
|
||||
}
|
||||
@@ -321,40 +470,52 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
}()
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
logger := a.logger.Named("reconnecting-pty")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
conn, err := reconnectingPTYListener.Accept()
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "accept pty failed", slog.Error(err))
|
||||
return
|
||||
}
|
||||
// This cannot use a JSON decoder, since that can
|
||||
// buffer additional data that is required for the PTY.
|
||||
rawLen := make([]byte, 2)
|
||||
_, err = conn.Read(rawLen)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
length := binary.LittleEndian.Uint16(rawLen)
|
||||
data := make([]byte, length)
|
||||
_, err = conn.Read(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var msg codersdk.ReconnectingPTYInit
|
||||
err = json.Unmarshal(data, &msg)
|
||||
if err != nil {
|
||||
continue
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-closed:
|
||||
case <-a.closed:
|
||||
_ = conn.Close()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
defer close(closed)
|
||||
// This cannot use a JSON decoder, since that can
|
||||
// buffer additional data that is required for the PTY.
|
||||
rawLen := make([]byte, 2)
|
||||
_, err = conn.Read(rawLen)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
length := binary.LittleEndian.Uint16(rawLen)
|
||||
data := make([]byte, length)
|
||||
_, err = conn.Read(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg codersdk.WorkspaceAgentReconnectingPTYInit
|
||||
err = json.Unmarshal(data, &msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.handleReconnectingPTY(ctx, logger, msg, conn)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
speedtestListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSpeedtestPort))
|
||||
speedtestListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentSpeedtestPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen for speedtest: %w", err)
|
||||
}
|
||||
@@ -364,50 +525,62 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
}
|
||||
}()
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
conn, err := speedtestListener.Accept()
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, "speedtest listener failed", slog.Error(err))
|
||||
return
|
||||
break
|
||||
}
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-closed:
|
||||
case <-a.closed:
|
||||
_ = conn.Close()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
defer close(closed)
|
||||
_ = speedtest.ServeConn(conn)
|
||||
}); err != nil {
|
||||
a.logger.Debug(ctx, "speedtest listener failed", slog.Error(err))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statisticsListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetStatisticsPort))
|
||||
apiListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentHTTPAPIServerPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen for statistics: %w", err)
|
||||
return nil, xerrors.Errorf("api listener: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = statisticsListener.Close()
|
||||
_ = apiListener.Close()
|
||||
}
|
||||
}()
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
defer statisticsListener.Close()
|
||||
defer apiListener.Close()
|
||||
server := &http.Server{
|
||||
Handler: a.statisticsHandler(),
|
||||
Handler: a.apiHandler(),
|
||||
ReadTimeout: 20 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo),
|
||||
ErrorLog: slog.Stdlib(ctx, a.logger.Named("http_api_server"), slog.LevelInfo),
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-a.closed:
|
||||
}
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
err := server.Serve(statisticsListener)
|
||||
err := server.Serve(apiListener)
|
||||
if err != nil && !xerrors.Is(err, http.ErrServerClosed) && !strings.Contains(err.Error(), "use of closed network connection") {
|
||||
a.logger.Critical(ctx, "serve statistics HTTP server", slog.Error(err))
|
||||
a.logger.Critical(ctx, "serve HTTP API server", slog.Error(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -419,7 +592,10 @@ func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_
|
||||
// runCoordinator runs a coordinator and returns whether a reconnect
|
||||
// should occur.
|
||||
func (a *agent) runCoordinator(ctx context.Context, network *tailnet.Conn) error {
|
||||
coordinator, err := a.client.ListenWorkspaceAgent(ctx)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
coordinator, err := a.client.Listen(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -441,7 +617,7 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
|
||||
}
|
||||
|
||||
a.logger.Info(ctx, "running startup script", slog.F("script", script))
|
||||
writer, err := a.filesystem.OpenFile(filepath.Join(a.tempDir, "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
writer, err := a.filesystem.OpenFile(filepath.Join(a.logDir, "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open startup script log file: %w", err)
|
||||
}
|
||||
@@ -480,12 +656,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))
|
||||
@@ -525,8 +705,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{
|
||||
@@ -582,8 +764,8 @@ func (a *agent) init(ctx context.Context) {
|
||||
go a.runLoop(ctx)
|
||||
}
|
||||
|
||||
func convertAgentStats(counts map[netlogtype.Connection]netlogtype.Counts) *codersdk.AgentStats {
|
||||
stats := &codersdk.AgentStats{
|
||||
func convertAgentStats(counts map[netlogtype.Connection]netlogtype.Counts) *agentsdk.Stats {
|
||||
stats := &agentsdk.Stats{
|
||||
ConnsByProto: map[string]int64{},
|
||||
NumConns: int64(len(counts)),
|
||||
}
|
||||
@@ -618,7 +800,7 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
if rawMetadata == nil {
|
||||
return nil, xerrors.Errorf("no metadata was provided: %w", err)
|
||||
}
|
||||
metadata, valid := rawMetadata.(codersdk.WorkspaceAgentMetadata)
|
||||
metadata, valid := rawMetadata.(agentsdk.Metadata)
|
||||
if !valid {
|
||||
return nil, xerrors.Errorf("metadata is the wrong type: %T", metadata)
|
||||
}
|
||||
@@ -644,7 +826,11 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
|
||||
cmd := exec.CommandContext(ctx, shell, args...)
|
||||
cmd.Dir = metadata.Directory
|
||||
if cmd.Dir == "" {
|
||||
|
||||
// If the metadata directory doesn't exist, we run the command
|
||||
// in the users home directory.
|
||||
_, err = os.Stat(cmd.Dir)
|
||||
if cmd.Dir == "" || err != nil {
|
||||
// Default to user home if a directory is not set.
|
||||
homedir, err := userHomeDir()
|
||||
if err != nil {
|
||||
@@ -727,7 +913,7 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
session.DisablePTYEmulation()
|
||||
|
||||
if !isQuietLogin(session.RawCommand()) {
|
||||
metadata, ok := a.metadata.Load().(codersdk.WorkspaceAgentMetadata)
|
||||
metadata, ok := a.metadata.Load().(agentsdk.Metadata)
|
||||
if ok {
|
||||
err = showMOTD(session, metadata.MOTDFile)
|
||||
if err != nil {
|
||||
@@ -800,7 +986,7 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.ReconnectingPTYInit, conn net.Conn) (retErr error) {
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.WorkspaceAgentReconnectingPTYInit, conn net.Conn) (retErr error) {
|
||||
defer conn.Close()
|
||||
|
||||
connectionID := uuid.NewString()
|
||||
@@ -1155,3 +1341,20 @@ func userHomeDir() (string, error) {
|
||||
}
|
||||
return u.HomeDir, nil
|
||||
}
|
||||
|
||||
// expandDirectory converts a directory path to an absolute path.
|
||||
// It primarily resolves the home directory and any environment
|
||||
// variables that may be set
|
||||
func expandDirectory(dir string) (string, error) {
|
||||
if dir == "" {
|
||||
return "", nil
|
||||
}
|
||||
if dir[0] == '~' {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = filepath.Join(home, dir[1:])
|
||||
}
|
||||
return os.ExpandEnv(dir), nil
|
||||
}
|
||||
|
||||
+498
-82
@@ -22,10 +22,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
scp "github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
@@ -37,11 +33,15 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/tailnet"
|
||||
"github.com/coder/coder/tailnet/tailnettest"
|
||||
@@ -52,12 +52,14 @@ func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
// NOTE: These tests only work when your default shell is bash for some reason.
|
||||
|
||||
func TestAgent_Stats_SSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
conn, stats, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
conn, _, stats, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
@@ -67,7 +69,7 @@ func TestAgent_Stats_SSH(t *testing.T) {
|
||||
defer session.Close()
|
||||
require.NoError(t, session.Run("echo test"))
|
||||
|
||||
var s *codersdk.AgentStats
|
||||
var s *agentsdk.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
@@ -83,7 +85,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
conn, stats, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
conn, _, stats, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
|
||||
ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
@@ -96,7 +98,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
_, err = ptyConn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *codersdk.AgentStats
|
||||
var s *agentsdk.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
@@ -108,7 +110,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
|
||||
func TestAgent_SessionExec(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -121,7 +123,7 @@ func TestAgent_SessionExec(t *testing.T) {
|
||||
|
||||
func TestAgent_GitSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
command := "sh -c 'echo $GIT_SSH_COMMAND'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %GIT_SSH_COMMAND%"
|
||||
@@ -133,14 +135,16 @@ func TestAgent_GitSSH(t *testing.T) {
|
||||
|
||||
func TestAgent_SessionTTYShell(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
t.Cleanup(cancel)
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
command := "bash"
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
command := "sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
@@ -152,11 +156,7 @@ func TestAgent_SessionTTYShell(t *testing.T) {
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
caret := "$"
|
||||
if runtime.GOOS == "windows" {
|
||||
caret = ">"
|
||||
}
|
||||
ptty.ExpectMatch(caret)
|
||||
_ = ptty.Peek(ctx, 1) // wait for the prompt
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
@@ -166,7 +166,7 @@ func TestAgent_SessionTTYShell(t *testing.T) {
|
||||
|
||||
func TestAgent_SessionTTYExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
command := "areallynotrealcommand"
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
@@ -205,7 +205,7 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) {
|
||||
// Set HOME so we can ensure no ~/.hushlogin is present.
|
||||
t.Setenv("HOME", tmpdir)
|
||||
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
session := setupSSHSession(t, agentsdk.Metadata{
|
||||
MOTDFile: name,
|
||||
})
|
||||
err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
@@ -251,7 +251,7 @@ func TestAgent_Session_TTY_Hushlogin(t *testing.T) {
|
||||
// Set HOME so we can ensure ~/.hushlogin is present.
|
||||
t.Setenv("HOME", tmpdir)
|
||||
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
session := setupSSHSession(t, agentsdk.Metadata{
|
||||
MOTDFile: name,
|
||||
})
|
||||
err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
@@ -273,7 +273,7 @@ func TestAgent_Session_TTY_Hushlogin(t *testing.T) {
|
||||
}
|
||||
|
||||
//nolint:paralleltest // This test reserves a port.
|
||||
func TestAgent_LocalForwarding(t *testing.T) {
|
||||
func TestAgent_TCPLocalForwarding(t *testing.T) {
|
||||
random, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
_ = random.Close()
|
||||
@@ -286,7 +286,7 @@ func TestAgent_LocalForwarding(t *testing.T) {
|
||||
defer local.Close()
|
||||
tcpAddr, valid = local.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
localPort := tcpAddr.Port
|
||||
remotePort := tcpAddr.Port
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
@@ -294,16 +294,231 @@ func TestAgent_LocalForwarding(t *testing.T) {
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
defer conn.Close()
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_, err = conn.Write(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start()
|
||||
cmd := setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, remotePort)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(localPort))
|
||||
require.NoError(t, err)
|
||||
conn.Close()
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(randomPort))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("test"))
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
if !assert.Equal(t, "test", string(b)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
|
||||
<-done
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
//nolint:paralleltest // This test reserves a port.
|
||||
func TestAgent_TCPRemoteForwarding(t *testing.T) {
|
||||
random, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
_ = random.Close()
|
||||
tcpAddr, valid := random.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
randomPort := tcpAddr.Port
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
tcpAddr, valid = l.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
localPort := tcpAddr.Port
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_, err = conn.Write(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-R", fmt.Sprintf("127.0.0.1:%d:127.0.0.1:%d", randomPort, localPort)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", randomPort))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("test"))
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
if !assert.Equal(t, "test", string(b)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
|
||||
<-done
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func TestAgent_UnixLocalForwarding(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix domain sockets are not fully supported on Windows")
|
||||
}
|
||||
|
||||
tmpdir := tempDirUnixSocket(t)
|
||||
remoteSocketPath := filepath.Join(tmpdir, "remote-socket")
|
||||
localSocketPath := filepath.Join(tmpdir, "local-socket")
|
||||
|
||||
l, err := net.Listen("unix", remoteSocketPath)
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_, err = conn.Write(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-L", fmt.Sprintf("%s:%s", localSocketPath, remoteSocketPath)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(localSocketPath)
|
||||
return err == nil
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
conn, err := net.Dial("unix", localSocketPath)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("test"))
|
||||
require.NoError(t, err)
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", string(b))
|
||||
_ = conn.Close()
|
||||
<-done
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func TestAgent_UnixRemoteForwarding(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix domain sockets are not fully supported on Windows")
|
||||
}
|
||||
|
||||
tmpdir := tempDirUnixSocket(t)
|
||||
remoteSocketPath := filepath.Join(tmpdir, "remote-socket")
|
||||
localSocketPath := filepath.Join(tmpdir, "local-socket")
|
||||
|
||||
l, err := net.Listen("unix", localSocketPath)
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_, err = conn.Write(b)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-R", fmt.Sprintf("%s:%s", remoteSocketPath, localSocketPath)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(remoteSocketPath)
|
||||
return err == nil
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
conn, err := net.Dial("unix", remoteSocketPath)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("test"))
|
||||
require.NoError(t, err)
|
||||
b := make([]byte, 4)
|
||||
_, err = conn.Read(b)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", string(b))
|
||||
_ = conn.Close()
|
||||
|
||||
<-done
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func TestAgent_SFTP(t *testing.T) {
|
||||
@@ -316,7 +531,8 @@ func TestAgent_SFTP(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
home = "/" + strings.ReplaceAll(home, "\\", "/")
|
||||
}
|
||||
conn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
@@ -347,7 +563,8 @@ func TestAgent_SCP(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
conn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
@@ -366,7 +583,7 @@ func TestAgent_EnvironmentVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
value := "value"
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
session := setupSSHSession(t, agentsdk.Metadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: value,
|
||||
},
|
||||
@@ -383,7 +600,7 @@ func TestAgent_EnvironmentVariables(t *testing.T) {
|
||||
func TestAgent_EnvironmentVariableExpansion(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
session := setupSSHSession(t, agentsdk.Metadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: "$SOMETHINGNOTSET",
|
||||
},
|
||||
@@ -410,7 +627,7 @@ func TestAgent_CoderEnvVars(t *testing.T) {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
command := "sh -c 'echo $" + key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + key + "%"
|
||||
@@ -433,7 +650,7 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
session := setupSSHSession(t, agentsdk.Metadata{})
|
||||
command := "sh -c 'echo $" + key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + key + "%"
|
||||
@@ -451,7 +668,8 @@ func TestAgent_StartupScript(t *testing.T) {
|
||||
t.Skip("This test doesn't work on Windows for some reason...")
|
||||
}
|
||||
content := "output"
|
||||
_, _, fs := setupAgent(t, codersdk.WorkspaceAgentMetadata{
|
||||
//nolint:dogsled
|
||||
_, _, _, fs := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "echo " + content,
|
||||
}, 0)
|
||||
var gotContent string
|
||||
@@ -479,6 +697,147 @@ func TestAgent_StartupScript(t *testing.T) {
|
||||
require.Equal(t, content, strings.TrimSpace(gotContent))
|
||||
}
|
||||
|
||||
func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "sleep 5",
|
||||
StartupScriptTimeout: time.Nanosecond,
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
codersdk.WorkspaceAgentLifecycleStarting,
|
||||
codersdk.WorkspaceAgentLifecycleStartTimeout,
|
||||
}
|
||||
|
||||
var got []codersdk.WorkspaceAgentLifecycle
|
||||
assert.Eventually(t, func() bool {
|
||||
got = client.getLifecycleStates()
|
||||
return len(got) > 0 && got[len(got)-1] == want[len(want)-1]
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
switch len(got) {
|
||||
case 1:
|
||||
// This can happen if lifecycle state updates are
|
||||
// too fast, only the latest one is reported.
|
||||
require.Equal(t, want[1:], got)
|
||||
default:
|
||||
// This is the expected case.
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "false",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
codersdk.WorkspaceAgentLifecycleStarting,
|
||||
codersdk.WorkspaceAgentLifecycleStartError,
|
||||
}
|
||||
|
||||
var got []codersdk.WorkspaceAgentLifecycle
|
||||
assert.Eventually(t, func() bool {
|
||||
got = client.getLifecycleStates()
|
||||
return len(got) > 0 && got[len(got)-1] == want[len(want)-1]
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
switch len(got) {
|
||||
case 1:
|
||||
// This can happen if lifecycle state updates are
|
||||
// too fast, only the latest one is reported.
|
||||
require.Equal(t, want[1:], got)
|
||||
default:
|
||||
// This is the expected case.
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ready", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
codersdk.WorkspaceAgentLifecycleStarting,
|
||||
codersdk.WorkspaceAgentLifecycleReady,
|
||||
}
|
||||
|
||||
var got []codersdk.WorkspaceAgentLifecycle
|
||||
assert.Eventually(t, func() bool {
|
||||
got = client.getLifecycleStates()
|
||||
return len(got) > 0 && got[len(got)-1] == want[len(want)-1]
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
switch len(got) {
|
||||
case 1:
|
||||
// This can happen if lifecycle state updates are
|
||||
// too fast, only the latest one is reported.
|
||||
require.Equal(t, want[1:], got)
|
||||
default:
|
||||
// This is the expected case.
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_Startup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyDirectory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.getStartup().Version != ""
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
require.Equal(t, "", client.getStartup().ExpandedDirectory)
|
||||
})
|
||||
|
||||
t.Run("HomeDirectory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "~",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.getStartup().Version != ""
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, homeDir, client.getStartup().ExpandedDirectory)
|
||||
})
|
||||
|
||||
t.Run("HomeEnvironmentVariable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "$HOME",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.getStartup().Version != ""
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, homeDir, client.getStartup().ExpandedDirectory)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -491,7 +850,8 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
conn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
id := uuid.New()
|
||||
netConn, err := conn.ReconnectingPTY(ctx, id, 100, 100, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
@@ -592,7 +952,8 @@ func TestAgent_Dial(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
conn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
require.True(t, conn.AwaitReachable(context.Background()))
|
||||
conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
@@ -613,7 +974,8 @@ func TestAgent_Speedtest(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
derpMap := tailnettest.RunDERPAndSTUN(t)
|
||||
conn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, agentsdk.Metadata{
|
||||
DERPMap: derpMap,
|
||||
}, 0)
|
||||
defer conn.Close()
|
||||
@@ -630,12 +992,12 @@ func TestAgent_Reconnect(t *testing.T) {
|
||||
defer coordinator.Close()
|
||||
|
||||
agentID := uuid.New()
|
||||
statsCh := make(chan *codersdk.AgentStats)
|
||||
statsCh := make(chan *agentsdk.Stats)
|
||||
derpMap := tailnettest.RunDERPAndSTUN(t)
|
||||
client := &client{
|
||||
t: t,
|
||||
agentID: agentID,
|
||||
metadata: codersdk.WorkspaceAgentMetadata{
|
||||
metadata: agentsdk.Metadata{
|
||||
DERPMap: derpMap,
|
||||
},
|
||||
statsChan: statsCh,
|
||||
@@ -670,11 +1032,11 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) {
|
||||
client := &client{
|
||||
t: t,
|
||||
agentID: uuid.New(),
|
||||
metadata: codersdk.WorkspaceAgentMetadata{
|
||||
metadata: agentsdk.Metadata{
|
||||
GitAuthConfigs: 1,
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
statsChan: make(chan *codersdk.AgentStats),
|
||||
statsChan: make(chan *agentsdk.Stats),
|
||||
coordinator: coordinator,
|
||||
}
|
||||
filesystem := afero.NewMemMapFs()
|
||||
@@ -698,7 +1060,8 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) {
|
||||
}
|
||||
|
||||
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
|
||||
agentConn, _, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
//nolint:dogsled
|
||||
agentConn, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
waitGroup := sync.WaitGroup{}
|
||||
@@ -733,15 +1096,19 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
|
||||
args := append(beforeArgs,
|
||||
"-o", "HostName "+tcpAddr.IP.String(),
|
||||
"-o", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"-o", "StrictHostKeyChecking=no", "host")
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"host",
|
||||
)
|
||||
args = append(args, afterArgs...)
|
||||
return exec.Command("ssh", args...)
|
||||
}
|
||||
|
||||
func setupSSHSession(t *testing.T, options codersdk.WorkspaceAgentMetadata) *ssh.Session {
|
||||
func setupSSHSession(t *testing.T, options agentsdk.Metadata) *ssh.Session {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
conn, _, _ := setupAgent(t, options, 0)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _ := setupAgent(t, options, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
@@ -761,9 +1128,10 @@ func (c closeFunc) Close() error {
|
||||
return c()
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeout time.Duration) (
|
||||
*codersdk.AgentConn,
|
||||
<-chan *codersdk.AgentStats,
|
||||
func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Duration) (
|
||||
*codersdk.WorkspaceAgentConn,
|
||||
*client,
|
||||
<-chan *agentsdk.Stats,
|
||||
afero.Fs,
|
||||
) {
|
||||
if metadata.DERPMap == nil {
|
||||
@@ -774,28 +1142,28 @@ func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeo
|
||||
_ = coordinator.Close()
|
||||
})
|
||||
agentID := uuid.New()
|
||||
statsCh := make(chan *codersdk.AgentStats, 50)
|
||||
statsCh := make(chan *agentsdk.Stats, 50)
|
||||
fs := afero.NewMemMapFs()
|
||||
c := &client{
|
||||
t: t,
|
||||
agentID: agentID,
|
||||
metadata: metadata,
|
||||
statsChan: statsCh,
|
||||
coordinator: coordinator,
|
||||
}
|
||||
closer := agent.New(agent.Options{
|
||||
Client: &client{
|
||||
t: t,
|
||||
agentID: agentID,
|
||||
metadata: metadata,
|
||||
statsChan: statsCh,
|
||||
coordinator: coordinator,
|
||||
},
|
||||
Client: c,
|
||||
Filesystem: fs,
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
ReconnectingPTYTimeout: ptyTimeout,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = closer.Close()
|
||||
})
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
DERPMap: metadata.DERPMap,
|
||||
Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug),
|
||||
EnableTrafficStats: true,
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
DERPMap: metadata.DERPMap,
|
||||
Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
clientConn, serverConn := net.Pipe()
|
||||
@@ -814,9 +1182,9 @@ func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeo
|
||||
return conn.UpdateNodes(node)
|
||||
})
|
||||
conn.SetNodeCallback(sendNode)
|
||||
return &codersdk.AgentConn{
|
||||
return &codersdk.WorkspaceAgentConn{
|
||||
Conn: conn,
|
||||
}, statsCh, fs
|
||||
}, c, statsCh, fs
|
||||
}
|
||||
|
||||
var dialTestPayload = []byte("dean-was-here123")
|
||||
@@ -853,17 +1221,21 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) {
|
||||
type client struct {
|
||||
t *testing.T
|
||||
agentID uuid.UUID
|
||||
metadata codersdk.WorkspaceAgentMetadata
|
||||
statsChan chan *codersdk.AgentStats
|
||||
metadata agentsdk.Metadata
|
||||
statsChan chan *agentsdk.Stats
|
||||
coordinator tailnet.Coordinator
|
||||
lastWorkspaceAgent func()
|
||||
|
||||
mu sync.Mutex // Protects following.
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
startup agentsdk.PostStartupRequest
|
||||
}
|
||||
|
||||
func (c *client) WorkspaceAgentMetadata(_ context.Context) (codersdk.WorkspaceAgentMetadata, error) {
|
||||
func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) {
|
||||
return c.metadata, nil
|
||||
}
|
||||
|
||||
func (c *client) ListenWorkspaceAgent(_ context.Context) (net.Conn, error) {
|
||||
func (c *client) Listen(_ context.Context) (net.Conn, error) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
closed := make(chan struct{})
|
||||
c.lastWorkspaceAgent = func() {
|
||||
@@ -873,34 +1245,33 @@ func (c *client) ListenWorkspaceAgent(_ context.Context) (net.Conn, error) {
|
||||
}
|
||||
c.t.Cleanup(c.lastWorkspaceAgent)
|
||||
go func() {
|
||||
_ = c.coordinator.ServeAgent(serverConn, c.agentID)
|
||||
_ = c.coordinator.ServeAgent(serverConn, c.agentID, "")
|
||||
close(closed)
|
||||
}()
|
||||
return clientConn, nil
|
||||
}
|
||||
|
||||
func (c *client) AgentReportStats(ctx context.Context, _ slog.Logger, stats func() *codersdk.AgentStats) (io.Closer, error) {
|
||||
func (c *client) ReportStats(ctx context.Context, _ slog.Logger, statsChan <-chan *agentsdk.Stats, setInterval func(time.Duration)) (io.Closer, error) {
|
||||
doneCh := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
defer close(doneCh)
|
||||
|
||||
t := time.NewTicker(500 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
setInterval(500 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
select {
|
||||
case c.statsChan <- stats():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// We don't want to send old stats.
|
||||
continue
|
||||
case stat := <-statsChan:
|
||||
select {
|
||||
case c.statsChan <- stat:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// We don't want to send old stats.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -912,10 +1283,55 @@ func (c *client) AgentReportStats(ctx context.Context, _ slog.Logger, stats func
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (*client) PostWorkspaceAgentAppHealth(_ context.Context, _ codersdk.PostWorkspaceAppHealthsRequest) error {
|
||||
func (c *client) getLifecycleStates() []codersdk.WorkspaceAgentLifecycle {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lifecycleStates
|
||||
}
|
||||
|
||||
func (c *client) PostLifecycle(_ context.Context, req agentsdk.PostLifecycleRequest) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lifecycleStates = append(c.lifecycleStates, req.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*client) PostWorkspaceAgentVersion(_ context.Context, _ string) error {
|
||||
func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) getStartup() agentsdk.PostStartupRequest {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.startup
|
||||
}
|
||||
|
||||
func (c *client) PostStartup(_ context.Context, startup agentsdk.PostStartupRequest) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.startup = startup
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (*agent) statisticsHandler() http.Handler {
|
||||
func (*agent) apiHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
@@ -27,7 +27,7 @@ func (*agent) statisticsHandler() http.Handler {
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
mut sync.Mutex
|
||||
ports []codersdk.ListeningPort
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ListeningPortsResponse{
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
|
||||
|
||||
// PostWorkspaceAgentAppHealth updates the workspace app health.
|
||||
type PostWorkspaceAgentAppHealth func(context.Context, codersdk.PostWorkspaceAppHealthsRequest) error
|
||||
type PostWorkspaceAgentAppHealth func(context.Context, agentsdk.PostAppHealthsRequest) error
|
||||
|
||||
// WorkspaceAppHealthReporter is a function that checks and reports the health of the workspace apps until the passed context is canceled.
|
||||
type WorkspaceAppHealthReporter func(ctx context.Context)
|
||||
@@ -132,7 +133,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
@@ -157,7 +158,7 @@ func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
// 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))
|
||||
require.LessOrEqual(t, atomic.LoadInt32(counter), int32(2))
|
||||
}
|
||||
|
||||
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
|
||||
@@ -180,7 +181,7 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
var newApps []codersdk.WorkspaceApp
|
||||
return append(newApps, apps...), nil
|
||||
}
|
||||
postWorkspaceAgentAppHealth := func(_ context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error {
|
||||
postWorkspaceAgentAppHealth := func(_ context.Context, req agentsdk.PostAppHealthsRequest) error {
|
||||
mu.Lock()
|
||||
for id, health := range req.Healths {
|
||||
for i, app := range apps {
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
lp.mut.Lock()
|
||||
defer lp.mut.Unlock()
|
||||
|
||||
if time.Since(lp.mtime) < time.Second {
|
||||
// copy
|
||||
ports := make([]codersdk.ListeningPort, len(lp.ports))
|
||||
ports := make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
|
||||
copy(ports, lp.ports)
|
||||
return ports, nil
|
||||
}
|
||||
@@ -30,9 +30,9 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
|
||||
}
|
||||
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.ListeningPort{}
|
||||
ports := []codersdk.WorkspaceAgentListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
|
||||
if tab.Process != nil {
|
||||
procName = tab.Process.Name
|
||||
}
|
||||
ports = append(ports, codersdk.ListeningPort{
|
||||
ports = append(ports, codersdk.WorkspaceAgentListeningPort{
|
||||
ProcessName: procName,
|
||||
Network: codersdk.ListeningPortNetworkTCP,
|
||||
Network: "tcp",
|
||||
Port: tab.LocalAddr.Port,
|
||||
})
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
|
||||
lp.mtime = time.Now()
|
||||
|
||||
// copy
|
||||
ports = make([]codersdk.ListeningPort, len(lp.ports))
|
||||
ports = make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
|
||||
copy(ports, lp.ports)
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ package agent
|
||||
|
||||
import "github.com/coder/coder/codersdk"
|
||||
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
|
||||
// moment. The UI will not show any "no ports found" message to the user, so
|
||||
// the user won't suspect a thing.
|
||||
return []codersdk.ListeningPort{}, nil
|
||||
return []codersdk.WorkspaceAgentListeningPort{}, nil
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,6 +1,10 @@
|
||||
package reaper
|
||||
|
||||
import "github.com/hashicorp/go-reap"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/go-reap"
|
||||
)
|
||||
|
||||
type Option func(o *options)
|
||||
|
||||
@@ -22,7 +26,16 @@ func WithPIDCallback(ch reap.PidCh) Option {
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
ExecArgs []string
|
||||
PIDs reap.PidCh
|
||||
// WithCatchSignals sets the signals that are caught and forwarded to the
|
||||
// child process. By default no signals are forwarded.
|
||||
func WithCatchSignals(sigs ...os.Signal) Option {
|
||||
return func(o *options) {
|
||||
o.CatchSignals = sigs
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
ExecArgs []string
|
||||
PIDs reap.PidCh
|
||||
CatchSignals []os.Signal
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
package reaper_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,9 +18,8 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // Non-parallel subtest.
|
||||
func TestReap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
// things like forkexecing which may have unintended
|
||||
// consequences in CI.
|
||||
@@ -28,8 +30,9 @@ func TestReap(t *testing.T) {
|
||||
// OK checks that's the reaper is successfully reaping
|
||||
// exited processes and passing the PIDs through the shared
|
||||
// channel.
|
||||
|
||||
//nolint:paralleltest // Signal handling.
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
pids := make(reap.PidCh, 1)
|
||||
err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
@@ -64,3 +67,39 @@ func TestReap(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:paralleltest // Signal handling.
|
||||
func TestReapInterrupt(t *testing.T) {
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
// things like forkexecing which may have unintended
|
||||
// consequences in CI.
|
||||
if _, ok := os.LookupEnv("CI"); ok {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
|
||||
errC := make(chan error, 1)
|
||||
pids := make(reap.PidCh, 1)
|
||||
|
||||
// Use signals to notify when the child process is ready for the
|
||||
// next step of our test.
|
||||
usrSig := make(chan os.Signal, 1)
|
||||
signal.Notify(usrSig, syscall.SIGUSR1, syscall.SIGUSR2)
|
||||
defer signal.Stop(usrSig)
|
||||
|
||||
go func() {
|
||||
errC <- reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
reaper.WithCatchSignals(os.Interrupt),
|
||||
// Signal propagation does not extend to children of children, so
|
||||
// we create a little bash script to ensure sleep is interrupted.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
|
||||
)
|
||||
}()
|
||||
|
||||
require.Equal(t, <-usrSig, syscall.SIGUSR1)
|
||||
err := syscall.Kill(os.Getpid(), syscall.SIGINT)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, <-usrSig, syscall.SIGUSR2)
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package reaper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/hashicorp/go-reap"
|
||||
@@ -15,6 +16,24 @@ func IsInitProcess() bool {
|
||||
return os.Getpid() == 1
|
||||
}
|
||||
|
||||
func catchSignals(pid int, sigs []os.Signal) {
|
||||
if len(sigs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sc := make(chan os.Signal, 1)
|
||||
signal.Notify(sc, sigs...)
|
||||
defer signal.Stop(sc)
|
||||
|
||||
for {
|
||||
s := <-sc
|
||||
sig, ok := s.(syscall.Signal)
|
||||
if ok {
|
||||
_ = syscall.Kill(pid, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ForkReap spawns a goroutine that reaps children. In order to avoid
|
||||
// complications with spawning `exec.Commands` in the same process that
|
||||
// is reaping, we forkexec a child process. This prevents a race between
|
||||
@@ -51,13 +70,17 @@ func ForkReap(opt ...Option) error {
|
||||
}
|
||||
|
||||
//#nosec G204
|
||||
pid, _ := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
|
||||
pid, err := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fork exec: %w", err)
|
||||
}
|
||||
|
||||
go catchSignals(pid, opts.CatchSignals)
|
||||
|
||||
var wstatus syscall.WaitStatus
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
for xerrors.Is(err, syscall.EINTR) {
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
+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, 0o700)
|
||||
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)
|
||||
}
|
||||
+16
-2
@@ -21,8 +21,12 @@ var (
|
||||
version string
|
||||
readVersion sync.Once
|
||||
|
||||
// Injected with ldflags at build!
|
||||
tag string
|
||||
// Updated by buildinfo_slim.go on start.
|
||||
slim bool
|
||||
|
||||
// Injected with ldflags at build, see scripts/build_go.sh
|
||||
tag string
|
||||
agpl string // either "true" or "false", ldflags does not support bools
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,6 +77,16 @@ func IsDev() bool {
|
||||
return strings.HasPrefix(Version(), develPrefix)
|
||||
}
|
||||
|
||||
// IsSlim returns true if this is a slim build.
|
||||
func IsSlim() bool {
|
||||
return slim
|
||||
}
|
||||
|
||||
// IsAGPL returns true if this is an AGPL build.
|
||||
func IsAGPL() bool {
|
||||
return strings.Contains(agpl, "t")
|
||||
}
|
||||
|
||||
// ExternalURL returns a URL referencing the current Coder version.
|
||||
// For production builds, this will link directly to a release.
|
||||
// For development builds, this will link to a commit.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build slim
|
||||
|
||||
package buildinfo
|
||||
|
||||
func init() {
|
||||
slim = true
|
||||
}
|
||||
+108
-23
@@ -3,12 +3,15 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
@@ -22,12 +25,13 @@ import (
|
||||
"github.com/coder/coder/agent/reaper"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
var (
|
||||
auth string
|
||||
logDir string
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
)
|
||||
@@ -35,7 +39,7 @@ func workspaceAgent() *cobra.Command {
|
||||
Use: "agent",
|
||||
// This command isn't useful to manually execute.
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
@@ -48,23 +52,26 @@ func workspaceAgent() *cobra.Command {
|
||||
return xerrors.Errorf("parse %q: %w", rawURL, err)
|
||||
}
|
||||
|
||||
logWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(os.TempDir(), "coder-agent.log"),
|
||||
MaxSize: 5, // MB
|
||||
}
|
||||
defer logWriter.Close()
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
|
||||
isLinux := runtime.GOOS == "linux"
|
||||
|
||||
// Spawn a reaper so that we don't accumulate a ton
|
||||
// of zombie processes.
|
||||
if reaper.IsInitProcess() && !noReap && isLinux {
|
||||
logWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "coder-agent-init.log"),
|
||||
MaxSize: 5, // MB
|
||||
}
|
||||
defer logWriter.Close()
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
|
||||
logger.Info(ctx, "spawning reaper process")
|
||||
// Do not start a reaper on the child process. It's important
|
||||
// to do this else we fork bomb ourselves.
|
||||
args := append(os.Args, "--no-reap")
|
||||
err := reaper.ForkReap(reaper.WithExecArgs(args...))
|
||||
err := reaper.ForkReap(
|
||||
reaper.WithExecArgs(args...),
|
||||
reaper.WithCatchSignals(InterruptSignals...),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to reap", slog.Error(err))
|
||||
return xerrors.Errorf("fork reap: %w", err)
|
||||
@@ -74,15 +81,41 @@ func workspaceAgent() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle interrupt signals to allow for graceful shutdown,
|
||||
// note that calling stopNotify disables the signal handler
|
||||
// and the next interrupt will terminate the program (you
|
||||
// probably want cancel instead).
|
||||
//
|
||||
// Note that we don't want to handle these signals in the
|
||||
// process that runs as PID 1, that's why we do this after
|
||||
// the reaper forked.
|
||||
ctx, stopNotify := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer stopNotify()
|
||||
|
||||
// dumpHandler does signal handling, so we call it after the
|
||||
// reaper.
|
||||
go dumpHandler(ctx)
|
||||
|
||||
ljLogger := &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "coder-agent.log"),
|
||||
MaxSize: 5, // MB
|
||||
}
|
||||
defer ljLogger.Close()
|
||||
logWriter := &closeWriter{w: ljLogger}
|
||||
defer logWriter.Close()
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
|
||||
version := buildinfo.Version()
|
||||
logger.Info(ctx, "starting agent",
|
||||
slog.F("url", coderURL),
|
||||
slog.F("auth", auth),
|
||||
slog.F("version", version),
|
||||
)
|
||||
client := codersdk.New(coderURL)
|
||||
client := agentsdk.New(coderURL)
|
||||
client.SDK.Logger = logger
|
||||
// Set a reasonable timeout so requests can't hang forever!
|
||||
client.HTTPClient.Timeout = 10 * time.Second
|
||||
client.SDK.HTTPClient.Timeout = 10 * time.Second
|
||||
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
@@ -93,7 +126,7 @@ func workspaceAgent() *cobra.Command {
|
||||
// exchangeToken returns a session token.
|
||||
// This is abstracted to allow for the same looping condition
|
||||
// regardless of instance identity auth type.
|
||||
var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error)
|
||||
var exchangeToken func(context.Context) (agentsdk.AuthenticateResponse, error)
|
||||
switch auth {
|
||||
case "token":
|
||||
token, err := cmd.Flags().GetString(varAgentToken)
|
||||
@@ -109,8 +142,8 @@ func workspaceAgent() *cobra.Command {
|
||||
if gcpClientRaw != nil {
|
||||
gcpClient, _ = gcpClientRaw.(*metadata.Client)
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient)
|
||||
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
|
||||
return client.AuthGoogleInstanceIdentity(ctx, "", gcpClient)
|
||||
}
|
||||
case "aws-instance-identity":
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
@@ -120,11 +153,11 @@ func workspaceAgent() *cobra.Command {
|
||||
if awsClientRaw != nil {
|
||||
awsClient, _ = awsClientRaw.(*http.Client)
|
||||
if awsClient != nil {
|
||||
client.HTTPClient = awsClient
|
||||
client.SDK.HTTPClient = awsClient
|
||||
}
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
|
||||
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
|
||||
return client.AuthAWSInstanceIdentity(ctx)
|
||||
}
|
||||
case "azure-instance-identity":
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
@@ -134,11 +167,11 @@ func workspaceAgent() *cobra.Command {
|
||||
if azureClientRaw != nil {
|
||||
azureClient, _ = azureClientRaw.(*http.Client)
|
||||
if azureClient != nil {
|
||||
client.HTTPClient = azureClient
|
||||
client.SDK.HTTPClient = azureClient
|
||||
}
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceAzureInstanceIdentity(ctx)
|
||||
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
|
||||
return client.AuthAzureInstanceIdentity(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +187,10 @@ func workspaceAgent() *cobra.Command {
|
||||
closer := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
LogDir: logDir,
|
||||
ExchangeToken: func(ctx context.Context) (string, error) {
|
||||
if exchangeToken == nil {
|
||||
return client.SessionToken(), nil
|
||||
return client.SDK.SessionToken(), nil
|
||||
}
|
||||
resp, err := exchangeToken(ctx)
|
||||
if err != nil {
|
||||
@@ -175,7 +209,58 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &auth, "auth", "", "CODER_AGENT_AUTH", "token", "Specify the authentication type to use for the agent")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noReap, "no-reap", "", "", false, "Do not start a process reaper.")
|
||||
cliflag.StringVarP(cmd.Flags(), &logDir, "log-dir", "", "CODER_AGENT_LOG_DIR", os.TempDir(), "Specify the location for the agent log files")
|
||||
cliflag.StringVarP(cmd.Flags(), &pprofAddress, "pprof-address", "", "CODER_AGENT_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noReap, "no-reap", "", "", false, "Do not start a process reaper.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler, addr, name string) (closeFunc func()) {
|
||||
logger.Debug(ctx, "http server listening", slog.F("addr", addr), slog.F("name", name))
|
||||
|
||||
// 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
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
}
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && !xerrors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "http server listen", slog.F("name", name), slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
_ = srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// closeWriter is a wrapper around an io.WriteCloser that prevents
|
||||
// writes after Close. This is necessary because lumberjack will
|
||||
// re-open the file on write.
|
||||
type closeWriter struct {
|
||||
w io.WriteCloser
|
||||
mu sync.Mutex // Protects following.
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *closeWriter) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.closed = true
|
||||
return c.w.Close()
|
||||
}
|
||||
|
||||
func (c *closeWriter) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return c.w.Write(p)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,10 +16,70 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestWorkspaceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("LogDirectory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
authToken := uuid.NewString()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "someagent",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
logDir := t.TempDir()
|
||||
cmd, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", authToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
cancel()
|
||||
err := <-errC
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := os.Stat(filepath.Join(logDir, "coder-agent.log"))
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, info.Size(), int64(0))
|
||||
})
|
||||
|
||||
t.Run("Azure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
|
||||
@@ -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,11 +71,14 @@ 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()
|
||||
if mode == 0 {
|
||||
mode = 0600
|
||||
mode = 0o600
|
||||
}
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
|
||||
+136
-41
@@ -10,16 +10,21 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/muesli/reflow/indent"
|
||||
"github.com/muesli/reflow/wordwrap"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var AgentStartError = xerrors.New("agent startup exited with non-zero exit status")
|
||||
|
||||
type AgentOptions struct {
|
||||
WorkspaceName string
|
||||
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
|
||||
FetchInterval time.Duration
|
||||
WarnInterval time.Duration
|
||||
NoWait bool // If true, don't wait for the agent to be ready.
|
||||
}
|
||||
|
||||
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
||||
@@ -36,48 +41,33 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected {
|
||||
// Fast path if the agent is ready (avoid showing connecting prompt).
|
||||
// We don't take the fast path for opts.NoWait yet because we want to
|
||||
// show the message.
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected &&
|
||||
(agent.LoginBeforeReady || agent.LifecycleState == codersdk.WorkspaceAgentLifecycleReady) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
||||
spin.Writer = writer
|
||||
spin.ForceOutput = true
|
||||
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..."
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
spin.Suffix = waitingMessage(agent, opts).Spin
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
defer cancelFunc()
|
||||
stopSpin := make(chan os.Signal, 1)
|
||||
signal.Notify(stopSpin, os.Interrupt)
|
||||
defer signal.Stop(stopSpin)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopSpin:
|
||||
}
|
||||
cancelFunc()
|
||||
signal.Stop(stopSpin)
|
||||
spin.Stop()
|
||||
// nolint:revive
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
var waitMessage string
|
||||
messageAfter := time.NewTimer(opts.WarnInterval)
|
||||
defer messageAfter.Stop()
|
||||
waitMessage := &message{}
|
||||
showMessage := func() {
|
||||
resourceMutex.Lock()
|
||||
defer resourceMutex.Unlock()
|
||||
|
||||
m := waitingMessage(agent)
|
||||
if m == waitMessage {
|
||||
m := waitingMessage(agent, opts)
|
||||
if m.Prompt == waitMessage.Prompt {
|
||||
return
|
||||
}
|
||||
moveUp := ""
|
||||
if waitMessage != "" {
|
||||
if waitMessage.Prompt != "" {
|
||||
// If this is an update, move a line up
|
||||
// to keep it tidy and aligned.
|
||||
moveUp = "\033[1A"
|
||||
@@ -86,20 +76,43 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
|
||||
// Stop the spinner while we write our message.
|
||||
spin.Stop()
|
||||
spin.Suffix = waitMessage.Spin
|
||||
// Clear the line and (if necessary) move up a line to write our message.
|
||||
_, _ = fmt.Fprintf(writer, "\033[2K%s%s\n\n", moveUp, Styles.Paragraph.Render(Styles.Prompt.String()+waitMessage))
|
||||
_, _ = fmt.Fprintf(writer, "\033[2K%s\n%s\n", moveUp, waitMessage.Prompt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
default:
|
||||
// Safe to resume operation.
|
||||
spin.Start()
|
||||
if spin.Suffix != "" {
|
||||
spin.Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path for showing the error message even when using no wait,
|
||||
// we do this just before starting the spinner to avoid needless
|
||||
// spinning.
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected &&
|
||||
!agent.LoginBeforeReady && opts.NoWait {
|
||||
showMessage()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start spinning after fast paths are handled.
|
||||
if spin.Suffix != "" {
|
||||
spin.Start()
|
||||
}
|
||||
defer spin.Stop()
|
||||
|
||||
warnAfter := time.NewTimer(opts.WarnInterval)
|
||||
defer warnAfter.Stop()
|
||||
warningShown := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-messageAfter.C:
|
||||
messageAfter.Stop()
|
||||
close(warningShown)
|
||||
case <-warnAfter.C:
|
||||
close(warningShown)
|
||||
showMessage()
|
||||
}
|
||||
}()
|
||||
@@ -121,6 +134,29 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
resourceMutex.Unlock()
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
// NOTE(mafredri): Once we have access to the workspace agent's
|
||||
// startup script logs, we can show them here.
|
||||
// https://github.com/coder/coder/issues/2957
|
||||
if !agent.LoginBeforeReady && !opts.NoWait {
|
||||
switch agent.LifecycleState {
|
||||
case codersdk.WorkspaceAgentLifecycleReady:
|
||||
return nil
|
||||
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||
showMessage()
|
||||
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||
showMessage()
|
||||
return AgentStartError
|
||||
default:
|
||||
select {
|
||||
case <-warningShown:
|
||||
showMessage()
|
||||
default:
|
||||
// This state is normal, we don't want
|
||||
// to show a message prematurely.
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
case codersdk.WorkspaceAgentTimeout, codersdk.WorkspaceAgentDisconnected:
|
||||
showMessage()
|
||||
@@ -128,19 +164,78 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
func waitingMessage(agent codersdk.WorkspaceAgent) string {
|
||||
var m string
|
||||
type message struct {
|
||||
Spin string
|
||||
Prompt string
|
||||
Troubleshoot bool
|
||||
}
|
||||
|
||||
func waitingMessage(agent codersdk.WorkspaceAgent, opts AgentOptions) (m *message) {
|
||||
m = &message{
|
||||
Spin: fmt.Sprintf("Waiting for connection from %s...", Styles.Field.Render(agent.Name)),
|
||||
Prompt: "Don't panic, your workspace is booting up!",
|
||||
}
|
||||
defer func() {
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected && opts.NoWait {
|
||||
m.Spin = ""
|
||||
}
|
||||
if m.Spin != "" {
|
||||
m.Spin = " " + m.Spin
|
||||
}
|
||||
|
||||
// We don't want to wrap the troubleshooting URL, so we'll handle word
|
||||
// wrapping ourselves (vs using lipgloss).
|
||||
w := wordwrap.NewWriter(Styles.Paragraph.GetWidth() - Styles.Paragraph.GetMarginLeft()*2)
|
||||
w.Breakpoints = []rune{' ', '\n'}
|
||||
|
||||
_, _ = fmt.Fprint(w, m.Prompt)
|
||||
if m.Troubleshoot {
|
||||
if agent.TroubleshootingURL != "" {
|
||||
_, _ = fmt.Fprintf(w, " See troubleshooting instructions at:\n%s", agent.TroubleshootingURL)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, " Wait for it to (re)connect or restart your workspace.")
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "\n")
|
||||
|
||||
// We want to prefix the prompt with a caret, but we want text on the
|
||||
// following lines to align with the text on the first line (i.e. added
|
||||
// spacing).
|
||||
ind := " " + Styles.Prompt.String()
|
||||
iw := indent.NewWriter(1, func(w io.Writer) {
|
||||
_, _ = w.Write([]byte(ind))
|
||||
ind = " " // Set indentation to space after initial prompt.
|
||||
})
|
||||
_, _ = fmt.Fprint(iw, w.String())
|
||||
m.Prompt = iw.String()
|
||||
}()
|
||||
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentTimeout:
|
||||
m = "The workspace agent is having trouble connecting."
|
||||
m.Prompt = "The workspace agent is having trouble connecting."
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
m = "The workspace agent lost connection!"
|
||||
m.Prompt = "The workspace agent lost connection!"
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
m.Spin = fmt.Sprintf("Waiting for %s to become ready...", Styles.Field.Render(agent.Name))
|
||||
m.Prompt = "Don't panic, your workspace agent has connected and the workspace is getting ready!"
|
||||
if opts.NoWait {
|
||||
m.Prompt = "Your workspace is still getting ready, it may be in an incomplete state."
|
||||
}
|
||||
|
||||
switch agent.LifecycleState {
|
||||
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||
m.Prompt = "The workspace is taking longer than expected to get ready, the agent startup script is still executing."
|
||||
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||
m.Spin = ""
|
||||
m.Prompt = "The workspace ran into a problem while getting ready, the agent startup script exited with non-zero status."
|
||||
default:
|
||||
// Not a failure state, no troubleshooting necessary.
|
||||
return m
|
||||
}
|
||||
default:
|
||||
// Not a failure state, no troubleshooting necessary.
|
||||
return "Don't panic, your workspace is booting up!"
|
||||
return m
|
||||
}
|
||||
if agent.TroubleshootingURL != "" {
|
||||
return fmt.Sprintf("%s See troubleshooting instructions at: %s", m, agent.TroubleshootingURL)
|
||||
}
|
||||
return fmt.Sprintf("%s Wait for it to (re)connect or restart your workspace.", m)
|
||||
m.Troubleshoot = true
|
||||
return m
|
||||
}
|
||||
|
||||
+275
-18
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
@@ -17,15 +18,20 @@ import (
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
var disconnected atomic.Bool
|
||||
ptty := ptytest.New(t)
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
LoginBeforeReady: true,
|
||||
}
|
||||
if disconnected.Load() {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
@@ -46,33 +52,35 @@ func TestAgent(t *testing.T) {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("lost connection")
|
||||
ptty.ExpectMatchContext(ctx, "lost connection")
|
||||
disconnected.Store(true)
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
||||
func TestAgent_TimeoutWithTroubleshootingURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
wantURL := "https://coder.com/troubleshoot"
|
||||
|
||||
var connected, timeout atomic.Bool
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
TroubleshootingURL: "https://coder.com/troubleshoot",
|
||||
TroubleshootingURL: wantURL,
|
||||
LoginBeforeReady: true,
|
||||
}
|
||||
switch {
|
||||
case !connected.Load() && timeout.Load():
|
||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||
case connected.Load():
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
case timeout.Load():
|
||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
@@ -85,15 +93,264 @@ func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
ptty.ExpectMatch("Don't panic")
|
||||
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||
timeout.Store(true)
|
||||
ptty.ExpectMatch(wantURL)
|
||||
ptty.ExpectMatchContext(ctx, wantURL)
|
||||
connected.Store(true)
|
||||
<-done
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestAgent_StartupTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||
|
||||
var status, state atomic.String
|
||||
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
LoginBeforeReady: false,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
TroubleshootingURL: wantURL,
|
||||
}
|
||||
|
||||
if s := status.Load(); s != "" {
|
||||
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||
}
|
||||
if s := state.Load(); s != "" {
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: time.Millisecond,
|
||||
NoWait: false,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||
setStatus(codersdk.WorkspaceAgentConnected)
|
||||
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||
ptty.ExpectMatchContext(ctx, "workspace is getting ready")
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
ptty.ExpectMatchContext(ctx, "is taking longer")
|
||||
ptty.ExpectMatchContext(ctx, wantURL)
|
||||
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestAgent_StartErrorExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||
|
||||
var status, state atomic.String
|
||||
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
LoginBeforeReady: false,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
TroubleshootingURL: wantURL,
|
||||
}
|
||||
|
||||
if s := status.Load(); s != "" {
|
||||
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||
}
|
||||
if s := state.Load(); s != "" {
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: 60 * time.Second,
|
||||
NoWait: false,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
setStatus(codersdk.WorkspaceAgentConnected)
|
||||
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||
ptty.ExpectMatchContext(ctx, "to become ready...")
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||
ptty.ExpectMatchContext(ctx, "ran into a problem")
|
||||
err := <-done
|
||||
require.ErrorIs(t, err, cliui.AgentStartError, "lifecycle start_error should exit with error")
|
||||
}
|
||||
|
||||
func TestAgent_NoWait(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||
|
||||
var status, state atomic.String
|
||||
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
LoginBeforeReady: false,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
TroubleshootingURL: wantURL,
|
||||
}
|
||||
|
||||
if s := status.Load(); s != "" {
|
||||
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||
}
|
||||
if s := state.Load(); s != "" {
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: time.Second,
|
||||
NoWait: true,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||
|
||||
setStatus(codersdk.WorkspaceAgentConnected)
|
||||
require.NoError(t, <-done, "created - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "starting - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "start timeout - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "start error - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "ready - should exit early")
|
||||
}
|
||||
|
||||
func TestAgent_LoginBeforeReadyEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||
|
||||
var status, state atomic.String
|
||||
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
LoginBeforeReady: true,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
TroubleshootingURL: wantURL,
|
||||
}
|
||||
|
||||
if s := status.Load(); s != "" {
|
||||
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||
}
|
||||
if s := state.Load(); s != "" {
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: time.Second,
|
||||
NoWait: false,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||
|
||||
setStatus(codersdk.WorkspaceAgentConnected)
|
||||
require.NoError(t, <-done, "created - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "starting - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "start timeout - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "start error - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||
require.NoError(t, <-done, "ready - should exit early")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type OutputFormat interface {
|
||||
ID() string
|
||||
AttachFlags(cmd *cobra.Command)
|
||||
Format(ctx context.Context, data any) (string, error)
|
||||
}
|
||||
|
||||
type OutputFormatter struct {
|
||||
formats []OutputFormat
|
||||
formatID string
|
||||
}
|
||||
|
||||
// NewOutputFormatter creates a new OutputFormatter with the given formats. The
|
||||
// first format is the default format. At least two formats must be provided.
|
||||
func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter {
|
||||
if len(formats) < 2 {
|
||||
panic("at least two output formats must be provided")
|
||||
}
|
||||
|
||||
formatIDs := make(map[string]struct{}, len(formats))
|
||||
for _, format := range formats {
|
||||
if format.ID() == "" {
|
||||
panic("output format ID must not be empty")
|
||||
}
|
||||
if _, ok := formatIDs[format.ID()]; ok {
|
||||
panic("duplicate format ID: " + format.ID())
|
||||
}
|
||||
formatIDs[format.ID()] = struct{}{}
|
||||
}
|
||||
|
||||
return &OutputFormatter{
|
||||
formats: formats,
|
||||
formatID: formats[0].ID(),
|
||||
}
|
||||
}
|
||||
|
||||
// AttachFlags attaches the --output flag to the given command, and any
|
||||
// additional flags required by the output formatters.
|
||||
func (f *OutputFormatter) AttachFlags(cmd *cobra.Command) {
|
||||
for _, format := range f.formats {
|
||||
format.AttachFlags(cmd)
|
||||
}
|
||||
|
||||
formatNames := make([]string, 0, len(f.formats))
|
||||
for _, format := range f.formats {
|
||||
formatNames = append(formatNames, format.ID())
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&f.formatID, "output", "o", f.formats[0].ID(), "Output format. Available formats: "+strings.Join(formatNames, ", "))
|
||||
}
|
||||
|
||||
// Format formats the given data using the format specified by the --output
|
||||
// flag. If the flag is not set, the default format is used.
|
||||
func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) {
|
||||
for _, format := range f.formats {
|
||||
if format.ID() == f.formatID {
|
||||
return format.Format(ctx, data)
|
||||
}
|
||||
}
|
||||
|
||||
return "", xerrors.Errorf("unknown output format %q", f.formatID)
|
||||
}
|
||||
|
||||
type tableFormat struct {
|
||||
defaultColumns []string
|
||||
allColumns []string
|
||||
sort string
|
||||
|
||||
columns []string
|
||||
}
|
||||
|
||||
var _ OutputFormat = &tableFormat{}
|
||||
|
||||
// TableFormat creates a table formatter for the given output type. The output
|
||||
// type should be specified as an empty slice of the desired type.
|
||||
//
|
||||
// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"})
|
||||
//
|
||||
// defaultColumns is optional and specifies the default columns to display. If
|
||||
// not specified, all columns are displayed by default.
|
||||
func TableFormat(out any, defaultColumns []string) OutputFormat {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
if v.Kind() != reflect.Slice {
|
||||
panic("DisplayTable called with a non-slice type")
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headers, defaultSort, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
panic("parse table headers: " + err.Error())
|
||||
}
|
||||
|
||||
tf := &tableFormat{
|
||||
defaultColumns: headers,
|
||||
allColumns: headers,
|
||||
sort: defaultSort,
|
||||
}
|
||||
if len(defaultColumns) > 0 {
|
||||
tf.defaultColumns = defaultColumns
|
||||
}
|
||||
|
||||
return tf
|
||||
}
|
||||
|
||||
// ID implements OutputFormat.
|
||||
func (*tableFormat) ID() string {
|
||||
return "table"
|
||||
}
|
||||
|
||||
// AttachFlags implements OutputFormat.
|
||||
func (f *tableFormat) AttachFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().StringSliceVarP(&f.columns, "column", "c", f.defaultColumns, "Columns to display in table output. Available columns: "+strings.Join(f.allColumns, ", "))
|
||||
}
|
||||
|
||||
// Format implements OutputFormat.
|
||||
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
|
||||
return DisplayTable(data, f.sort, f.columns)
|
||||
}
|
||||
|
||||
type jsonFormat struct{}
|
||||
|
||||
var _ OutputFormat = jsonFormat{}
|
||||
|
||||
// JSONFormat creates a JSON formatter.
|
||||
func JSONFormat() OutputFormat {
|
||||
return jsonFormat{}
|
||||
}
|
||||
|
||||
// ID implements OutputFormat.
|
||||
func (jsonFormat) ID() string {
|
||||
return "json"
|
||||
}
|
||||
|
||||
// AttachFlags implements OutputFormat.
|
||||
func (jsonFormat) AttachFlags(_ *cobra.Command) {}
|
||||
|
||||
// Format implements OutputFormat.
|
||||
func (jsonFormat) Format(_ context.Context, data any) (string, error) {
|
||||
outBytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal output to JSON: %w", err)
|
||||
}
|
||||
|
||||
return string(outBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
type format struct {
|
||||
id string
|
||||
attachFlagsFn func(cmd *cobra.Command)
|
||||
formatFn func(ctx context.Context, data any) (string, error)
|
||||
}
|
||||
|
||||
var _ cliui.OutputFormat = &format{}
|
||||
|
||||
func (f *format) ID() string {
|
||||
return f.id
|
||||
}
|
||||
|
||||
func (f *format) AttachFlags(cmd *cobra.Command) {
|
||||
if f.attachFlagsFn != nil {
|
||||
f.attachFlagsFn(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *format) Format(ctx context.Context, data any) (string, error) {
|
||||
if f.formatFn != nil {
|
||||
return f.formatFn(ctx, data)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func Test_OutputFormatter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RequiresTwoFormatters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, func() {
|
||||
cliui.NewOutputFormatter()
|
||||
})
|
||||
require.Panics(t, func() {
|
||||
cliui.NewOutputFormatter(cliui.JSONFormat())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoMissingFormatID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, func() {
|
||||
cliui.NewOutputFormatter(
|
||||
cliui.JSONFormat(),
|
||||
&format{id: ""},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoDuplicateFormats", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, func() {
|
||||
cliui.NewOutputFormatter(
|
||||
cliui.JSONFormat(),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var called int64
|
||||
f := cliui.NewOutputFormatter(
|
||||
cliui.JSONFormat(),
|
||||
&format{
|
||||
id: "foo",
|
||||
attachFlagsFn: func(cmd *cobra.Command) {
|
||||
cmd.Flags().StringP("foo", "f", "", "foo flag 1234")
|
||||
},
|
||||
formatFn: func(_ context.Context, _ any) (string, error) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
return "foo", nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
f.AttachFlags(cmd)
|
||||
|
||||
selected, err := cmd.Flags().GetString("output")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "json", selected)
|
||||
usage := cmd.Flags().FlagUsages()
|
||||
require.Contains(t, usage, "Available formats: json, foo")
|
||||
require.Contains(t, usage, "foo flag 1234")
|
||||
|
||||
ctx := context.Background()
|
||||
data := []string{"hi", "dean", "was", "here"}
|
||||
out, err := f.Format(ctx, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []string
|
||||
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
||||
require.Equal(t, data, got)
|
||||
require.EqualValues(t, 0, atomic.LoadInt64(&called))
|
||||
|
||||
require.NoError(t, cmd.Flags().Set("output", "foo"))
|
||||
out, err = f.Format(ctx, data)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "foo", out)
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||
|
||||
require.NoError(t, cmd.Flags().Set("output", "bar"))
|
||||
out, err = f.Format(ctx, data)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "bar")
|
||||
require.Equal(t, "", out)
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||
})
|
||||
}
|
||||
@@ -60,3 +60,59 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render(templateVersionParameter.Name))
|
||||
if templateVersionParameter.DescriptionPlaintext != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
var err error
|
||||
var value string
|
||||
if len(templateVersionParameter.Options) > 0 {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
var richParameterOption *codersdk.TemplateVersionParameterOption
|
||||
richParameterOption, err = RichSelect(cmd, RichSelectOptions{
|
||||
Options: templateVersionParameter.Options,
|
||||
Default: templateVersionParameter.DefaultValue,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(richParameterOption.Name))
|
||||
value = richParameterOption.Value
|
||||
}
|
||||
} else {
|
||||
text := "Enter a value"
|
||||
if templateVersionParameter.DefaultValue != "" {
|
||||
text += fmt.Sprintf(" (default: %q)", templateVersionParameter.DefaultValue)
|
||||
}
|
||||
text += ":"
|
||||
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
Text: Styles.Bold.Render(text),
|
||||
Validate: func(value string) error {
|
||||
return validateRichPrompt(value, templateVersionParameter)
|
||||
},
|
||||
})
|
||||
value = strings.TrimSpace(value)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If they didn't specify anything, use the default value if set.
|
||||
if len(templateVersionParameter.Options) == 0 && value == "" {
|
||||
value = templateVersionParameter.DefaultValue
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateRichPrompt(value string, p codersdk.TemplateVersionParameter) error {
|
||||
return codersdk.ValidateWorkspaceBuildParameter(p, codersdk.WorkspaceBuildParameter{
|
||||
Name: p.Name,
|
||||
Value: value,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestWorkspaceResources(t *testing.T) {
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Name: "dev",
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}},
|
||||
@@ -60,6 +61,7 @@ func TestWorkspaceResources(t *testing.T) {
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
CreatedAt: database.Now().Add(-10 * time.Second),
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
Name: "dev",
|
||||
OperatingSystem: "linux",
|
||||
Architecture: "amd64",
|
||||
@@ -70,12 +72,14 @@ func TestWorkspaceResources(t *testing.T) {
|
||||
Name: "dev",
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleReady,
|
||||
Name: "go",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}, {
|
||||
DisconnectedAt: &disconnected,
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleReady,
|
||||
Name: "postgres",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,6 +45,42 @@ type SelectOptions struct {
|
||||
HideSearch bool
|
||||
}
|
||||
|
||||
type RichSelectOptions struct {
|
||||
Options []codersdk.TemplateVersionParameterOption
|
||||
Default string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
|
||||
// RichSelect displays a list of user options including name and description.
|
||||
func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
|
||||
opts := make([]string, len(richOptions.Options))
|
||||
for i, option := range richOptions.Options {
|
||||
line := option.Name
|
||||
if len(option.Description) > 0 {
|
||||
line += ": " + option.Description
|
||||
}
|
||||
opts[i] = line
|
||||
}
|
||||
|
||||
selected, err := Select(cmd, SelectOptions{
|
||||
Options: opts,
|
||||
Default: richOptions.Default,
|
||||
Size: richOptions.Size,
|
||||
HideSearch: richOptions.HideSearch,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, option := range opts {
|
||||
if option == selected {
|
||||
return &richOptions.Options[i], nil
|
||||
}
|
||||
}
|
||||
return nil, xerrors.Errorf("unknown option selected: %s", selected)
|
||||
}
|
||||
|
||||
// Select displays a list of user options.
|
||||
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
// The survey library used *always* fails when testing on Windows,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
@@ -42,3 +43,46 @@ func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func TestRichSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("RichSelect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newRichSelect(ptty, cliui.RichSelectOptions{
|
||||
Options: []codersdk.TemplateVersionParameterOption{
|
||||
{
|
||||
Name: "A-Name",
|
||||
Value: "A-Value",
|
||||
Description: "A-Description",
|
||||
}, {
|
||||
Name: "B-Name",
|
||||
Value: "B-Value",
|
||||
Description: "B-Description",
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
require.Equal(t, "A-Value", <-msgChan)
|
||||
})
|
||||
}
|
||||
|
||||
func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
richOption, err := cliui.RichSelect(cmd, opts)
|
||||
if err == nil {
|
||||
value = richOption.Value
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
+44
-39
@@ -22,10 +22,10 @@ func Table() table.Writer {
|
||||
return tableWriter
|
||||
}
|
||||
|
||||
// FilterTableColumns returns configurations to hide columns
|
||||
// filterTableColumns returns configurations to hide columns
|
||||
// that are not provided in the array. If the array is empty,
|
||||
// no filtering will occur!
|
||||
func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
|
||||
func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
|
||||
if len(columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -51,6 +51,9 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
||||
// of structs. At least one field in the struct must have a `table:""` tag
|
||||
// containing the name of the column in the outputted table.
|
||||
//
|
||||
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
|
||||
// tag will be used to sort. An error will be returned if no field has this tag.
|
||||
//
|
||||
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
|
||||
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
|
||||
// malformed or a field is marked as recursive but does not contain a struct or
|
||||
@@ -67,13 +70,16 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headersRaw, err := typeToTableHeaders(v.Type().Elem())
|
||||
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||
}
|
||||
if len(headersRaw) == 0 {
|
||||
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
|
||||
}
|
||||
if sort == "" {
|
||||
sort = defaultSort
|
||||
}
|
||||
headers := make(table.Row, len(headersRaw))
|
||||
for i, header := range headersRaw {
|
||||
headers[i] = header
|
||||
@@ -101,7 +107,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
||||
h, ok := headersMap[column]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
|
||||
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, column, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
|
||||
// Autocorrect
|
||||
@@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
// Setup the table formatter.
|
||||
tw := Table()
|
||||
tw.AppendHeader(headers)
|
||||
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
|
||||
tw.SetColumnConfigs(filterTableColumns(headers, filterColumns))
|
||||
if sort != "" {
|
||||
tw.SortBy([]table.SortBy{{
|
||||
Name: sort,
|
||||
@@ -182,29 +188,32 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, nil
|
||||
return "", false, false, nil
|
||||
}
|
||||
|
||||
recursive := false
|
||||
defaultSortOpt := false
|
||||
recursiveOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
if opt == "recursive" {
|
||||
recursive = true
|
||||
continue
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
defaultSortOpt = true
|
||||
case "recursive":
|
||||
recursiveOpt = true
|
||||
default:
|
||||
return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
|
||||
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -214,34 +223,41 @@ func isStructOrStructPointer(t reflect.Type) bool {
|
||||
// typeToTableHeaders converts a type to a slice of column names. If the given
|
||||
// type is invalid (not a struct or a pointer to a struct, has invalid table
|
||||
// tags, etc.), an error is returned.
|
||||
func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
||||
func typeToTableHeaders(t reflect.Type) ([]string, string, error) {
|
||||
if !isStructOrStructPointer(t) {
|
||||
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
|
||||
return nil, "", xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
|
||||
}
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
headers := []string{}
|
||||
defaultSortName := ""
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
name, defaultSort, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if defaultSort {
|
||||
if defaultSortName != "" {
|
||||
return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String())
|
||||
}
|
||||
defaultSortName = name
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
|
||||
return nil, "", xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
|
||||
}
|
||||
|
||||
childNames, err := typeToTableHeaders(fieldType)
|
||||
childNames, _, err := typeToTableHeaders(fieldType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
}
|
||||
for _, childName := range childNames {
|
||||
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
||||
@@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
||||
headers = append(headers, name)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
if defaultSortName == "" {
|
||||
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
|
||||
}
|
||||
|
||||
return headers, defaultSortName, nil
|
||||
}
|
||||
|
||||
// valueToTableMap converts a struct to a map of column name to value. If the
|
||||
@@ -276,7 +296,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
name, _, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
@@ -309,18 +329,3 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// TableHeaders returns the table header names of all
|
||||
// fields in tSlice. tSlice must be a slice of some type.
|
||||
func TableHeaders(tSlice any) ([]string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(tSlice))
|
||||
rawHeaders, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("type to table headers: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(rawHeaders))
|
||||
for _, hdr := range rawHeaders {
|
||||
out = append(out, strings.Replace(hdr, " ", "_", -1))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+32
-52
@@ -24,7 +24,7 @@ func (s stringWrapper) String() string {
|
||||
}
|
||||
|
||||
type tableTest1 struct {
|
||||
Name string `table:"name"`
|
||||
Name string `table:"name,default_sort"`
|
||||
NotIncluded string // no table tag
|
||||
Age int `table:"age"`
|
||||
Roles []string `table:"roles"`
|
||||
@@ -39,21 +39,45 @@ type tableTest1 struct {
|
||||
}
|
||||
|
||||
type tableTest2 struct {
|
||||
Name stringWrapper `table:"name"`
|
||||
Name stringWrapper `table:"name,default_sort"`
|
||||
Age int `table:"age"`
|
||||
NotIncluded string `table:"-"`
|
||||
}
|
||||
|
||||
type tableTest3 struct {
|
||||
NotIncluded string // no table tag
|
||||
Sub tableTest2 `table:"inner,recursive"`
|
||||
Sub tableTest2 `table:"inner,recursive,default_sort"`
|
||||
}
|
||||
|
||||
func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC)
|
||||
|
||||
// Not sorted by name or age to test sorting.
|
||||
in := []tableTest1{
|
||||
{
|
||||
Name: "bar",
|
||||
Age: 20,
|
||||
Roles: []string{"a"},
|
||||
Sub1: tableTest2{
|
||||
Name: stringWrapper{str: "bar1"},
|
||||
Age: 21,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: stringWrapper{str: "bar3"},
|
||||
Age: 23,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: stringWrapper{str: "bar4"},
|
||||
Age: 24,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
{
|
||||
Name: "foo",
|
||||
Age: 10,
|
||||
@@ -79,28 +103,6 @@ func Test_DisplayTable(t *testing.T) {
|
||||
Time: someTime,
|
||||
TimePtr: &someTime,
|
||||
},
|
||||
{
|
||||
Name: "bar",
|
||||
Age: 20,
|
||||
Roles: []string{"a"},
|
||||
Sub1: tableTest2{
|
||||
Name: stringWrapper{str: "bar1"},
|
||||
Age: 21,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: stringWrapper{str: "bar3"},
|
||||
Age: 23,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: stringWrapper{str: "bar4"},
|
||||
Age: 24,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
{
|
||||
Name: "baz",
|
||||
Age: 30,
|
||||
@@ -132,9 +134,9 @@ func Test_DisplayTable(t *testing.T) {
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
// Test with non-pointer values.
|
||||
@@ -154,17 +156,17 @@ baz 30 [] baz1 31 <nil> <nil> baz3
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Sort", func(t *testing.T) {
|
||||
t.Run("CustomSort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "name", nil)
|
||||
out, err := cliui.DisplayTable(in, "age", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
@@ -175,9 +177,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
|
||||
|
||||
expected := `
|
||||
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||
foo foo1 foo3 2022-08-02T15:49:10Z
|
||||
bar bar1 bar3 2022-08-02T15:49:10Z
|
||||
baz baz1 baz3 2022-08-02T15:49:10Z
|
||||
foo foo1 foo3 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
||||
@@ -327,28 +329,6 @@ baz baz1 baz3 2022-08-02T15:49:10Z
|
||||
})
|
||||
}
|
||||
|
||||
func Test_TableHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := []tableTest1{}
|
||||
expectedFields := []string{
|
||||
"name",
|
||||
"age",
|
||||
"roles",
|
||||
"sub_1_name",
|
||||
"sub_1_age",
|
||||
"sub_2_name",
|
||||
"sub_2_age",
|
||||
"sub_3_inner_name",
|
||||
"sub_3_inner_age",
|
||||
"sub_4",
|
||||
"time",
|
||||
"time_ptr",
|
||||
}
|
||||
headers, err := cliui.TableHeaders(s)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, expectedFields, headers)
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
func compareTables(t *testing.T, expected, out string) {
|
||||
t.Helper()
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ func (f File) Delete() error {
|
||||
|
||||
// Write writes the string to the file.
|
||||
func (f File) Write(s string) error {
|
||||
return write(string(f), 0600, []byte(s))
|
||||
return write(string(f), 0o600, []byte(s))
|
||||
}
|
||||
|
||||
// Read reads the file to a string.
|
||||
@@ -72,7 +72,7 @@ func (f File) Read() (string, error) {
|
||||
// open opens a file in the configuration directory,
|
||||
// creating all intermediate directories.
|
||||
func open(path string, flag int, mode os.FileMode) (*os.File, error) {
|
||||
err := os.MkdirAll(filepath.Dir(path), 0750)
|
||||
err := os.MkdirAll(filepath.Dir(path), 0o750)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-14
@@ -206,7 +206,11 @@ func configSSH() *cobra.Command {
|
||||
// Parse the previous configuration only if config-ssh
|
||||
// has been run previously.
|
||||
var lastConfig *sshConfigOptions
|
||||
if section, ok := sshConfigGetCoderSection(configRaw); ok {
|
||||
section, ok, err := sshConfigGetCoderSection(configRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
c := sshConfigParseLastOptions(bytes.NewReader(section))
|
||||
lastConfig = &c
|
||||
}
|
||||
@@ -249,7 +253,10 @@ func configSSH() *cobra.Command {
|
||||
configModified := configRaw
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
before, after := sshConfigSplitOnCoderSection(configModified)
|
||||
before, _, after, err := sshConfigSplitOnCoderSection(configModified)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Write the first half of the users config file to buf.
|
||||
_, _ = buf.Write(before)
|
||||
|
||||
@@ -418,22 +425,39 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
|
||||
return o
|
||||
}
|
||||
|
||||
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool) {
|
||||
startIndex := bytes.Index(data, []byte(sshStartToken))
|
||||
endIndex := bytes.Index(data, []byte(sshEndToken))
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
return data[startIndex : endIndex+len(sshEndToken)], true
|
||||
// sshConfigGetCoderSection is a helper function that only returns the coder
|
||||
// section of the SSH config and a boolean if it exists.
|
||||
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool, err error) {
|
||||
_, section, _, err = sshConfigSplitOnCoderSection(data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false
|
||||
|
||||
return section, len(section) > 0, nil
|
||||
}
|
||||
|
||||
// sshConfigSplitOnCoderSection splits the SSH config into two sections,
|
||||
// before contains the lines before sshStartToken and after contains the
|
||||
// lines after sshEndToken.
|
||||
func sshConfigSplitOnCoderSection(data []byte) (before, after []byte) {
|
||||
// sshConfigSplitOnCoderSection splits the SSH config into 3 sections.
|
||||
// All lines before sshStartToken, the coder section, and all lines after
|
||||
// sshEndToken.
|
||||
func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []byte, err error) {
|
||||
startCount := bytes.Count(data, []byte(sshStartToken))
|
||||
endCount := bytes.Count(data, []byte(sshEndToken))
|
||||
if startCount > 1 || endCount > 1 {
|
||||
return nil, nil, nil, xerrors.New("Malformed config: ssh config has multiple coder sections, please remove all but one")
|
||||
}
|
||||
|
||||
startIndex := bytes.Index(data, []byte(sshStartToken))
|
||||
endIndex := bytes.Index(data, []byte(sshEndToken))
|
||||
if startIndex == -1 && endIndex != -1 {
|
||||
return nil, nil, nil, xerrors.New("Malformed config: ssh config has end header, but missing start header")
|
||||
}
|
||||
if startIndex != -1 && endIndex == -1 {
|
||||
return nil, nil, nil, xerrors.New("Malformed config: ssh config has start header, but missing end header")
|
||||
}
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
if startIndex > endIndex {
|
||||
return nil, nil, nil, xerrors.New("Malformed config: ssh config has coder section, but it is malformed and the END header is before the START header")
|
||||
}
|
||||
// We use -1 and +1 here to also include the preceding
|
||||
// and trailing newline, where applicable.
|
||||
start := startIndex
|
||||
@@ -444,10 +468,10 @@ func sshConfigSplitOnCoderSection(data []byte) (before, after []byte) {
|
||||
if end < len(data) {
|
||||
end++
|
||||
}
|
||||
return data[:start], data[end:]
|
||||
return data[:start], data[start:end], data[end:], nil
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return data, nil, nil, nil
|
||||
}
|
||||
|
||||
// writeWithTempFileAndMove writes to a temporary file in the same
|
||||
|
||||
@@ -11,6 +11,125 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_sshConfigSplitOnCoderSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Input string
|
||||
Before string
|
||||
Section string
|
||||
After string
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
Name: "Empty",
|
||||
Input: "",
|
||||
Before: "",
|
||||
Section: "",
|
||||
After: "",
|
||||
Err: false,
|
||||
},
|
||||
{
|
||||
Name: "JustSection",
|
||||
Input: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
|
||||
Before: "",
|
||||
Section: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
|
||||
After: "",
|
||||
Err: false,
|
||||
},
|
||||
{
|
||||
Name: "NoSection",
|
||||
Input: strings.Join([]string{"# Some content"}, "\n"),
|
||||
Before: "# Some content",
|
||||
Section: "",
|
||||
After: "",
|
||||
Err: false,
|
||||
},
|
||||
{
|
||||
Name: "Normal",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshStartToken,
|
||||
sshEndToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Before: "# Content before the section",
|
||||
Section: strings.Join([]string{"", sshStartToken, sshEndToken, ""}, "\n"),
|
||||
After: "# Content after the section",
|
||||
Err: false,
|
||||
},
|
||||
{
|
||||
Name: "OutOfOrder",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshEndToken,
|
||||
sshStartToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Err: true,
|
||||
},
|
||||
{
|
||||
Name: "MissingStart",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshEndToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Err: true,
|
||||
},
|
||||
{
|
||||
Name: "MissingEnd",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshEndToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Err: true,
|
||||
},
|
||||
{
|
||||
Name: "ExtraStart",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshStartToken,
|
||||
sshEndToken,
|
||||
sshStartToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Err: true,
|
||||
},
|
||||
{
|
||||
Name: "ExtraEnd",
|
||||
Input: strings.Join([]string{
|
||||
"# Content before the section",
|
||||
sshStartToken,
|
||||
sshEndToken,
|
||||
sshEndToken,
|
||||
"# Content after the section",
|
||||
}, "\n"),
|
||||
Err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before, section, after, err := sshConfigSplitOnCoderSection([]byte(tc.Input))
|
||||
if tc.Err {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.Before, string(before), "before")
|
||||
require.Equal(t, tc.Section, string(section), "section")
|
||||
require.Equal(t, tc.After, string(after), "after")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This test tries to mimic the behavior of OpenSSH
|
||||
// when executing e.g. a ProxyCommand.
|
||||
func Test_sshConfigExecEscape(t *testing.T) {
|
||||
|
||||
+32
-2
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
@@ -104,7 +104,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
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)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
@@ -529,6 +529,36 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
"--yes",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Start/End out of order",
|
||||
matches: []match{
|
||||
// {match: "Continue?", write: "yes"},
|
||||
},
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"# Content before coder block",
|
||||
headerEnd,
|
||||
headerStart,
|
||||
"# Content after coder block",
|
||||
}, "\n"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple sections",
|
||||
matches: []match{
|
||||
// {match: "Continue?", write: "yes"},
|
||||
},
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
headerEnd,
|
||||
headerStart,
|
||||
headerEnd,
|
||||
}, "\n"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
+119
-27
@@ -17,11 +17,12 @@ import (
|
||||
|
||||
func create() *cobra.Command {
|
||||
var (
|
||||
parameterFile string
|
||||
templateName string
|
||||
startAt string
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
parameterFile string
|
||||
richParameterFile string
|
||||
templateName string
|
||||
startAt string
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -121,11 +122,12 @@ func create() *cobra.Command {
|
||||
schedSpec = ptr.Ref(sched.String())
|
||||
}
|
||||
|
||||
parameters, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
|
||||
Template: template,
|
||||
ExistingParams: []codersdk.Parameter{},
|
||||
ParameterFile: parameterFile,
|
||||
NewWorkspaceName: workspaceName,
|
||||
buildParams, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
|
||||
Template: template,
|
||||
ExistingParams: []codersdk.Parameter{},
|
||||
ParameterFile: parameterFile,
|
||||
RichParameterFile: richParameterFile,
|
||||
NewWorkspaceName: workspaceName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -140,11 +142,12 @@ func create() *cobra.Command {
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
|
||||
ParameterValues: parameters,
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
|
||||
ParameterValues: buildParams.parameters,
|
||||
RichParameterValues: buildParams.richParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,26 +166,55 @@ func create() *cobra.Command {
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
|
||||
cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
|
||||
cliflag.StringVarP(cmd.Flags(), &richParameterFile, "rich-parameter-file", "", "CODER_RICH_PARAMETER_FILE", "", "Specify a file path with values for rich parameters defined in the template.")
|
||||
cliflag.StringVarP(cmd.Flags(), &startAt, "start-at", "", "CODER_WORKSPACE_START_AT", "", "Specify the workspace autostart schedule. Check `coder schedule start --help` for the syntax.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &stopAfter, "stop-after", "", "CODER_WORKSPACE_STOP_AFTER", 8*time.Hour, "Specify a duration after which the workspace should shut down (e.g. 8h).")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type prepWorkspaceBuildArgs struct {
|
||||
Template codersdk.Template
|
||||
ExistingParams []codersdk.Parameter
|
||||
ParameterFile string
|
||||
NewWorkspaceName string
|
||||
Template codersdk.Template
|
||||
ExistingParams []codersdk.Parameter
|
||||
ParameterFile string
|
||||
ExistingRichParams []codersdk.WorkspaceBuildParameter
|
||||
RichParameterFile string
|
||||
NewWorkspaceName string
|
||||
|
||||
UpdateWorkspace bool
|
||||
}
|
||||
|
||||
type buildParameters struct {
|
||||
// Parameters contains legacy parameters stored in /parameters.
|
||||
parameters []codersdk.CreateParameterRequest
|
||||
// Rich parameters stores values for build parameters annotated with description, icon, type, etc.
|
||||
richParameters []codersdk.WorkspaceBuildParameter
|
||||
}
|
||||
|
||||
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
|
||||
// Any missing params will be prompted to the user.
|
||||
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.CreateParameterRequest, error) {
|
||||
// Any missing params will be prompted to the user. It supports legacy and rich parameters.
|
||||
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var useRichParameters bool
|
||||
if len(args.ExistingRichParams) > 0 && len(args.RichParameterFile) > 0 {
|
||||
useRichParameters = true
|
||||
}
|
||||
|
||||
var useLegacyParameters bool
|
||||
if len(args.ExistingParams) > 0 || len(args.ParameterFile) > 0 {
|
||||
useLegacyParameters = true
|
||||
}
|
||||
|
||||
if useRichParameters && useLegacyParameters {
|
||||
return nil, xerrors.Errorf("Rich parameters can't be used together with legacy parameters.")
|
||||
}
|
||||
|
||||
templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Legacy parameters
|
||||
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -200,7 +232,7 @@ func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWo
|
||||
}
|
||||
}
|
||||
disclaimerPrinted := false
|
||||
parameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
legacyParameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
PromptParamLoop:
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
if !parameterSchema.AllowOverrideSource {
|
||||
@@ -227,19 +259,76 @@ PromptParamLoop:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
legacyParameters = append(legacyParameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: parameterValue,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
|
||||
if disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
// Rich parameters
|
||||
templateVersionParameters, err := client.TemplateVersionRichParameters(cmd.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
|
||||
}
|
||||
|
||||
parameterMapFromFile = map[string]string{}
|
||||
useParamFile = false
|
||||
if args.RichParameterFile != "" {
|
||||
useParamFile = true
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(args.RichParameterFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
disclaimerPrinted = false
|
||||
richParameters := make([]codersdk.WorkspaceBuildParameter, 0)
|
||||
PromptRichParamLoop:
|
||||
for _, templateVersionParameter := range templateVersionParameters {
|
||||
if !disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
|
||||
disclaimerPrinted = true
|
||||
}
|
||||
|
||||
// Param file is all or nothing
|
||||
if !useParamFile {
|
||||
for _, e := range args.ExistingRichParams {
|
||||
if e.Name == templateVersionParameter.Name {
|
||||
// If the param already exists, we do not need to prompt it again.
|
||||
// The workspace scope will reuse params for each build.
|
||||
continue PromptRichParamLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if args.UpdateWorkspace && !templateVersionParameter.Mutable {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name)))
|
||||
continue
|
||||
}
|
||||
|
||||
parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(cmd, parameterMapFromFile, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
richParameters = append(richParameters, *parameterValue)
|
||||
}
|
||||
|
||||
if disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
ParameterValues: parameters,
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
ParameterValues: legacyParameters,
|
||||
RichParameterValues: richParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
@@ -279,5 +368,8 @@ PromptParamLoop:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parameters, nil
|
||||
return &buildParameters{
|
||||
parameters: legacyParameters,
|
||||
richParameters: richParameters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+283
-1
@@ -87,7 +87,7 @@ func TestCreate(t *testing.T) {
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "-y")
|
||||
|
||||
member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
cmdCtx, done := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
go func() {
|
||||
@@ -321,6 +321,288 @@ func TestCreate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
firstParameterName = "first_parameter"
|
||||
firstParameterDescription = "This is first parameter"
|
||||
firstParameterValue = "1"
|
||||
|
||||
secondParameterName = "second_parameter"
|
||||
secondParameterDescription = "This is second parameter"
|
||||
secondParameterValue = "2"
|
||||
|
||||
immutableParameterName = "third_parameter"
|
||||
immutableParameterDescription = "This is not mutable parameter"
|
||||
immutableParameterValue = "3"
|
||||
)
|
||||
|
||||
echoResponses := &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Parameters: []*proto.RichParameter{
|
||||
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
|
||||
{Name: secondParameterName, Description: secondParameterDescription, Mutable: true},
|
||||
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
t.Run("InputParameters", 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, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("RichParametersFile", 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, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString(
|
||||
firstParameterName + ": " + firstParameterValue + "\n" +
|
||||
secondParameterName + ": " + secondParameterValue + "\n" +
|
||||
immutableParameterName + ": " + immutableParameterValue)
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
stringParameterName = "string_parameter"
|
||||
stringParameterValue = "abc"
|
||||
|
||||
numberParameterName = "number_parameter"
|
||||
numberParameterValue = "7"
|
||||
|
||||
boolParameterName = "bool_parameter"
|
||||
boolParameterValue = "true"
|
||||
)
|
||||
|
||||
numberRichParameters := []*proto.RichParameter{
|
||||
{Name: numberParameterName, Type: "number", Mutable: true, ValidationMin: 3, ValidationMax: 10},
|
||||
}
|
||||
|
||||
stringRichParameters := []*proto.RichParameter{
|
||||
{Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"},
|
||||
}
|
||||
|
||||
boolRichParameters := []*proto.RichParameter{
|
||||
{Name: boolParameterName, Type: "bool", Mutable: true},
|
||||
}
|
||||
|
||||
prepareEchoResponses := func(richParameters []*proto.RichParameter) *echo.Responses {
|
||||
return &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Parameters: richParameters,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("ValidateString", 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, prepareEchoResponses(stringRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
stringParameterName, "$$",
|
||||
"does not match", "",
|
||||
"Enter a value", "abc",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ValidateNumber", 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, prepareEchoResponses(numberRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
numberParameterName, "12",
|
||||
"is more than the maximum", "",
|
||||
"Enter a value", "8",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ValidateBool", 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, prepareEchoResponses(boolRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
boolParameterName, "cat",
|
||||
"boolean value can be either", "",
|
||||
"Enter a value", "true",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
|
||||
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
|
||||
return []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
|
||||
+4
-4
@@ -39,7 +39,7 @@ func TestDelete(t *testing.T) {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestDelete(t *testing.T) {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestDelete(t *testing.T) {
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
adminUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
orgID := adminUser.OrganizationID
|
||||
client := coderdtest.CreateAnotherUser(t, adminClient, orgID)
|
||||
client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID)
|
||||
user, err := client.User(context.Background(), codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestDelete(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
|
||||
workspace, err = client.Workspace(context.Background(), workspace.ID)
|
||||
|
||||
+167
-28
@@ -32,6 +32,11 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Usage: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
|
||||
Flag: "wildcard-access-url",
|
||||
},
|
||||
RedirectToAccessURL: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Redirect to Access URL",
|
||||
Usage: "Specifies whether to redirect requests that do not match the access URL host.",
|
||||
Flag: "redirect-to-access-url",
|
||||
},
|
||||
// DEPRECATED: Use HTTPAddress or TLS.Address instead.
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
@@ -248,6 +253,23 @@ 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",
|
||||
},
|
||||
SignInText: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OpenID Connect sign in text",
|
||||
Usage: "The text to show on the OpenID Connect sign in button",
|
||||
Flag: "oidc-sign-in-text",
|
||||
Default: "OpenID Connect",
|
||||
},
|
||||
IconURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OpenID connect icon URL",
|
||||
Usage: "URL pointing to the icon to use on the OepnID Connect login button",
|
||||
Flag: "oidc-icon-url",
|
||||
},
|
||||
},
|
||||
|
||||
Telemetry: &codersdk.TelemetryConfig{
|
||||
@@ -283,11 +305,13 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Flag: "tls-address",
|
||||
Default: "127.0.0.1:3443",
|
||||
},
|
||||
// DEPRECATED: Use RedirectToAccessURL instead.
|
||||
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,
|
||||
Hidden: true,
|
||||
},
|
||||
CertFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Certificate Files",
|
||||
@@ -303,7 +327,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",
|
||||
@@ -350,18 +374,26 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
Usage: "Controls if the 'Secure' property is set on browser session cookies.",
|
||||
Flag: "secure-auth-cookie",
|
||||
},
|
||||
StrictTransportSecurity: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "Strict-Transport-Security",
|
||||
Usage: "Controls if the 'Strict-Transport-Security' header is set on all static file responses. " +
|
||||
"This header should only be set if the server is accessed via HTTPS. This value is the MaxAge in seconds of " +
|
||||
"the header.",
|
||||
Default: 0,
|
||||
Flag: "strict-transport-security",
|
||||
},
|
||||
StrictTransportSecurityOptions: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Strict-Transport-Security Options",
|
||||
Usage: "Two optional fields can be set in the Strict-Transport-Security header; 'includeSubDomains' and 'preload'. " +
|
||||
"The 'strict-transport-security' flag must be set to a non-zero value for these options to be used.",
|
||||
Flag: "strict-transport-security-options",
|
||||
},
|
||||
SSHKeygenAlgorithm: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "SSH Keygen Algorithm",
|
||||
Usage: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",
|
||||
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",
|
||||
@@ -429,16 +461,36 @@ 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,
|
||||
},
|
||||
},
|
||||
// DEPRECATED: use Experiments instead.
|
||||
Experimental: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Experimental",
|
||||
Usage: "Enable experimental features. Experimental features are not ready for production.",
|
||||
Flag: "experimental",
|
||||
Name: "Experimental",
|
||||
Usage: "Enable experimental features. Experimental features are not ready for production.",
|
||||
Flag: "experimental",
|
||||
Default: false,
|
||||
Hidden: true,
|
||||
},
|
||||
Experiments: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Experiments",
|
||||
Usage: "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.",
|
||||
Flag: "experiments",
|
||||
Default: []string{},
|
||||
},
|
||||
UpdateCheck: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Update Check",
|
||||
@@ -448,10 +500,76 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
},
|
||||
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Max Token Lifetime",
|
||||
Usage: "The maximum lifetime duration for any user creating a token.",
|
||||
Usage: "The maximum lifetime duration users can specify when creating an API 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,
|
||||
},
|
||||
},
|
||||
Logging: &codersdk.LoggingConfig{
|
||||
Human: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Human Log Location",
|
||||
Usage: "Output human-readable logs to a given file.",
|
||||
Flag: "log-human",
|
||||
Default: "/dev/stderr",
|
||||
},
|
||||
JSON: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "JSON Log Location",
|
||||
Usage: "Output JSON logs to a given file.",
|
||||
Flag: "log-json",
|
||||
Default: "",
|
||||
},
|
||||
Stackdriver: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Stackdriver Log Location",
|
||||
Usage: "Output Stackdriver compatible logs to a given file.",
|
||||
Flag: "log-stackdriver",
|
||||
Default: "",
|
||||
},
|
||||
},
|
||||
Dangerous: &codersdk.DangerousConfig{
|
||||
AllowPathAppSharing: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "DANGEROUS: Allow Path App Sharing",
|
||||
Usage: "Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.",
|
||||
Flag: "dangerous-allow-path-app-sharing",
|
||||
Default: false,
|
||||
},
|
||||
AllowPathAppSiteOwnerAccess: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "DANGEROUS: Allow Site Owners to Access Path Apps",
|
||||
Usage: "Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.",
|
||||
Flag: "dangerous-allow-path-app-site-owner-access",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
DisablePathApps: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable Path Apps",
|
||||
Usage: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.",
|
||||
Flag: "disable-path-apps",
|
||||
Default: false,
|
||||
},
|
||||
SessionDuration: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Session Duration",
|
||||
Usage: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.",
|
||||
Flag: "session-duration",
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
DisableSessionExpiryRefresh: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable Session Expiry Refresh",
|
||||
Usage: "Disable automatic session expiry bumping due to activity. This forces all sessions to become invalid after the session expiry duration has been reached.",
|
||||
Flag: "disable-session-expiry-refresh",
|
||||
Default: false,
|
||||
},
|
||||
DisablePasswordAuth: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable Password Authentication",
|
||||
Usage: "Disable password authentication. This is recommended for security purposes in production deployments that rely on an identity provider. Any user with the owner role will be able to sign in with their password regardless of this setting to avoid potential lock out. If you are locked out of your account, you can use the `coder server create-admin` command to create a new admin user directly in the database.",
|
||||
Flag: "disable-password-auth",
|
||||
Default: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,31 +608,40 @@ 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!
|
||||
rawSlice := reflect.ValueOf(vip.GetStringSlice(prefix)).Interface()
|
||||
slice, ok := rawSlice.([]string)
|
||||
stringSlice, ok := rawSlice.([]string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("string slice is of type %T", rawSlice))
|
||||
}
|
||||
value := make([]string, 0, len(slice))
|
||||
for _, entry := range slice {
|
||||
value := make([]string, 0, len(stringSlice))
|
||||
for _, entry := range stringSlice {
|
||||
value = append(value, strings.Split(entry, ",")...)
|
||||
}
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(value))
|
||||
@@ -572,6 +699,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)
|
||||
@@ -618,7 +748,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
|
||||
@@ -655,7 +785,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
|
||||
@@ -664,15 +794,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:
|
||||
|
||||
@@ -232,6 +232,23 @@ func TestConfig(t *testing.T) {
|
||||
require.Equal(t, config.Prometheus.Enable.Value, true)
|
||||
require.Equal(t, config.Prometheus.Address.Value, config.Prometheus.Address.Default)
|
||||
},
|
||||
}, {
|
||||
Name: "Experiments - no features",
|
||||
Env: map[string]string{
|
||||
"CODER_EXPERIMENTS": "",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Empty(t, config.Experiments.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "Experiments - multiple features",
|
||||
Env: map[string]string{
|
||||
"CODER_EXPERIMENTS": "foo,bar",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
expected := []string{"foo", "bar"}
|
||||
require.ElementsMatch(t, expected, config.Experiments.Value)
|
||||
},
|
||||
}} {
|
||||
tc := tc
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ func dotfiles() *cobra.Command {
|
||||
}
|
||||
|
||||
// ensure command dir exists
|
||||
err = os.MkdirAll(gitCmdDir, 0750)
|
||||
err = os.MkdirAll(gitCmdDir, 0o750)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ensuring dir at %s: %w", gitCmdDir, err)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestDotfiles(t *testing.T) {
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", ".bashrc")
|
||||
@@ -56,7 +56,7 @@ func TestDotfiles(t *testing.T) {
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0750)
|
||||
err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", "install.sh")
|
||||
@@ -82,12 +82,12 @@ func TestDotfiles(t *testing.T) {
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
// add a conflicting file at destination
|
||||
// nolint:gosec
|
||||
err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0750)
|
||||
err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", ".bashrc")
|
||||
@@ -119,7 +119,7 @@ func testGitRepo(t *testing.T, root config.Root) string {
|
||||
r, err := cryptorand.String(8)
|
||||
require.NoError(t, err)
|
||||
dir := filepath.Join(string(root), fmt.Sprintf("test-repo-%s", r))
|
||||
err = os.MkdirAll(dir, 0750)
|
||||
err = os.MkdirAll(dir, 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "init")
|
||||
|
||||
+5
-5
@@ -39,7 +39,7 @@ func gitAskpass() *cobra.Command {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
|
||||
token, err := client.WorkspaceAgentGitAuth(ctx, host, false)
|
||||
token, err := client.GitAuth(ctx, host, false)
|
||||
if err != nil {
|
||||
var apiError *codersdk.Error
|
||||
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
|
||||
@@ -58,7 +58,7 @@ func gitAskpass() *cobra.Command {
|
||||
}
|
||||
|
||||
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
|
||||
token, err = client.WorkspaceAgentGitAuth(ctx, host, true)
|
||||
token, err = client.GitAuth(ctx, host, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -69,12 +69,12 @@ func gitAskpass() *cobra.Command {
|
||||
|
||||
if token.Password != "" {
|
||||
if user == "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Password)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), token.Password)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
@@ -22,7 +23,7 @@ func TestGitAskpass(t *testing.T) {
|
||||
t.Setenv("GIT_PREFIX", "/")
|
||||
t.Run("UsernameAndPassword", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{
|
||||
Username: "something",
|
||||
Password: "bananas",
|
||||
})
|
||||
@@ -61,8 +62,8 @@ func TestGitAskpass(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Poll", func(t *testing.T) {
|
||||
resp := atomic.Pointer[codersdk.WorkspaceAgentGitAuthResponse]{}
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
resp := atomic.Pointer[agentsdk.GitAuthResponse]{}
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
URL: "https://something.org",
|
||||
})
|
||||
poll := make(chan struct{}, 10)
|
||||
@@ -88,7 +89,7 @@ func TestGitAskpass(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
<-poll
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ func gitssh() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
key, err := client.AgentGitSSHKey(ctx)
|
||||
key, err := client.GitSSHKey(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get agent git ssh token: %w", err)
|
||||
}
|
||||
|
||||
+29
-34
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -14,14 +13,21 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// workspaceListRow is the type provided to the OutputFormatter. This is a bit
|
||||
// dodgy but it's the only way to do complex display code for one format vs. the
|
||||
// other.
|
||||
type workspaceListRow struct {
|
||||
Workspace string `table:"workspace"`
|
||||
Template string `table:"template"`
|
||||
Status string `table:"status"`
|
||||
LastBuilt string `table:"last built"`
|
||||
Outdated bool `table:"outdated"`
|
||||
StartsAt string `table:"starts at"`
|
||||
StopsAfter string `table:"stops after"`
|
||||
// For JSON format:
|
||||
codersdk.Workspace `table:"-"`
|
||||
|
||||
// For table format:
|
||||
WorkspaceName string `json:"-" table:"workspace,default_sort"`
|
||||
Template string `json:"-" table:"template"`
|
||||
Status string `json:"-" table:"status"`
|
||||
LastBuilt string `json:"-" table:"last built"`
|
||||
Outdated bool `json:"-" table:"outdated"`
|
||||
StartsAt string `json:"-" table:"starts at"`
|
||||
StopsAfter string `json:"-" table:"stops after"`
|
||||
}
|
||||
|
||||
func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow {
|
||||
@@ -47,24 +53,27 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders
|
||||
|
||||
user := usersByID[workspace.OwnerID]
|
||||
return workspaceListRow{
|
||||
Workspace: user.Username + "/" + workspace.Name,
|
||||
Template: workspace.TemplateName,
|
||||
Status: status,
|
||||
LastBuilt: durationDisplay(lastBuilt),
|
||||
Outdated: workspace.Outdated,
|
||||
StartsAt: autostartDisplay,
|
||||
StopsAfter: autostopDisplay,
|
||||
Workspace: workspace,
|
||||
WorkspaceName: user.Username + "/" + workspace.Name,
|
||||
Template: workspace.TemplateName,
|
||||
Status: status,
|
||||
LastBuilt: durationDisplay(lastBuilt),
|
||||
Outdated: workspace.Outdated,
|
||||
StartsAt: autostartDisplay,
|
||||
StopsAfter: autostopDisplay,
|
||||
}
|
||||
}
|
||||
|
||||
func list() *cobra.Command {
|
||||
var (
|
||||
all bool
|
||||
columns []string
|
||||
defaultQuery = "owner:me"
|
||||
searchQuery string
|
||||
me bool
|
||||
displayWorkspaces []workspaceListRow
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]workspaceListRow{}, nil),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -85,14 +94,6 @@ func list() *cobra.Command {
|
||||
filter.FilterQuery = ""
|
||||
}
|
||||
|
||||
if me {
|
||||
myUser, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter.Owner = myUser.Username
|
||||
}
|
||||
|
||||
res, err := client.Workspaces(cmd.Context(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -121,7 +122,7 @@ func list() *cobra.Command {
|
||||
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
||||
}
|
||||
|
||||
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
|
||||
out, err := formatter.Format(cmd.Context(), displayWorkspaces)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,16 +132,10 @@ func list() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
availColumns, err := cliui.TableHeaders(displayWorkspaces)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
columnString := strings.Join(availColumns[:], ", ")
|
||||
|
||||
cmd.Flags().BoolVarP(&all, "all", "a", false,
|
||||
"Specifies whether all workspaces will be listed or not.")
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
|
||||
fmt.Sprintf("Specify a column to filter in the table. Available columns are: %v", columnString))
|
||||
cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.")
|
||||
|
||||
formatter.AttachFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/testutil"
|
||||
)
|
||||
@@ -42,4 +46,30 @@ func TestList(t *testing.T) {
|
||||
cancelFunc()
|
||||
<-done
|
||||
})
|
||||
|
||||
t.Run("JSON", 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)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "list", "--output=json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
out := bytes.NewBuffer(nil)
|
||||
cmd.SetOut(out)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var templates []codersdk.Workspace
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &templates))
|
||||
require.Len(t, templates, 1)
|
||||
})
|
||||
}
|
||||
|
||||
+20
-7
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -49,9 +50,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"
|
||||
@@ -143,16 +153,19 @@ func login() *cobra.Command {
|
||||
|
||||
for !matching {
|
||||
password, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: func(s string) error {
|
||||
return userpassword.Validate(s)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify password prompt: %w", err)
|
||||
}
|
||||
confirm, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
|
||||
+42
-10
@@ -54,8 +54,43 @@ func TestLogin(t *testing.T) {
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "password", // Confirm.
|
||||
"password", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // 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("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", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // Confirm.
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
@@ -71,11 +106,8 @@ func TestLogin(t *testing.T) {
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "SomeSecurePassword!", "--first-user-trial")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
@@ -111,8 +143,8 @@ func TestLogin(t *testing.T) {
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "mypass",
|
||||
"password", "wrongpass", // Confirm.
|
||||
"password", "MyFirstSecurePassword!",
|
||||
"password", "MyNonMatchingSecurePassword!", // Confirm.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -125,9 +157,9 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("Enter a " + cliui.Styles.Field.Render("password"))
|
||||
|
||||
pty.WriteLine("pass")
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine("pass")
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.ExpectMatch("trial")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ func TestLogout(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// Changing the permissions to throw error during deletion.
|
||||
err = os.Chmod(string(config), 0500)
|
||||
err = os.Chmod(string(config), 0o500)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer func() {
|
||||
|
||||
+25
-3
@@ -3,11 +3,10 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
@@ -19,7 +18,6 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error)
|
||||
parameterMap := make(map[string]string)
|
||||
|
||||
parameterFileContents, err := os.ReadFile(parameterFile)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -58,3 +56,27 @@ func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string
|
||||
}
|
||||
return parameterValue, nil
|
||||
}
|
||||
|
||||
func getWorkspaceBuildParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, templateVersionParameter codersdk.TemplateVersionParameter) (*codersdk.WorkspaceBuildParameter, error) {
|
||||
var parameterValue string
|
||||
var err error
|
||||
if parameterMap != nil {
|
||||
var ok bool
|
||||
parameterValue, ok = parameterMap[templateVersionParameter.Name]
|
||||
if !ok {
|
||||
parameterValue, err = cliui.RichParameter(cmd, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parameterValue, err = cliui.RichParameter(cmd, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &codersdk.WorkspaceBuildParameter{
|
||||
Name: templateVersionParameter.Name,
|
||||
Value: parameterValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
)
|
||||
|
||||
func parameterList() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
@@ -71,16 +73,16 @@ func parameterList() *cobra.Command {
|
||||
return xerrors.Errorf("fetch params: %w", err)
|
||||
}
|
||||
|
||||
out, err := cliui.DisplayTable(params, "name", columns)
|
||||
out, err := formatter.Format(cmd.Context(), params)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("render table: %w", err)
|
||||
return xerrors.Errorf("render output: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
|
||||
"Specify a column to filter in the table.")
|
||||
|
||||
formatter.AttachFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func ping() *cobra.Command {
|
||||
var (
|
||||
pingNum int
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
verbose bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "ping <workspace>",
|
||||
Short: "Ping a workspace",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspaceName := args[0]
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, workspaceName, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var logger slog.Logger
|
||||
if verbose {
|
||||
logger = slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{Logger: logger})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
derpMap := conn.DERPMap()
|
||||
_ = derpMap
|
||||
|
||||
n := 0
|
||||
didP2p := false
|
||||
start := time.Now()
|
||||
for {
|
||||
if n > 0 {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
n++
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
dur, p2p, pong, err := conn.Ping(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.DeadlineExceeded) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "ping to %q timed out \n", workspaceName)
|
||||
if n == pingNum {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err.Error() == "no matching peer" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "ping to %q failed %s\n", workspaceName, err.Error())
|
||||
if n == pingNum {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
dur = dur.Round(time.Millisecond)
|
||||
var via string
|
||||
if p2p {
|
||||
if !didP2p {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "p2p connection established in",
|
||||
cliui.Styles.DateTimeStamp.Render(time.Since(start).Round(time.Millisecond).String()),
|
||||
)
|
||||
}
|
||||
didP2p = true
|
||||
|
||||
via = fmt.Sprintf("%s via %s",
|
||||
cliui.Styles.Fuchsia.Render("p2p"),
|
||||
cliui.Styles.Code.Render(pong.Endpoint),
|
||||
)
|
||||
} else {
|
||||
derpName := "unknown"
|
||||
derpRegion, ok := derpMap.Regions[pong.DERPRegionID]
|
||||
if ok {
|
||||
derpName = derpRegion.RegionName
|
||||
}
|
||||
via = fmt.Sprintf("%s via %s",
|
||||
cliui.Styles.Fuchsia.Render("proxied"),
|
||||
cliui.Styles.Code.Render(fmt.Sprintf("DERP(%s)", derpName)),
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "pong from %s %s in %s\n",
|
||||
cliui.Styles.Keyword.Render(workspaceName),
|
||||
via,
|
||||
cliui.Styles.DateTimeStamp.Render(dur.String()),
|
||||
)
|
||||
|
||||
if n == pingNum {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enables verbose logging.")
|
||||
cmd.Flags().DurationVarP(&pingWait, "wait", "", time.Second, "Specifies how long to wait between pings.")
|
||||
cmd.Flags().DurationVarP(&pingTimeout, "timeout", "t", 5*time.Second, "Specifies how long to wait for a ping to complete.")
|
||||
cmd.Flags().IntVarP(&pingNum, "num", "n", 10, "Specifies the number of pings to perform.")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
cmd, root := clitest.New(t, "ping", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
pty.ExpectMatch("pong from " + workspace.Name)
|
||||
cancel()
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -156,7 +156,7 @@ func portForward() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.AgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.WorkspaceAgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
|
||||
|
||||
var (
|
||||
|
||||
+1
-3
@@ -11,9 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func publickey() *cobra.Command {
|
||||
var (
|
||||
reset bool
|
||||
)
|
||||
var reset bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "publickey",
|
||||
|
||||
+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)
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func resetPassword() *cobra.Command {
|
||||
var (
|
||||
postgresURL string
|
||||
)
|
||||
var postgresURL string
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "reset-password <username>",
|
||||
@@ -50,9 +48,11 @@ func resetPassword() *cobra.Command {
|
||||
}
|
||||
|
||||
password, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter new " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
Text: "Enter new " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: func(s string) error {
|
||||
return userpassword.Validate(s)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("password prompt: %w", err)
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestResetPassword(t *testing.T) {
|
||||
|
||||
const email = "some@one.com"
|
||||
const username = "example"
|
||||
const oldPassword = "password"
|
||||
const newPassword = "password2"
|
||||
const oldPassword = "MyOldPassword!"
|
||||
const newPassword = "MyNewPassword!"
|
||||
|
||||
// start postgres and coder server processes
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func restart() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "restart <workspace>",
|
||||
Short: "Restart a workspace",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm restart workspace?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cliui.WorkspaceBuild(ctx, out, client, build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cliui.WorkspaceBuild(ctx, out, client, build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nThe %s workspace has been restarted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", 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)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
||||
cmd, root := clitest.New(t, "restart", workspace.Name, "--yes")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
pty.ExpectMatch("Stopping workspace")
|
||||
pty.ExpectMatch("Starting workspace")
|
||||
pty.ExpectMatch("workspace has been restarted")
|
||||
|
||||
err := <-done
|
||||
require.NoError(t, err, "execute failed")
|
||||
})
|
||||
}
|
||||
+127
-8
@@ -5,11 +5,15 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +32,7 @@ import (
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -59,9 +64,7 @@ const (
|
||||
envURL = "CODER_URL"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnauthenticated = xerrors.New(notLoggedInMessage)
|
||||
)
|
||||
var errUnauthenticated = xerrors.New(notLoggedInMessage)
|
||||
|
||||
func init() {
|
||||
// Set cobra template functions in init to avoid conflicts in tests.
|
||||
@@ -80,10 +83,12 @@ func Core() []*cobra.Command {
|
||||
login(),
|
||||
logout(),
|
||||
parameters(),
|
||||
ping(),
|
||||
portForward(),
|
||||
publickey(),
|
||||
rename(),
|
||||
resetPassword(),
|
||||
restart(),
|
||||
scaletest(),
|
||||
schedules(),
|
||||
show(),
|
||||
@@ -97,6 +102,7 @@ func Core() []*cobra.Command {
|
||||
update(),
|
||||
users(),
|
||||
versionCmd(),
|
||||
vscodeSSH(),
|
||||
workspaceAgent(),
|
||||
}
|
||||
}
|
||||
@@ -209,12 +215,22 @@ func versionCmd() *cobra.Command {
|
||||
Short: "Show coder version",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var str strings.Builder
|
||||
_, _ = str.WriteString(fmt.Sprintf("Coder %s", buildinfo.Version()))
|
||||
_, _ = str.WriteString("Coder ")
|
||||
if buildinfo.IsAGPL() {
|
||||
_, _ = str.WriteString("(AGPL) ")
|
||||
}
|
||||
_, _ = str.WriteString(buildinfo.Version())
|
||||
buildTime, valid := buildinfo.Time()
|
||||
if valid {
|
||||
_, _ = str.WriteString(" " + buildTime.Format(time.UnixDate))
|
||||
}
|
||||
_, _ = str.WriteString("\r\n" + buildinfo.ExternalURL() + "\r\n")
|
||||
_, _ = str.WriteString("\r\n" + buildinfo.ExternalURL() + "\r\n\r\n")
|
||||
|
||||
if buildinfo.IsSlim() {
|
||||
_, _ = str.WriteString(fmt.Sprintf("Slim build of Coder, does not support the %s subcommand.\n", cliui.Styles.Code.Render("server")))
|
||||
} else {
|
||||
_, _ = str.WriteString(fmt.Sprintf("Full build of Coder, supports the %s subcommand.\n", cliui.Styles.Code.Render("server")))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), str.String())
|
||||
return nil
|
||||
@@ -318,7 +334,7 @@ func createUnauthenticatedClient(cmd *cobra.Command, serverURL *url.URL) (*coder
|
||||
|
||||
// createAgentClient returns a new client from the command context.
|
||||
// It works just like CreateClient, but uses the agent token and URL instead.
|
||||
func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
func createAgentClient(cmd *cobra.Command) (*agentsdk.Client, error) {
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -331,7 +347,7 @@ func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := codersdk.New(serverURL)
|
||||
client := agentsdk.New(serverURL)
|
||||
client.SetSessionToken(token)
|
||||
return client, nil
|
||||
}
|
||||
@@ -575,7 +591,7 @@ func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
|
||||
clientVersion := buildinfo.Version()
|
||||
info, err := client.BuildInfo(ctx)
|
||||
// Avoid printing errors that are connection-related.
|
||||
if codersdk.IsConnectionErr(err) {
|
||||
if isConnectionError(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -630,3 +646,106 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IiConnectionErr is a convenience function for checking if the source of an
|
||||
// error is due to a 'connection refused', 'no such host', etc.
|
||||
func isConnectionError(err error) bool {
|
||||
var (
|
||||
// E.g. no such host
|
||||
dnsErr *net.DNSError
|
||||
// Eg. connection refused
|
||||
opErr *net.OpError
|
||||
)
|
||||
|
||||
return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr)
|
||||
}
|
||||
|
||||
+154
-15
@@ -2,11 +2,15 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,6 +22,8 @@ import (
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -26,58 +32,128 @@ import (
|
||||
// make update-golden-files
|
||||
var updateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z`)
|
||||
|
||||
//nolint:tparallel,paralleltest // These test sets env vars.
|
||||
func TestCommandHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
commonEnv := map[string]string{
|
||||
"HOME": "~",
|
||||
"CODER_CONFIG_DIR": "~/.config/coderv2",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
rootClient, replacements := prepareTestData(t)
|
||||
|
||||
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",
|
||||
"CODER_CACHE_DIRECTORY": "~/.cache/coder",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "coder agent --help",
|
||||
cmd: []string{"agent", "--help"},
|
||||
env: map[string]string{
|
||||
"CODER_AGENT_LOG_DIR": "/tmp",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "coder list --output json",
|
||||
cmd: []string{"list", "--output", "json"},
|
||||
},
|
||||
{
|
||||
name: "coder users list --output json",
|
||||
cmd: []string{"users", "list", "--output", "json"},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
||||
tmpwd := "/"
|
||||
if runtime.GOOS == "windows" {
|
||||
tmpwd = "C:\\"
|
||||
}
|
||||
err := os.Chdir(tmpwd)
|
||||
var buf bytes.Buffer
|
||||
root, _ := clitest.New(t, tt.cmd...)
|
||||
root.SetOut(&buf)
|
||||
err := root.ExecuteContext(ctx)
|
||||
cmd, cfg := clitest.New(t, tt.cmd...)
|
||||
clitest.SetupConfig(t, rootClient, cfg)
|
||||
cmd.SetOut(&buf)
|
||||
assert.NoError(t, err)
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
err2 := os.Chdir(wd)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err2)
|
||||
|
||||
got := buf.Bytes()
|
||||
// Remove CRLF newlines (Windows).
|
||||
got = bytes.ReplaceAll(got, []byte{'\r', '\n'}, []byte{'\n'})
|
||||
|
||||
replace := map[string][]byte{
|
||||
// Remove CRLF newlines (Windows).
|
||||
string([]byte{'\r', '\n'}): []byte("\n"),
|
||||
// The `coder templates create --help` command prints the path
|
||||
// to the working directory (--directory flag default value).
|
||||
fmt.Sprintf("%q", tmpwd): []byte("\"[current directory]\""),
|
||||
}
|
||||
for k, v := range replacements {
|
||||
replace[k] = []byte(v)
|
||||
}
|
||||
for k, v := range replace {
|
||||
got = bytes.ReplaceAll(got, []byte(k), v)
|
||||
}
|
||||
|
||||
// Replace any timestamps with a placeholder.
|
||||
got = timestampRegex.ReplaceAll(got, []byte("[timestamp]"))
|
||||
|
||||
gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
||||
if *updateGoldenFiles {
|
||||
@@ -95,6 +171,69 @@ 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 prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
rootClient := coderdtest.New(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, rootClient)
|
||||
secondUser, err := rootClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "testuser2@coder.com",
|
||||
Username: "testuser2",
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil)
|
||||
version = coderdtest.AwaitTemplateVersionJob(t, rootClient, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
|
||||
req.Name = "test-template"
|
||||
})
|
||||
workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.Name = "test-workspace"
|
||||
})
|
||||
workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, rootClient, workspace.LatestBuild.ID)
|
||||
|
||||
replacements := map[string]string{
|
||||
firstUser.UserID.String(): "[first user ID]",
|
||||
secondUser.ID.String(): "[second user ID]",
|
||||
firstUser.OrganizationID.String(): "[first org ID]",
|
||||
version.ID.String(): "[version ID]",
|
||||
version.Name: "[version name]",
|
||||
version.Job.ID.String(): "[version job ID]",
|
||||
version.Job.FileID.String(): "[version file ID]",
|
||||
version.Job.WorkerID.String(): "[version worker ID]",
|
||||
template.ID.String(): "[template ID]",
|
||||
workspace.ID.String(): "[workspace ID]",
|
||||
workspaceBuild.ID.String(): "[workspace build ID]",
|
||||
workspaceBuild.Job.ID.String(): "[workspace build job ID]",
|
||||
workspaceBuild.Job.FileID.String(): "[workspace build file ID]",
|
||||
workspaceBuild.Job.WorkerID.String(): "[workspace build worker ID]",
|
||||
}
|
||||
|
||||
return rootClient, replacements
|
||||
}
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("FormatCobraError", func(t *testing.T) {
|
||||
|
||||
+31
-11
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -185,7 +187,9 @@ func (o *scaleTestOutput) write(res harness.Results, stdout io.Writer) error {
|
||||
// Sync the file to disk if it's a file.
|
||||
if s, ok := w.(interface{ Sync() error }); ok {
|
||||
err := s.Sync()
|
||||
if err != nil {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -265,7 +269,7 @@ func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User,
|
||||
// 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.
|
||||
// it against a non-owner account on a production deployment.
|
||||
ok := false
|
||||
for _, role := range me.Roles {
|
||||
if role.Name == "owner" {
|
||||
@@ -305,9 +309,7 @@ func (r *userCleanupRunner) Run(ctx context.Context, _ string, _ io.Writer) erro
|
||||
}
|
||||
|
||||
func scaletestCleanup() *cobra.Command {
|
||||
var (
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
)
|
||||
cleanupStrategy := &scaletestStrategyFlags{cleanup: true}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
@@ -325,7 +327,14 @@ func scaletestCleanup() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &headerTransport{
|
||||
transport: http.DefaultTransport,
|
||||
headers: map[string]string{
|
||||
codersdk.BypassRatelimitHeader: "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PrintErrln("Fetching scaletest workspaces...")
|
||||
var (
|
||||
@@ -488,7 +497,9 @@ func scaletestCreateWorkspaces() *cobra.Command {
|
||||
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.",
|
||||
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)
|
||||
@@ -501,7 +512,14 @@ func scaletestCreateWorkspaces() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &headerTransport{
|
||||
transport: http.DefaultTransport,
|
||||
headers: map[string]string{
|
||||
codersdk.BypassRatelimitHeader: "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
return xerrors.Errorf("--count is required and must be greater than 0")
|
||||
@@ -663,7 +681,7 @@ func scaletestCreateWorkspaces() *cobra.Command {
|
||||
if runCommand != "" {
|
||||
config.ReconnectingPTY = &reconnectingpty.Config{
|
||||
// AgentID is set by the test automatically.
|
||||
Init: codersdk.ReconnectingPTYInit{
|
||||
Init: codersdk.WorkspaceAgentReconnectingPTYInit{
|
||||
ID: uuid.Nil,
|
||||
Height: 24,
|
||||
Width: 80,
|
||||
@@ -790,8 +808,10 @@ type runnableTraceWrapper struct {
|
||||
span trace.Span
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &runnableTraceWrapper{}
|
||||
var _ harness.Cleanable = &runnableTraceWrapper{}
|
||||
var (
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ 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())
|
||||
|
||||
+334
-157
@@ -1,7 +1,12 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
@@ -9,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
@@ -46,6 +52,8 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"cdr.dev/slog/sloggers/slogjson"
|
||||
"cdr.dev/slog/sloggers/slogstackdriver"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
@@ -53,7 +61,7 @@ import (
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/autobuild/executor"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/databasefake"
|
||||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/database/migrations"
|
||||
"github.com/coder/coder/coderd/devtunnel"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
@@ -81,6 +89,13 @@ 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)
|
||||
@@ -101,22 +116,25 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
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")
|
||||
return xerrors.Errorf("TLS is disabled. Enable with --tls-enable or specify a HTTP address")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
logger, logCloser, err := buildLogger(cmd, cfg)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("make logger: %w", err)
|
||||
}
|
||||
if cfg.Trace.CaptureLogs.Value {
|
||||
logger = logger.AppendSinks(tracing.SlogSink{})
|
||||
}
|
||||
|
||||
// Main command context for managing cancellation
|
||||
// of running services.
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
defer logCloser()
|
||||
|
||||
// Register signals early on so that graceful shutdown can't
|
||||
// be interrupted by additional signals. Note that we avoid
|
||||
@@ -216,6 +234,20 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}
|
||||
defer httpListener.Close()
|
||||
|
||||
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())
|
||||
@@ -227,7 +259,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
cmd.Println("Started HTTP listener at " + httpURL.String())
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -240,6 +271,13 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
return xerrors.New("tls address must be set if tls is enabled")
|
||||
}
|
||||
|
||||
// DEPRECATED: This redirect used to default to true.
|
||||
// It made more sense to have the redirect be opt-in.
|
||||
if os.Getenv("CODER_TLS_REDIRECT_HTTP") == "true" || cmd.Flags().Changed("tls-redirect-http-to-https") {
|
||||
cmd.PrintErr(cliui.Styles.Warn.Render("WARN:") + " --tls-redirect-http-to-https is deprecated, please use --redirect-to-access-url instead\n")
|
||||
cfg.RedirectToAccessURL.Value = cfg.TLS.RedirectHTTP.Value
|
||||
}
|
||||
|
||||
tlsConfig, err = configureTLS(
|
||||
cfg.TLS.MinVersion.Value,
|
||||
cfg.TLS.ClientAuth.Value,
|
||||
@@ -259,6 +297,22 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
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())
|
||||
@@ -270,7 +324,6 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
Scheme: "https",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
cmd.Println("Started TLS/HTTPS listener at " + httpsURL.String())
|
||||
}
|
||||
|
||||
// Sanity check that at least one listener was started.
|
||||
@@ -348,15 +401,6 @@ 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())
|
||||
|
||||
@@ -371,27 +415,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,
|
||||
@@ -438,7 +461,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
AppHostname: appHostname,
|
||||
AppHostnameRegex: appHostnameRegex,
|
||||
Logger: logger.Named("coderd"),
|
||||
Database: databasefake.New(),
|
||||
Database: dbfake.New(),
|
||||
DERPMap: derpMap,
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
CacheDir: cacheDir,
|
||||
@@ -449,18 +472,26 @@ 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
|
||||
}
|
||||
|
||||
if cfg.StrictTransportSecurity.Value > 0 {
|
||||
options.StrictTransportSecurityCfg, err = httpmw.HSTSConfigOptions(cfg.StrictTransportSecurity.Value, cfg.StrictTransportSecurityOptions.Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", cfg.StrictTransportSecurityOptions.Value, err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UpdateCheck.Value {
|
||||
options.UpdateCheckOptions = &updatecheck.Options{
|
||||
// Avoid spamming GitHub API checking for updates.
|
||||
@@ -523,69 +554,30 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: cfg.OIDC.Scopes.Value,
|
||||
},
|
||||
Provider: oidcProvider,
|
||||
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,
|
||||
SignInText: cfg.OIDC.SignInText.Value,
|
||||
IconURL: cfg.OIDC.IconURL.Value,
|
||||
IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value,
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InMemoryDatabase.Value {
|
||||
options.Database = databasefake.New()
|
||||
options.Database = dbfake.New()
|
||||
options.Pubsub = database.NewPubsubInMemory()
|
||||
} else {
|
||||
logger.Debug(ctx, "connecting to postgresql")
|
||||
sqlDB, err := sql.Open(sqlDriver, cfg.PostgresURL.Value)
|
||||
sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, cfg.PostgresURL.Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
// Ensure the PostgreSQL version is >=13.0.0!
|
||||
version, err := sqlDB.QueryContext(ctx, "SHOW server_version;")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get postgres version: %w", err)
|
||||
}
|
||||
if !version.Next() {
|
||||
return xerrors.Errorf("no rows returned for version select")
|
||||
}
|
||||
var versionStr string
|
||||
err = version.Scan(&versionStr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("scan version: %w", err)
|
||||
}
|
||||
_ = version.Close()
|
||||
versionStr = strings.Split(versionStr, " ")[0]
|
||||
if semver.Compare("v"+versionStr, "v13") < 0 {
|
||||
return xerrors.New("PostgreSQL version must be v13.0.0 or higher!")
|
||||
}
|
||||
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)
|
||||
}
|
||||
// The default is 0 but the request will fail with a 500 if the DB
|
||||
// cannot accept new connections, so we try to limit that here.
|
||||
// Requests will wait for a new connection instead of a hard error
|
||||
// if a limit is set.
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
// Allow a max of 3 idle connections at a time. Lower values end up
|
||||
// creating a lot of connection churn. Since each connection uses about
|
||||
// 10MB of memory, we're allocating 30MB to Postgres connections per
|
||||
// replica, but is better than causing Postgres to spawn a thread 15-20
|
||||
// times/sec. PGBouncer's transaction pooling is not the greatest so
|
||||
// it's not optimal for us to deploy.
|
||||
//
|
||||
// This was set to 10 before we started doing HA deployments, but 3 was
|
||||
// later determined to be a better middle ground as to not use up all
|
||||
// of PGs default connection limit while simultaneously avoiding a lot
|
||||
// of connection churn.
|
||||
sqlDB.SetMaxIdleConns(3)
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
options.Database = database.New(sqlDB)
|
||||
options.Pubsub, err = database.NewPubsub(ctx, sqlDB, cfg.PostgresURL.Value)
|
||||
@@ -678,6 +670,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.
|
||||
@@ -687,16 +683,17 @@ 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,
|
||||
@@ -733,8 +730,8 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
// 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)
|
||||
if cfg.RedirectToAccessURL.Value {
|
||||
handler = redirectToAccessURL(handler, accessURLParsed, tunnel != nil, appHostnameRegex)
|
||||
}
|
||||
|
||||
// ReadHeaderTimeout is purposefully not enabled. It caused some
|
||||
@@ -797,9 +794,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()))
|
||||
}
|
||||
|
||||
@@ -916,7 +914,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 {
|
||||
@@ -925,37 +924,50 @@ 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.")
|
||||
|
||||
createAdminUserCommand := newCreateAdminUserCommand()
|
||||
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd, createAdminUserCommand)
|
||||
|
||||
deployment.AttachFlags(root.Flags(), vip, false)
|
||||
|
||||
@@ -964,9 +976,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
|
||||
// parseURL parses a string into a URL.
|
||||
func parseURL(u string) (*url.URL, error) {
|
||||
var (
|
||||
hasScheme = strings.HasPrefix(u, "http:") || strings.HasPrefix(u, "https:")
|
||||
)
|
||||
hasScheme := strings.HasPrefix(u, "http:") || strings.HasPrefix(u, "https:")
|
||||
|
||||
if !hasScheme {
|
||||
return nil, xerrors.Errorf("URL %q must have a scheme of either http or https", u)
|
||||
@@ -1100,6 +1110,11 @@ func newProvisionerDaemon(
|
||||
|
||||
// nolint: revive
|
||||
func printLogo(cmd *cobra.Command) {
|
||||
// Only print the logo in TTYs.
|
||||
if !isTTYOut(cmd) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s - Software development on your infrastucture\n", cliui.Styles.Bold.Render("Coder "+buildinfo.Version()))
|
||||
}
|
||||
|
||||
@@ -1107,12 +1122,6 @@ func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, er
|
||||
if len(tlsCertFiles) != len(tlsKeyFiles) {
|
||||
return nil, xerrors.New("--tls-cert-file and --tls-key-file must be used the same amount of times")
|
||||
}
|
||||
if len(tlsCertFiles) == 0 {
|
||||
return nil, xerrors.New("--tls-cert-file is required when tls is enabled")
|
||||
}
|
||||
if len(tlsKeyFiles) == 0 {
|
||||
return nil, xerrors.New("--tls-key-file is required when tls is enabled")
|
||||
}
|
||||
|
||||
certs := make([]tls.Certificate, len(tlsCertFiles))
|
||||
for i := range tlsCertFiles {
|
||||
@@ -1128,6 +1137,36 @@ func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, er
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// generateSelfSignedCertificate creates an unsafe self-signed certificate
|
||||
// at random that allows users to proceed with setup in the event they
|
||||
// haven't configured any TLS certificates.
|
||||
func generateSelfSignedCertificate() (*tls.Certificate, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour * 24 * 180),
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cert tls.Certificate
|
||||
cert.Certificate = append(cert.Certificate, derBytes)
|
||||
cert.PrivateKey = privateKey
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (*tls.Config, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
@@ -1164,6 +1203,14 @@ func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load certificates: %w", err)
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
selfSignedCertificate, err := generateSelfSignedCertificate()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("generate self signed certificate: %w", err)
|
||||
}
|
||||
certs = append(certs, *selfSignedCertificate)
|
||||
}
|
||||
|
||||
tlsConfig.Certificates = certs
|
||||
tlsConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
// If there's only one certificate, return it.
|
||||
@@ -1319,29 +1366,6 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler, addr, name string) (closeFunc func()) {
|
||||
logger.Debug(ctx, "http server listening", slog.F("addr", addr), slog.F("name", name))
|
||||
|
||||
// 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
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
}
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && !xerrors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "http server listen", slog.F("name", name), slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
_ = srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedPostgresURL returns the URL for the embedded PostgreSQL deployment.
|
||||
func embeddedPostgresURL(cfg config.Root) (string, error) {
|
||||
pgPassword, err := cfg.PostgresPassword().Read()
|
||||
@@ -1400,7 +1424,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)
|
||||
}
|
||||
@@ -1451,13 +1475,166 @@ func configureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile stri
|
||||
return ctx, &http.Client{}, nil
|
||||
}
|
||||
|
||||
func redirectHTTPToAccessURL(handler http.Handler, accessURL *url.URL) http.Handler {
|
||||
// nolint:revive
|
||||
func redirectToAccessURL(handler http.Handler, accessURL *url.URL, tunnel bool, appHostnameRegex *regexp.Regexp) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil {
|
||||
redirect := func() {
|
||||
http.Redirect(w, r, accessURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Only do this if we aren't tunneling.
|
||||
// If we are tunneling, we want to allow the request to go through
|
||||
// because the tunnel doesn't proxy with TLS.
|
||||
if !tunnel && accessURL.Scheme == "https" && r.TLS == nil {
|
||||
redirect()
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
if r.Host == accessURL.Host {
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if appHostnameRegex != nil && appHostnameRegex.MatchString(r.Host) {
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
redirect()
|
||||
})
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
func buildLogger(cmd *cobra.Command, cfg *codersdk.DeploymentConfig) (slog.Logger, func(), error) {
|
||||
var (
|
||||
sinks = []slog.Sink{}
|
||||
closers = []func() error{}
|
||||
)
|
||||
|
||||
addSinkIfProvided := func(sinkFn func(io.Writer) slog.Sink, loc string) error {
|
||||
switch loc {
|
||||
case "":
|
||||
|
||||
case "/dev/stdout":
|
||||
sinks = append(sinks, sinkFn(cmd.OutOrStdout()))
|
||||
|
||||
case "/dev/stderr":
|
||||
sinks = append(sinks, sinkFn(cmd.ErrOrStderr()))
|
||||
|
||||
default:
|
||||
fi, err := os.OpenFile(loc, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open log file %q: %w", loc, err)
|
||||
}
|
||||
|
||||
closers = append(closers, fi.Close)
|
||||
sinks = append(sinks, sinkFn(fi))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := addSinkIfProvided(sloghuman.Sink, cfg.Logging.Human.Value)
|
||||
if err != nil {
|
||||
return slog.Logger{}, nil, xerrors.Errorf("add human sink: %w", err)
|
||||
}
|
||||
err = addSinkIfProvided(slogjson.Sink, cfg.Logging.JSON.Value)
|
||||
if err != nil {
|
||||
return slog.Logger{}, nil, xerrors.Errorf("add json sink: %w", err)
|
||||
}
|
||||
err = addSinkIfProvided(slogstackdriver.Sink, cfg.Logging.Stackdriver.Value)
|
||||
if err != nil {
|
||||
return slog.Logger{}, nil, xerrors.Errorf("add stackdriver sink: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Trace.CaptureLogs.Value {
|
||||
sinks = append(sinks, tracing.SlogSink{})
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
if len(sinks) == 0 {
|
||||
return slog.Logger{}, nil, xerrors.New("no loggers provided")
|
||||
}
|
||||
|
||||
return slog.Make(sinks...).Leveled(level), func() {
|
||||
for _, closer := range closers {
|
||||
_ = closer()
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
|
||||
logger.Debug(ctx, "connecting to postgresql")
|
||||
sqlDB, err := sql.Open(driver, dbURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer pingCancel()
|
||||
|
||||
err = sqlDB.PingContext(pingCtx)
|
||||
if err != nil {
|
||||
return nil, 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 {
|
||||
return nil, xerrors.Errorf("get postgres version: %w", err)
|
||||
}
|
||||
if !version.Next() {
|
||||
return nil, xerrors.Errorf("no rows returned for version select")
|
||||
}
|
||||
var versionStr string
|
||||
err = version.Scan(&versionStr)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("scan version: %w", err)
|
||||
}
|
||||
_ = version.Close()
|
||||
versionStr = strings.Split(versionStr, " ")[0]
|
||||
if semver.Compare("v"+versionStr, "v13") < 0 {
|
||||
return nil, xerrors.New("PostgreSQL version must be v13.0.0 or higher!")
|
||||
}
|
||||
logger.Debug(ctx, "connected to postgresql", slog.F("version", versionStr))
|
||||
|
||||
err = migrations.Up(sqlDB)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("migrate up: %w", err)
|
||||
}
|
||||
// The default is 0 but the request will fail with a 500 if the DB
|
||||
// cannot accept new connections, so we try to limit that here.
|
||||
// Requests will wait for a new connection instead of a hard error
|
||||
// if a limit is set.
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
// Allow a max of 3 idle connections at a time. Lower values end up
|
||||
// creating a lot of connection churn. Since each connection uses about
|
||||
// 10MB of memory, we're allocating 30MB to Postgres connections per
|
||||
// replica, but is better than causing Postgres to spawn a thread 15-20
|
||||
// times/sec. PGBouncer's transaction pooling is not the greatest so
|
||||
// it's not optimal for us to deploy.
|
||||
//
|
||||
// This was set to 10 before we started doing HA deployments, but 3 was
|
||||
// later determined to be a better middle ground as to not use up all
|
||||
// of PGs default connection limit while simultaneously avoiding a lot
|
||||
// of connection churn.
|
||||
sqlDB.SetMaxIdleConns(3)
|
||||
|
||||
ok = true
|
||||
return sqlDB, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func newCreateAdminUserCommand() *cobra.Command {
|
||||
var (
|
||||
newUserDBURL string
|
||||
newUserSSHKeygenAlgorithm string
|
||||
newUserUsername string
|
||||
newUserEmail string
|
||||
newUserPassword string
|
||||
)
|
||||
createAdminUserCommand := &cobra.Command{
|
||||
Use: "create-admin-user",
|
||||
Short: "Create a new admin user with the given username, email and password and adds it to every organization.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(newUserSSHKeygenAlgorithm)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse ssh keygen algorithm %q: %w", newUserSSHKeygenAlgorithm, err)
|
||||
}
|
||||
|
||||
if val, exists := os.LookupEnv("CODER_POSTGRES_URL"); exists {
|
||||
newUserDBURL = val
|
||||
}
|
||||
if val, exists := os.LookupEnv("CODER_SSH_KEYGEN_ALGORITHM"); exists {
|
||||
newUserSSHKeygenAlgorithm = val
|
||||
}
|
||||
if val, exists := os.LookupEnv("CODER_USERNAME"); exists {
|
||||
newUserUsername = val
|
||||
}
|
||||
if val, exists := os.LookupEnv("CODER_EMAIL"); exists {
|
||||
newUserEmail = val
|
||||
}
|
||||
if val, exists := os.LookupEnv("CODER_PASSWORD"); exists {
|
||||
newUserPassword = val
|
||||
}
|
||||
|
||||
cfg := createConfig(cmd)
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer cancel()
|
||||
|
||||
if newUserDBURL == "" {
|
||||
cmd.Printf("Using built-in PostgreSQL (%s)\n", cfg.PostgresPath())
|
||||
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = closePg()
|
||||
}()
|
||||
newUserDBURL = url
|
||||
}
|
||||
|
||||
sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
db := database.New(sqlDB)
|
||||
|
||||
validateInputs := func(username, email, password string) error {
|
||||
// Use the validator tags so we match the API's validation.
|
||||
req := codersdk.CreateUserRequest{
|
||||
Username: "username",
|
||||
Email: "email@coder.com",
|
||||
Password: "ValidPa$$word123!",
|
||||
OrganizationID: uuid.New(),
|
||||
}
|
||||
if username != "" {
|
||||
req.Username = username
|
||||
}
|
||||
if email != "" {
|
||||
req.Email = email
|
||||
}
|
||||
if password != "" {
|
||||
req.Password = password
|
||||
}
|
||||
|
||||
return httpapi.Validate.Struct(req)
|
||||
}
|
||||
|
||||
if newUserUsername == "" {
|
||||
newUserUsername, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Username",
|
||||
Validate: func(val string) error {
|
||||
if val == "" {
|
||||
return xerrors.New("username cannot be empty")
|
||||
}
|
||||
return validateInputs(val, "", "")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newUserEmail == "" {
|
||||
newUserEmail, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Email",
|
||||
Validate: func(val string) error {
|
||||
if val == "" {
|
||||
return xerrors.New("email cannot be empty")
|
||||
}
|
||||
return validateInputs("", val, "")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newUserPassword == "" {
|
||||
newUserPassword, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Password",
|
||||
Secret: true,
|
||||
Validate: func(val string) error {
|
||||
if val == "" {
|
||||
return xerrors.New("password cannot be empty")
|
||||
}
|
||||
return validateInputs("", "", val)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prompt again.
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm password",
|
||||
Secret: true,
|
||||
Validate: func(val string) error {
|
||||
if val != newUserPassword {
|
||||
return xerrors.New("passwords do not match")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = validateInputs(newUserUsername, newUserEmail, newUserPassword)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate inputs: %w", err)
|
||||
}
|
||||
|
||||
hashedPassword, err := userpassword.Hash(newUserPassword)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
// Create the user.
|
||||
var newUser database.User
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
orgs, err := tx.GetOrganizations(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get organizations: %w", err)
|
||||
}
|
||||
|
||||
// Sort organizations by name so that test output is consistent.
|
||||
sort.Slice(orgs, func(i, j int) bool {
|
||||
return orgs[i].Name < orgs[j].Name
|
||||
})
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Creating user...")
|
||||
newUser, err = tx.InsertUser(ctx, database.InsertUserParams{
|
||||
ID: uuid.New(),
|
||||
Email: newUserEmail,
|
||||
Username: newUserUsername,
|
||||
HashedPassword: []byte(hashedPassword),
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Generating user SSH key...")
|
||||
privateKey, publicKey, err := gitsshkey.Generate(sshKeygenAlgorithm)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate user gitsshkey: %w", err)
|
||||
}
|
||||
_, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
|
||||
UserID: newUser.ID,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert user gitsshkey: %w", err)
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Adding user to organization %q (%s) as admin...\n", org.Name, org.ID.String())
|
||||
_, err := tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
|
||||
OrganizationID: org.ID,
|
||||
UserID: newUser.ID,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
Roles: []string{rbac.RoleOrgAdmin(org.ID)},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert organization member: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "User created successfully.")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "ID: "+newUser.ID.String())
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Username: "+newUser.Username)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Email: "+newUser.Email)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Password: ********")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
createAdminUserCommand.Flags().StringVar(&newUserDBURL, "postgres-url", "", "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). Consumes $CODER_POSTGRES_URL.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserSSHKeygenAlgorithm, "ssh-keygen-algorithm", "ed25519", "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\". Consumes $CODER_SSH_KEYGEN_ALGORITHM.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserUsername, "username", "", "The username of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_USERNAME.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserEmail, "email", "", "The email of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_EMAIL.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserPassword, "password", "", "The password of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_PASSWORD.")
|
||||
|
||||
return createAdminUserCommand
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
//nolint:paralleltest, tparallel
|
||||
func TestServerCreateAdminUser(t *testing.T) {
|
||||
const (
|
||||
username = "dean"
|
||||
email = "dean@example.com"
|
||||
password = "SecurePa$$word123"
|
||||
)
|
||||
|
||||
verifyUser := func(t *testing.T, dbURL, username, email, password string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
sqlDB, err := sql.Open("postgres", dbURL)
|
||||
require.NoError(t, err)
|
||||
defer sqlDB.Close()
|
||||
db := database.New(sqlDB)
|
||||
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, testutil.WaitShort)
|
||||
defer pingCancel()
|
||||
_, err = db.Ping(pingCtx)
|
||||
require.NoError(t, err, "ping db")
|
||||
|
||||
user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Email: email,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, username, user.Username, "username does not match")
|
||||
require.Equal(t, email, user.Email, "email does not match")
|
||||
|
||||
ok, err := userpassword.Compare(string(user.HashedPassword), password)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "password does not match")
|
||||
|
||||
require.EqualValues(t, []string{rbac.RoleOwner()}, user.RBACRoles, "user does not have owner role")
|
||||
|
||||
// Check that user is admin in every org.
|
||||
orgs, err := db.GetOrganizations(ctx)
|
||||
require.NoError(t, err)
|
||||
orgIDs := make(map[uuid.UUID]struct{}, len(orgs))
|
||||
for _, org := range orgs {
|
||||
orgIDs[org.ID] = struct{}{}
|
||||
}
|
||||
|
||||
orgMemberships, err := db.GetOrganizationMembershipsByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships))
|
||||
for _, membership := range orgMemberships {
|
||||
orgIDs2[membership.OrganizationID] = struct{}{}
|
||||
assert.Equal(t, []string{rbac.RoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin")
|
||||
}
|
||||
|
||||
require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs")
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
|
||||
sqlDB, err := sql.Open("postgres", connectionURL)
|
||||
require.NoError(t, err)
|
||||
defer sqlDB.Close()
|
||||
db := database.New(sqlDB)
|
||||
|
||||
// Sometimes generating SSH keys takes a really long time if there isn't
|
||||
// enough entropy. We don't want the tests to fail in these cases.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancel()
|
||||
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, testutil.WaitShort)
|
||||
defer pingCancel()
|
||||
_, err = db.Ping(pingCtx)
|
||||
require.NoError(t, err, "ping db")
|
||||
|
||||
// Insert a few orgs.
|
||||
org1Name, org1ID := "org1", uuid.New()
|
||||
org2Name, org2ID := "org2", uuid.New()
|
||||
_, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{
|
||||
ID: org1ID,
|
||||
Name: org1Name,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{
|
||||
ID: org2ID,
|
||||
Name: org2Name,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server", "create-admin-user",
|
||||
"--postgres-url", connectionURL,
|
||||
"--ssh-keygen-algorithm", "ed25519",
|
||||
"--username", username,
|
||||
"--email", email,
|
||||
"--password", password,
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
t.Log("root.ExecuteContext() returned:", err)
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "Creating user...")
|
||||
pty.ExpectMatchContext(ctx, "Generating user SSH key...")
|
||||
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org1Name, org1ID.String()))
|
||||
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org2Name, org2ID.String()))
|
||||
pty.ExpectMatchContext(ctx, "User created successfully.")
|
||||
pty.ExpectMatchContext(ctx, username)
|
||||
pty.ExpectMatchContext(ctx, email)
|
||||
pty.ExpectMatchContext(ctx, "****")
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
|
||||
verifyUser(t, connectionURL, username, email, password)
|
||||
})
|
||||
|
||||
//nolint:paralleltest
|
||||
t.Run("Env", func(t *testing.T) {
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
|
||||
// Sometimes generating SSH keys takes a really long time if there isn't
|
||||
// enough entropy. We don't want the tests to fail in these cases.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancel()
|
||||
|
||||
t.Setenv("CODER_POSTGRES_URL", connectionURL)
|
||||
t.Setenv("CODER_SSH_KEYGEN_ALGORITHM", "ed25519")
|
||||
t.Setenv("CODER_USERNAME", username)
|
||||
t.Setenv("CODER_EMAIL", email)
|
||||
t.Setenv("CODER_PASSWORD", password)
|
||||
|
||||
root, _ := clitest.New(t, "server", "create-admin-user")
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
t.Log("root.ExecuteContext() returned:", err)
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "User created successfully.")
|
||||
pty.ExpectMatchContext(ctx, username)
|
||||
pty.ExpectMatchContext(ctx, email)
|
||||
pty.ExpectMatchContext(ctx, "****")
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
|
||||
verifyUser(t, connectionURL, username, email, password)
|
||||
})
|
||||
|
||||
t.Run("Stdin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
|
||||
// Sometimes generating SSH keys takes a really long time if there isn't
|
||||
// enough entropy. We don't want the tests to fail in these cases.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancel()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server", "create-admin-user",
|
||||
"--postgres-url", connectionURL,
|
||||
"--ssh-keygen-algorithm", "ed25519",
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
t.Log("root.ExecuteContext() returned:", err)
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "> Username")
|
||||
pty.WriteLine(username)
|
||||
pty.ExpectMatchContext(ctx, "> Email")
|
||||
pty.WriteLine(email)
|
||||
pty.ExpectMatchContext(ctx, "> Password")
|
||||
pty.WriteLine(password)
|
||||
pty.ExpectMatchContext(ctx, "> Confirm password")
|
||||
pty.WriteLine(password)
|
||||
|
||||
pty.ExpectMatchContext(ctx, "User created successfully.")
|
||||
pty.ExpectMatchContext(ctx, username)
|
||||
pty.ExpectMatchContext(ctx, email)
|
||||
pty.ExpectMatchContext(ctx, "****")
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
|
||||
verifyUser(t, connectionURL, username, email, password)
|
||||
})
|
||||
|
||||
t.Run("Validates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server", "create-admin-user",
|
||||
"--postgres-url", connectionURL,
|
||||
"--ssh-keygen-algorithm", "rsa4096",
|
||||
"--username", "$",
|
||||
"--email", "not-an-email",
|
||||
"--password", "x",
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
err = root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "'email' failed on the 'email' tag")
|
||||
require.ErrorContains(t, err, "'username' failed on the 'username' tag")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
"github.com/coder/coder/coderd"
|
||||
)
|
||||
|
||||
func Server(vip *viper.Viper, _ func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start a Coder server",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
serverUnsupported(cmd.ErrOrStderr())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var pgRawURL bool
|
||||
postgresBuiltinURLCmd := &cobra.Command{
|
||||
Use: "postgres-builtin-url",
|
||||
Short: "Output the connection URL for the built-in PostgreSQL deployment.",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
serverUnsupported(cmd.ErrOrStderr())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
postgresBuiltinServeCmd := &cobra.Command{
|
||||
Use: "postgres-builtin-serve",
|
||||
Short: "Run the built-in PostgreSQL deployment.",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
serverUnsupported(cmd.ErrOrStderr())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
newUserDBURL string
|
||||
newUserSSHKeygenAlgorithm string
|
||||
newUserUsername string
|
||||
newUserEmail string
|
||||
newUserPassword string
|
||||
)
|
||||
createAdminUserCommand := &cobra.Command{
|
||||
Use: "create-admin-user",
|
||||
Short: "Create a new admin user with the given username, email and password and adds it to every organization.",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
serverUnsupported(cmd.ErrOrStderr())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// We still have to attach the flags to the commands so users don't get
|
||||
// an error when they try to use them.
|
||||
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.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserDBURL, "postgres-url", "", "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). Consumes $CODER_POSTGRES_URL.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserSSHKeygenAlgorithm, "ssh-keygen-algorithm", "ed25519", "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\". Consumes $CODER_SSH_KEYGEN_ALGORITHM.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserUsername, "username", "", "The username of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_USERNAME.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserEmail, "email", "", "The email of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_EMAIL.")
|
||||
createAdminUserCommand.Flags().StringVar(&newUserPassword, "password", "", "The password of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_PASSWORD.")
|
||||
|
||||
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd, createAdminUserCommand)
|
||||
|
||||
deployment.AttachFlags(root.Flags(), vip, false)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func serverUnsupported(w io.Writer) {
|
||||
_, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", cliui.Styles.Code.Render("server"))
|
||||
_, _ = fmt.Fprintln(w, "")
|
||||
_, _ = fmt.Fprintln(w, "Please use a build of Coder from GitHub releases:")
|
||||
_, _ = fmt.Fprintln(w, " https://github.com/coder/coder/releases")
|
||||
os.Exit(1)
|
||||
}
|
||||
+304
-25
@@ -32,6 +32,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/codersdk"
|
||||
@@ -70,11 +71,7 @@ func TestServer(t *testing.T) {
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
Password: "password",
|
||||
})
|
||||
_, err = client.CreateFirstUser(ctx, coderdtest.FirstUserParams)
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
@@ -118,6 +115,21 @@ func TestServer(t *testing.T) {
|
||||
|
||||
pty.ExpectMatch("psql")
|
||||
})
|
||||
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
||||
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := pty.ReadLine(ctx)
|
||||
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.
|
||||
@@ -275,11 +287,6 @@ func TestServer(t *testing.T) {
|
||||
args []string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "NoCertAndKey",
|
||||
args: []string{"--tls-enable"},
|
||||
errContains: "--tls-cert-file is required when tls is enabled",
|
||||
},
|
||||
{
|
||||
name: "NoCert",
|
||||
args: []string{"--tls-enable", "--tls-key-file", key1Path},
|
||||
@@ -358,6 +365,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -478,12 +486,12 @@ func TestServer(t *testing.T) {
|
||||
// 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()
|
||||
httpLine := pty.ReadLine(ctx)
|
||||
httpAddr := strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
|
||||
require.NotEmpty(t, httpAddr)
|
||||
const tlsLinePrefix = "Started TLS/HTTPS listener at "
|
||||
pty.ExpectMatch(tlsLinePrefix)
|
||||
tlsLine := pty.ReadLine()
|
||||
tlsLine := pty.ReadLine(ctx)
|
||||
tlsAddr := strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
|
||||
@@ -512,6 +520,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -526,7 +535,9 @@ func TestServer(t *testing.T) {
|
||||
name string
|
||||
httpListener bool
|
||||
tlsListener bool
|
||||
redirect bool
|
||||
accessURL string
|
||||
requestURL string
|
||||
// Empty string means no redirect.
|
||||
expectRedirect string
|
||||
}{
|
||||
@@ -534,9 +545,25 @@ func TestServer(t *testing.T) {
|
||||
name: "OK",
|
||||
httpListener: true,
|
||||
tlsListener: true,
|
||||
redirect: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "NoRedirect",
|
||||
httpListener: true,
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "NoRedirectWithWildcard",
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
requestURL: "https://dev.example.com",
|
||||
expectRedirect: "",
|
||||
redirect: true,
|
||||
},
|
||||
{
|
||||
name: "NoTLSListener",
|
||||
httpListener: true,
|
||||
@@ -562,14 +589,21 @@ func TestServer(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
if c.requestURL == "" {
|
||||
c.requestURL = c.accessURL
|
||||
}
|
||||
|
||||
httpListenAddr := ""
|
||||
if c.httpListener {
|
||||
httpListenAddr = ":0"
|
||||
}
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
flags := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--cache-dir", t.TempDir(),
|
||||
}
|
||||
if c.httpListener {
|
||||
flags = append(flags, "--http-address", ":0")
|
||||
"--http-address", httpListenAddr,
|
||||
}
|
||||
if c.tlsListener {
|
||||
flags = append(flags,
|
||||
@@ -577,11 +611,15 @@ func TestServer(t *testing.T) {
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--wildcard-access-url", "*.example.com",
|
||||
)
|
||||
}
|
||||
if c.accessURL != "" {
|
||||
flags = append(flags, "--access-url", c.accessURL)
|
||||
}
|
||||
if c.redirect {
|
||||
flags = append(flags, "--redirect-to-access-url")
|
||||
}
|
||||
|
||||
root, _ := clitest.New(t, flags...)
|
||||
pty := ptytest.New(t)
|
||||
@@ -601,14 +639,14 @@ func TestServer(t *testing.T) {
|
||||
if c.httpListener {
|
||||
const httpLinePrefix = "Started HTTP listener at "
|
||||
pty.ExpectMatch(httpLinePrefix)
|
||||
httpLine := pty.ReadLine()
|
||||
httpLine := pty.ReadLine(ctx)
|
||||
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()
|
||||
tlsLine := pty.ReadLine(ctx)
|
||||
tlsAddr = strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
}
|
||||
@@ -634,23 +672,27 @@ func TestServer(t *testing.T) {
|
||||
|
||||
// Verify TLS
|
||||
if c.tlsListener {
|
||||
tlsURL, err := url.Parse(tlsAddr)
|
||||
accessURLParsed, err := url.Parse(c.requestURL)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(tlsURL)
|
||||
client := codersdk.New(accessURLParsed)
|
||||
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,
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tls.Dial(network, strings.TrimPrefix(tlsAddr, "https://"), &tls.Config{
|
||||
// nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
if err != nil {
|
||||
require.ErrorContains(t, err, "Invalid application URL")
|
||||
}
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
@@ -658,6 +700,58 @@ func TestServer(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
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())
|
||||
@@ -672,7 +766,7 @@ func TestServer(t *testing.T) {
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "either HTTP or TLS must be enabled")
|
||||
require.ErrorContains(t, err, "TLS is disabled. Enable with --tls-enable or specify a HTTP address")
|
||||
})
|
||||
|
||||
t.Run("NoTLSAddress", func(t *testing.T) {
|
||||
@@ -767,6 +861,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1054,6 +1149,190 @@ func TestServer(t *testing.T) {
|
||||
<-serverErr
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Logging", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("CreatesFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
fiName := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--verbose",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-human", fiName,
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fiName)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("Human", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
fi := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--verbose",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-human", fi,
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
fi := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--verbose",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-json", fi,
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("Stackdriver", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancelFunc()
|
||||
|
||||
fi := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--verbose",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-stackdriver", fi,
|
||||
)
|
||||
// Attach pty so we get debug output from the command if this test
|
||||
// fails.
|
||||
pty := ptytest.New(t)
|
||||
root.SetOut(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
defer func() {
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
}()
|
||||
|
||||
// Wait for server to listen on HTTP, this is a good
|
||||
// starting point for expecting logs.
|
||||
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at ")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
})
|
||||
|
||||
t.Run("Multiple", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancelFunc()
|
||||
|
||||
fi1 := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
fi2 := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
fi3 := testutil.TempFile(t, "", "coder-logging-test-*")
|
||||
|
||||
// NOTE(mafredri): This test might end up downloading Terraform
|
||||
// which can take a long time and end up failing the test.
|
||||
// This is why we wait extra long below for server to listen on
|
||||
// HTTP.
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--verbose",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-human", fi1,
|
||||
"--log-json", fi2,
|
||||
"--log-stackdriver", fi3,
|
||||
)
|
||||
// Attach pty so we get debug output from the command if this test
|
||||
// fails.
|
||||
pty := ptytest.New(t)
|
||||
root.SetOut(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
defer func() {
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
}()
|
||||
|
||||
// Wait for server to listen on HTTP, this is a good
|
||||
// starting point for expecting logs.
|
||||
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at ")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi1)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalMedium, "log human size > 0")
|
||||
require.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi2)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalMedium, "log json size > 0")
|
||||
require.Eventually(t, func() bool {
|
||||
stat, err := os.Stat(fi3)
|
||||
return err == nil && stat.Size() > 0
|
||||
}, testutil.WaitShort, testutil.IntervalMedium, "log stackdriver size > 0")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) {
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
+18
-2
@@ -5,12 +5,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -21,7 +23,7 @@ func TestSpeedtest(t *testing.T) {
|
||||
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
|
||||
}
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
@@ -30,13 +32,27 @@ func TestSpeedtest(t *testing.T) {
|
||||
defer agentCloser.Close()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
ws, err := client.Workspace(ctx, workspace.ID)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
a := ws.LatestBuild.Resources[0].Agents[0]
|
||||
return a.Status == codersdk.WorkspaceAgentConnected &&
|
||||
a.LifecycleState == codersdk.WorkspaceAgentLifecycleReady
|
||||
}, testutil.WaitLong, testutil.IntervalFast, "agent is not ready")
|
||||
|
||||
cmd, root := clitest.New(t, "speedtest", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user