Compare commits
435 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3a4861e93 | |||
| ea7a80c5ff | |||
| 2ff1c6d613 | |||
| 537547fcc3 | |||
| 67db36bf81 | |||
| 8cf292f50a | |||
| 1724cbf872 | |||
| ce11400b56 | |||
| 28e002e8bb | |||
| 454da9e0ef | |||
| 32ecb853ed | |||
| 570b7f95d2 | |||
| b2671639a7 | |||
| 991c720c09 | |||
| 31a37025c4 | |||
| 9af03d6180 | |||
| 4dd95c5e01 | |||
| 9ea21bf8ee | |||
| 6304bfb5c0 | |||
| b56e1bb002 | |||
| c560d6d2ae | |||
| 5f6dd0ca2a | |||
| 8850ce0d9a | |||
| 05e449943d | |||
| 7cf1e20aac | |||
| 418a8a77dc | |||
| d30da81f42 | |||
| dc6639bf69 | |||
| 7f226d4f90 | |||
| 3d8b77d6f1 | |||
| ec6f78d09e | |||
| 2e53fb55da | |||
| 16364db483 | |||
| 7c46f76c82 | |||
| acbe968f41 | |||
| 1fb7365cb1 | |||
| 17adfd1134 | |||
| acf000aaa5 | |||
| d613ba9987 | |||
| cae8b88f60 | |||
| 5876dc1f97 | |||
| 29dbfe067c | |||
| 677721e4a1 | |||
| a414de9e81 | |||
| 473ab208af | |||
| 16363fd1ff | |||
| 282507f0fb | |||
| 50db90c33d | |||
| 8abe48c155 | |||
| 8a4a179565 | |||
| 7f65a837b1 | |||
| 0bf6229edb | |||
| 1ba6fab0e0 | |||
| 8298a924f6 | |||
| a32169ccb5 | |||
| 7a52a9cfc8 | |||
| f6a8c360e5 | |||
| 43e8ba0811 | |||
| 2a8a147e7d | |||
| bbdf24686d | |||
| 457ad74d83 | |||
| f7c10adb04 | |||
| 8231de94ca | |||
| 6149905a83 | |||
| b412ef0dbb | |||
| d993a97fee | |||
| 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 |
@@ -57,7 +57,7 @@ RUN mkdir -p /etc/apt/keyrings \
|
||||
&& 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.14
|
||||
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,29 +152,29 @@ 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
|
||||
@@ -243,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:
|
||||
@@ -280,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:
|
||||
@@ -294,24 +251,27 @@ 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
|
||||
@@ -320,7 +280,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.8.2
|
||||
tag: v1.9.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -335,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()
|
||||
@@ -384,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
|
||||
@@ -397,24 +334,24 @@ 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
|
||||
@@ -423,7 +360,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.8.2
|
||||
tag: v1.9.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -432,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()
|
||||
@@ -474,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: |
|
||||
@@ -503,24 +417,24 @@ 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 }}-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
|
||||
@@ -574,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
|
||||
@@ -593,12 +506,12 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "16.16.0"
|
||||
|
||||
- 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
|
||||
@@ -614,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
|
||||
|
||||
@@ -638,7 +546,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -647,24 +555,24 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "16.16.0"
|
||||
|
||||
- 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
|
||||
@@ -675,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
|
||||
@@ -704,6 +609,10 @@ jobs:
|
||||
# only get 1 commit on shallow checkout.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16.16.0"
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd site && yarn
|
||||
|
||||
@@ -735,23 +644,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*
|
||||
@@ -1,64 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
# run every week at 10:24 on Thursday
|
||||
- cron: "24 10 * * 4"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["go", "javascript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Go Cache Paths
|
||||
if: matrix.language == 'go'
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Mod Cache
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Remove Makefile # workaround to prevent CodeQL from building site
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
# Disable Analysis step from trying to build the project.
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -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.3.0
|
||||
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,90 @@
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/coder/coder-base:latest
|
||||
|
||||
- name: Verify that images are pushed properly
|
||||
run: |
|
||||
# retry 10 times with a 5 second delay as the images may not be
|
||||
# available immediately
|
||||
for i in {1..10}; do
|
||||
rc=0
|
||||
raw_manifests=$(docker buildx imagetools inspect --raw ghcr.io/coder/coder-base:latest) || rc=$?
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "$i" -eq 10 ]]; then
|
||||
echo "Failed to pull manifests after 10 retries"
|
||||
exit 1
|
||||
fi
|
||||
echo "Failed to pull manifests, retrying in 5 seconds"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
manifests=$(
|
||||
echo "$raw_manifests" | \
|
||||
jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)'
|
||||
)
|
||||
|
||||
# Verify all 3 platforms are present.
|
||||
set -euxo pipefail
|
||||
echo "$manifests" | grep -q linux/amd64
|
||||
echo "$manifests" | grep -q linux/arm64
|
||||
echo "$manifests" | grep -q linux/arm/v7
|
||||
@@ -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,60 +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
|
||||
|
||||
- name: Submit updated manifest to winget-pkgs
|
||||
run: |
|
||||
$release_assets = gh release view --repo coder/coder "$env:CODER_VERSION" --json assets | `
|
||||
ConvertFrom-Json
|
||||
# Get the installer URL from the release assets.
|
||||
$installer_url = $release_assets.assets | `
|
||||
Where-Object name -Match ".*_windows_amd64_installer.exe$" | `
|
||||
Select -ExpandProperty url
|
||||
|
||||
echo "Installer URL: $installer_url"
|
||||
|
||||
# The package version is the same as the tag minus the leading "v".
|
||||
$version = $env:CODER_VERSION -replace "^v", ""
|
||||
|
||||
echo "Package version: $version"
|
||||
|
||||
# The URL "|X64" suffix forces the architecture as it cannot be
|
||||
# sniffed properly from the URL. wingetcreate checks both the URL and
|
||||
# binary magic bytes for the architecture and they need to both match,
|
||||
# but they only check for `x64`, `win64` and `_64` in the URL. Our URL
|
||||
# contains `amd64` which doesn't match sadly.
|
||||
#
|
||||
# wingetcreate will still do the binary magic bytes check, so if we
|
||||
# accidentally change the architecture of the installer, it will fail
|
||||
# submission.
|
||||
.\wingetcreate.exe update Coder.Coder `
|
||||
--submit `
|
||||
--version "${version}" `
|
||||
--urls "${installer_url}|X64" `
|
||||
--token "${{ secrets.CDRCI_GITHUB_TOKEN }}"
|
||||
|
||||
env:
|
||||
# For gh CLI:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Comment on PR
|
||||
run: |
|
||||
# find the PR that wingetcreate just made
|
||||
$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 @matifali"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Filtering pull requests is much easier when we can reliably guarantee
|
||||
# that the "Assignee" field is populated.
|
||||
name: PR Auto Assign
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
assign-author:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: toshimaru/auto-author-assign@v1.6.2
|
||||
@@ -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
|
||||
+226
-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,69 @@ 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
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-base-tag.outputs.tag }}
|
||||
|
||||
- name: Verify that images are pushed properly
|
||||
run: |
|
||||
# retry 10 times with a 5 second delay as the images may not be
|
||||
# available immediately
|
||||
for i in {1..10}; do
|
||||
rc=0
|
||||
raw_manifests=$(docker buildx imagetools inspect --raw "${{ steps.image-base-tag.outputs.tag }}") || rc=$?
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "$i" -eq 10 ]]; then
|
||||
echo "Failed to pull manifests after 10 retries"
|
||||
exit 1
|
||||
fi
|
||||
echo "Failed to pull manifests, retrying in 5 seconds"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
manifests=$(
|
||||
echo "$raw_manifests" | \
|
||||
jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)'
|
||||
)
|
||||
|
||||
# Verify all 3 platforms are present.
|
||||
set -euxo pipefail
|
||||
echo "$manifests" | grep -q linux/amd64
|
||||
echo "$manifests" | grep -q linux/arm64
|
||||
echo "$manifests" | grep -q linux/arm/v7
|
||||
|
||||
- name: Build Linux Docker images
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
@@ -151,14 +252,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 +280,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
|
||||
@@ -179,6 +292,7 @@ jobs:
|
||||
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 +303,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 }}
|
||||
uses: actions/upload-artifact@v2
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
./build/*_installer.exe
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.tgz
|
||||
@@ -202,3 +317,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@8bd2f9fbda2109502356ff8a6a89da55b1ead252
|
||||
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@v3
|
||||
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@v7.0.0
|
||||
with:
|
||||
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! 👀👀👀
|
||||
+7
-1
@@ -23,7 +23,10 @@ site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/states/*.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
@@ -33,6 +36,9 @@ cli/testdata/.gen-golden
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
|
||||
+7
-1
@@ -26,7 +26,10 @@ site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/states/*.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
@@ -36,6 +39,9 @@ cli/testdata/.gen-golden
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
|
||||
@@ -1,2 +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
+18
-1
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
@@ -111,12 +114,14 @@
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tanstack",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"tbody",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
@@ -128,6 +133,7 @@
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tparallel",
|
||||
@@ -180,6 +186,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 +208,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. |
|
||||
@@ -92,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
|
||||
@@ -296,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)")"
|
||||
@@ -361,9 +368,15 @@ 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
|
||||
@@ -411,6 +424,8 @@ gen: \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli.md \
|
||||
docs/admin/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
@@ -429,6 +444,8 @@ gen/mark-fresh:
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli.md \
|
||||
docs/admin/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
@@ -483,10 +500,20 @@ docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/me
|
||||
cd site
|
||||
yarn run format:write:only ../docs/admin/prometheus.md
|
||||
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen -not \( -path './scripts/apidocgen/node_modules' -prune \) -type f) $(wildcard coderd/*.go) $(wildcard codersdk/*.go) .swaggo
|
||||
./scripts/apidocgen/generate.sh
|
||||
docs/cli.md: scripts/clidocgen/main.go $(GO_SRC_FILES) docs/manifest.json
|
||||
rm -rf ./docs/cli/*.md
|
||||
BASE_PATH="." go run ./scripts/clidocgen
|
||||
cd site
|
||||
yarn run format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json
|
||||
yarn run format:write:only ../docs/cli.md ../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
|
||||
@@ -556,7 +583,7 @@ site/.eslintignore site/.prettierignore: .prettierignore Makefile
|
||||
done < "$<"
|
||||
|
||||
test: test-clean
|
||||
gotestsum --debug -- -v -short ./...
|
||||
gotestsum -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
# When updating -timeout for this test, keep in sync with
|
||||
@@ -566,7 +593,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 \
|
||||
@@ -591,7 +617,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.
|
||||
+407
-126
@@ -18,6 +18,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -41,6 +42,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"
|
||||
@@ -55,10 +57,19 @@ const (
|
||||
// command just returning a nonzero exit code, and is chosen as an arbitrary, high number
|
||||
// unlikely to shadow other exit codes, which are typically 1, 2, 3, etc.
|
||||
MagicSessionErrorCode = 229
|
||||
|
||||
// MagicSSHSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection.
|
||||
// This is stripped from any commands being executed, and is counted towards connection stats.
|
||||
MagicSSHSessionTypeEnvironmentVariable = "__CODER_SSH_SESSION_TYPE"
|
||||
// MagicSSHSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself.
|
||||
MagicSSHSessionTypeVSCode = "vscode"
|
||||
// MagicSSHSessionTypeJetBrains is set in the SSH config by the JetBrains extension to identify itself.
|
||||
MagicSSHSessionTypeJetBrains = "jetbrains"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ExchangeToken func(ctx context.Context) (string, error)
|
||||
Client Client
|
||||
@@ -68,11 +79,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 +97,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 +118,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 +132,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 +149,21 @@ 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
|
||||
|
||||
statRxPackets atomic.Int64
|
||||
statRxBytes atomic.Int64
|
||||
statTxPackets atomic.Int64
|
||||
statTxBytes atomic.Int64
|
||||
connCountVSCode atomic.Int64
|
||||
connCountJetBrains atomic.Int64
|
||||
connCountReconnectingPTY atomic.Int64
|
||||
connCountSSHSession atomic.Int64
|
||||
}
|
||||
|
||||
// runLoop attempts to start the agent in a retry loop.
|
||||
@@ -135,8 +171,10 @@ 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")
|
||||
a.logger.Info(ctx, "connecting to coderd")
|
||||
err := a.run(ctx)
|
||||
// Cancel after the run is complete to clean up any leaked resources!
|
||||
if err == nil {
|
||||
@@ -149,13 +187,65 @@ func (a *agent) runLoop(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
a.logger.Info(ctx, "likely disconnected from coder", slog.Error(err))
|
||||
a.logger.Info(ctx, "disconnected from coderd")
|
||||
continue
|
||||
}
|
||||
a.logger.Warn(ctx, "run exited with error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "reporting 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,51 +256,102 @@ func (a *agent) run(ctx context.Context) error {
|
||||
}
|
||||
a.sessionToken.Store(&sessionToken)
|
||||
|
||||
err = a.client.PostWorkspaceAgentVersion(ctx, buildinfo.Version())
|
||||
metadata, err := a.client.Metadata(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch metadata: %w", err)
|
||||
}
|
||||
a.logger.Info(ctx, "fetched metadata", slog.F("metadata", 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)
|
||||
}
|
||||
|
||||
metadata, err := a.client.WorkspaceAgentMetadata(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch metadata: %w", err)
|
||||
}
|
||||
a.logger.Info(ctx, "fetched metadata")
|
||||
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.Debug(ctx, "running tailnet with derpmap", slog.F("derpmap", metadata.DERPMap))
|
||||
a.logger, metadata.Apps, a.client.PostAppHealth)(appReporterCtx)
|
||||
|
||||
a.closeMutex.Lock()
|
||||
network := a.network
|
||||
a.closeMutex.Unlock()
|
||||
if network == nil {
|
||||
a.logger.Debug(ctx, "creating tailnet")
|
||||
network, err = a.createTailnet(ctx, metadata.DERPMap)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tailnet: %w", err)
|
||||
@@ -227,33 +368,15 @@ func (a *agent) run(ctx context.Context) error {
|
||||
return xerrors.New("agent is closed")
|
||||
}
|
||||
|
||||
// Report statistics from the created network.
|
||||
cl, err := a.client.AgentReportStats(ctx, a.logger, func() *codersdk.AgentStats {
|
||||
stats := network.ExtractTrafficStats()
|
||||
return convertAgentStats(stats)
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
} else {
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
// This is OK because the agent never re-creates the tailnet
|
||||
// and the only shutdown indicator is agent.Close().
|
||||
<-a.closed
|
||||
_ = cl.Close()
|
||||
}); err != nil {
|
||||
a.logger.Debug(ctx, "report stats goroutine", slog.Error(err))
|
||||
_ = cl.Close()
|
||||
}
|
||||
}
|
||||
a.startReportingConnectionStats(ctx)
|
||||
} else {
|
||||
// Update the DERP map!
|
||||
network.SetDERPMap(metadata.DERPMap)
|
||||
}
|
||||
|
||||
a.logger.Debug(ctx, "running coordinator")
|
||||
a.logger.Debug(ctx, "running tailnet connection coordinator")
|
||||
err = a.runCoordinator(ctx, network)
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, "coordinator exited", slog.Error(err))
|
||||
return xerrors.Errorf("run coordinator: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -275,10 +398,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 +411,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,29 +421,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
|
||||
}
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
_ = a.trackConnGoroutine(func() {
|
||||
go func() {
|
||||
select {
|
||||
case <-network.Closed():
|
||||
case <-closed:
|
||||
case <-a.closed:
|
||||
_ = conn.Close()
|
||||
}
|
||||
_ = conn.Close()
|
||||
})
|
||||
_ = a.trackConnGoroutine(func() {
|
||||
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)
|
||||
}
|
||||
@@ -332,40 +458,54 @@ 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
|
||||
if !a.isClosed() {
|
||||
logger.Debug(ctx, "accept pty failed", slog.Error(err))
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -375,50 +515,64 @@ 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
|
||||
if !a.isClosed() {
|
||||
a.logger.Debug(ctx, "speedtest listener failed", slog.Error(err))
|
||||
}
|
||||
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
|
||||
@@ -430,13 +584,18 @@ 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
|
||||
}
|
||||
defer coordinator.Close()
|
||||
a.logger.Info(ctx, "connected to coordination server")
|
||||
sendNodes, errChan := tailnet.ServeCoordinator(coordinator, network.UpdateNodes)
|
||||
a.logger.Info(ctx, "connected to coordination endpoint")
|
||||
sendNodes, errChan := tailnet.ServeCoordinator(coordinator, func(nodes []*tailnet.Node) error {
|
||||
return network.UpdateNodes(nodes, false)
|
||||
})
|
||||
network.SetNodeCallback(sendNodes)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -452,7 +611,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)
|
||||
}
|
||||
@@ -479,7 +638,6 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
|
||||
}
|
||||
|
||||
func (a *agent) init(ctx context.Context) {
|
||||
a.logger.Info(ctx, "generating host key")
|
||||
// Clients' should ignore the host key when connecting.
|
||||
// The agent needs to authenticate with coderd to SSH,
|
||||
// so SSH authentication doesn't improve security.
|
||||
@@ -599,23 +757,6 @@ func (a *agent) init(ctx context.Context) {
|
||||
go a.runLoop(ctx)
|
||||
}
|
||||
|
||||
func convertAgentStats(counts map[netlogtype.Connection]netlogtype.Counts) *codersdk.AgentStats {
|
||||
stats := &codersdk.AgentStats{
|
||||
ConnsByProto: map[string]int64{},
|
||||
NumConns: int64(len(counts)),
|
||||
}
|
||||
|
||||
for conn, count := range counts {
|
||||
stats.ConnsByProto[conn.Proto.String()]++
|
||||
stats.RxPackets += int64(count.RxPackets)
|
||||
stats.RxBytes += int64(count.RxBytes)
|
||||
stats.TxPackets += int64(count.TxPackets)
|
||||
stats.TxBytes += int64(count.TxBytes)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// createCommand processes raw command input with OpenSSH-like behavior.
|
||||
// If the rawCommand provided is empty, it will default to the users shell.
|
||||
// This injects environment variables specified by the user at launch too.
|
||||
@@ -635,7 +776,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)
|
||||
}
|
||||
@@ -661,7 +802,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 {
|
||||
@@ -722,7 +867,27 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
ctx := session.Context()
|
||||
cmd, err := a.createCommand(ctx, session.RawCommand(), session.Environ())
|
||||
env := session.Environ()
|
||||
var magicType string
|
||||
for index, kv := range env {
|
||||
if !strings.HasPrefix(kv, MagicSSHSessionTypeEnvironmentVariable) {
|
||||
continue
|
||||
}
|
||||
magicType = strings.TrimPrefix(kv, MagicSSHSessionTypeEnvironmentVariable+"=")
|
||||
env = append(env[:index], env[index+1:]...)
|
||||
}
|
||||
switch magicType {
|
||||
case MagicSSHSessionTypeVSCode:
|
||||
a.connCountVSCode.Add(1)
|
||||
case MagicSSHSessionTypeJetBrains:
|
||||
a.connCountJetBrains.Add(1)
|
||||
case "":
|
||||
a.connCountSSHSession.Add(1)
|
||||
default:
|
||||
a.logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("type", magicType))
|
||||
}
|
||||
|
||||
cmd, err := a.createCommand(ctx, session.RawCommand(), env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -744,7 +909,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 {
|
||||
@@ -817,9 +982,11 @@ 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()
|
||||
|
||||
a.connCountReconnectingPTY.Add(1)
|
||||
|
||||
connectionID := uuid.NewString()
|
||||
logger = logger.With(slog.F("id", msg.ID), slog.F("connection_id", connectionID))
|
||||
|
||||
@@ -1010,6 +1177,103 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m
|
||||
}
|
||||
}
|
||||
|
||||
// startReportingConnectionStats runs the connection stats reporting goroutine.
|
||||
func (a *agent) startReportingConnectionStats(ctx context.Context) {
|
||||
reportStats := func(networkStats map[netlogtype.Connection]netlogtype.Counts) {
|
||||
stats := &agentsdk.Stats{
|
||||
ConnectionCount: int64(len(networkStats)),
|
||||
ConnectionsByProto: map[string]int64{},
|
||||
}
|
||||
// Tailscale resets counts on every report!
|
||||
// We'd rather have these compound, like Linux does!
|
||||
for conn, counts := range networkStats {
|
||||
stats.ConnectionsByProto[conn.Proto.String()]++
|
||||
stats.RxBytes = a.statRxBytes.Add(int64(counts.RxBytes))
|
||||
stats.RxPackets = a.statRxPackets.Add(int64(counts.RxPackets))
|
||||
stats.TxBytes = a.statTxBytes.Add(int64(counts.TxBytes))
|
||||
stats.TxPackets = a.statTxPackets.Add(int64(counts.TxPackets))
|
||||
}
|
||||
|
||||
// Tailscale's connection stats are not cumulative, but it makes no sense to make
|
||||
// ours temporary.
|
||||
stats.SessionCountSSH = a.connCountSSHSession.Load()
|
||||
stats.SessionCountVSCode = a.connCountVSCode.Load()
|
||||
stats.SessionCountJetBrains = a.connCountJetBrains.Load()
|
||||
stats.SessionCountReconnectingPTY = a.connCountReconnectingPTY.Load()
|
||||
|
||||
// Compute the median connection latency!
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
status := a.network.Status()
|
||||
durations := []float64{}
|
||||
ctx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
for nodeID, peer := range status.Peer {
|
||||
if !peer.Active {
|
||||
continue
|
||||
}
|
||||
addresses, found := a.network.NodeAddresses(nodeID)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
duration, _, _, err := a.network.Ping(ctx, addresses[0].Addr())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
durations = append(durations, float64(duration.Microseconds()))
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Float64s(durations)
|
||||
durationsLength := len(durations)
|
||||
if durationsLength == 0 {
|
||||
stats.ConnectionMedianLatencyMS = -1
|
||||
} else if durationsLength%2 == 0 {
|
||||
stats.ConnectionMedianLatencyMS = (durations[durationsLength/2-1] + durations[durationsLength/2]) / 2
|
||||
} else {
|
||||
stats.ConnectionMedianLatencyMS = durations[durationsLength/2]
|
||||
}
|
||||
// Convert from microseconds to milliseconds.
|
||||
stats.ConnectionMedianLatencyMS /= 1000
|
||||
|
||||
select {
|
||||
case a.connStatsChan <- stats:
|
||||
default:
|
||||
a.logger.Warn(ctx, "network stat dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// Report statistics from the created network.
|
||||
cl, err := a.client.ReportStats(ctx, a.logger, a.connStatsChan, func(d time.Duration) {
|
||||
a.network.SetConnStatsCallback(d, 2048,
|
||||
func(_, _ time.Time, virtual, _ map[netlogtype.Connection]netlogtype.Counts) {
|
||||
reportStats(virtual)
|
||||
},
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
} else {
|
||||
if err = a.trackConnGoroutine(func() {
|
||||
// This is OK because the agent never re-creates the tailnet
|
||||
// and the only shutdown indicator is agent.Close().
|
||||
<-a.closed
|
||||
_ = cl.Close()
|
||||
}); err != nil {
|
||||
a.logger.Debug(ctx, "report stats goroutine", slog.Error(err))
|
||||
_ = cl.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isClosed returns whether the API is closed or not.
|
||||
func (a *agent) isClosed() bool {
|
||||
select {
|
||||
@@ -1172,3 +1436,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
|
||||
}
|
||||
|
||||
+305
-81
@@ -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,11 +69,11 @@ 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
|
||||
return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSSH == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
@@ -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,11 +98,51 @@ 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
|
||||
return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountReconnectingPTY == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAgent_Stats_Magic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
conn, _, stats, _ := setupAgent(t, agentsdk.Metadata{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
session.Setenv(agent.MagicSSHSessionTypeEnvironmentVariable, agent.MagicSSHSessionTypeVSCode)
|
||||
defer session.Close()
|
||||
|
||||
command := "sh -c 'echo $" + agent.MagicSSHSessionTypeEnvironmentVariable + "'"
|
||||
expected := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
expected = "%" + agent.MagicSSHSessionTypeEnvironmentVariable + "%"
|
||||
command = "cmd.exe /c echo " + expected
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, strings.TrimSpace(string(output)))
|
||||
var s *agentsdk.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 &&
|
||||
// Ensure that the connection didn't count as a "normal" SSH session.
|
||||
// This was a special one, so it should be labeled specially in the stats!
|
||||
s.SessionCountVSCode == 1 &&
|
||||
// Ensure that connection latency is being counted!
|
||||
// If it isn't, it's set to -1.
|
||||
s.ConnectionMedianLatencyMS >= 0
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
@@ -108,7 +150,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 +163,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 +175,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 +196,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 +206,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 +245,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 +291,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{})
|
||||
@@ -306,7 +346,7 @@ func TestAgent_TCPLocalForwarding(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, remotePort)}, []string{"sleep", "10"})
|
||||
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)
|
||||
|
||||
@@ -373,7 +413,7 @@ func TestAgent_TCPRemoteForwarding(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-R", fmt.Sprintf("127.0.0.1:%d:127.0.0.1:%d", randomPort, localPort)}, []string{"sleep", "10"})
|
||||
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)
|
||||
|
||||
@@ -438,7 +478,7 @@ func TestAgent_UnixLocalForwarding(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-L", fmt.Sprintf("%s:%s", localSocketPath, remoteSocketPath)}, []string{"sleep", "10"})
|
||||
cmd := setupSSHCommand(t, []string{"-L", fmt.Sprintf("%s:%s", localSocketPath, remoteSocketPath)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -496,7 +536,7 @@ func TestAgent_UnixRemoteForwarding(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := setupSSHCommand(t, []string{"-R", fmt.Sprintf("%s:%s", remoteSocketPath, localSocketPath)}, []string{"sleep", "10"})
|
||||
cmd := setupSSHCommand(t, []string{"-R", fmt.Sprintf("%s:%s", remoteSocketPath, localSocketPath)}, []string{"sleep", "5"})
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -531,7 +571,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()
|
||||
@@ -562,7 +603,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()
|
||||
@@ -581,7 +623,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,
|
||||
},
|
||||
@@ -598,7 +640,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",
|
||||
},
|
||||
@@ -625,7 +667,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 + "%"
|
||||
@@ -648,7 +690,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 + "%"
|
||||
@@ -666,7 +708,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
|
||||
@@ -694,6 +737,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" {
|
||||
@@ -706,7 +890,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)
|
||||
@@ -807,7 +992,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)
|
||||
@@ -828,7 +1014,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()
|
||||
@@ -845,12 +1032,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,
|
||||
@@ -885,11 +1072,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()
|
||||
@@ -913,7 +1100,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{}
|
||||
@@ -956,10 +1144,11 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
|
||||
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() {
|
||||
@@ -979,9 +1168,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 {
|
||||
@@ -992,28 +1182,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()
|
||||
@@ -1029,12 +1219,21 @@ func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeo
|
||||
coordinator.ServeClient(serverConn, uuid.New(), agentID)
|
||||
}()
|
||||
sendNode, _ := tailnet.ServeCoordinator(clientConn, func(node []*tailnet.Node) error {
|
||||
return conn.UpdateNodes(node)
|
||||
return conn.UpdateNodes(node, false)
|
||||
})
|
||||
conn.SetNodeCallback(sendNode)
|
||||
return &codersdk.AgentConn{
|
||||
agentConn := &codersdk.WorkspaceAgentConn{
|
||||
Conn: conn,
|
||||
}, statsCh, fs
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = agentConn.Close()
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
if !agentConn.AwaitReachable(ctx) {
|
||||
t.Fatal("agent not reachable")
|
||||
}
|
||||
return agentConn, c, statsCh, fs
|
||||
}
|
||||
|
||||
var dialTestPayload = []byte("dean-was-here123")
|
||||
@@ -1071,17 +1270,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() {
|
||||
@@ -1091,34 +1294,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -1130,11 +1332,33 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 < 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
|
||||
}
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
|
||||
|
||||
// Create socket parent dir if not exists.
|
||||
parentDir := filepath.Dir(addr)
|
||||
err = os.MkdirAll(parentDir, 0700)
|
||||
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),
|
||||
|
||||
+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"
|
||||
|
||||
@@ -78,7 +78,7 @@ func extractTar(t *testing.T, data []byte, directory string) {
|
||||
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,72 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type GitAuthOptions struct {
|
||||
Fetch func(context.Context) ([]codersdk.TemplateVersionGitAuth, error)
|
||||
FetchInterval time.Duration
|
||||
}
|
||||
|
||||
func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
opts.FetchInterval = 500 * time.Millisecond
|
||||
}
|
||||
gitAuth, err := opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
||||
spin.Writer = writer
|
||||
spin.ForceOutput = true
|
||||
spin.Suffix = " Waiting for Git authentication..."
|
||||
defer spin.Stop()
|
||||
|
||||
ticker := time.NewTicker(opts.FetchInterval)
|
||||
defer ticker.Stop()
|
||||
for _, auth := range gitAuth {
|
||||
if auth.Authenticated {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL)
|
||||
|
||||
ticker.Reset(opts.FetchInterval)
|
||||
spin.Start()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
gitAuth, err := opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var authed bool
|
||||
for _, a := range gitAuth {
|
||||
if !a.Authenticated || a.ID != auth.ID {
|
||||
continue
|
||||
}
|
||||
authed = true
|
||||
break
|
||||
}
|
||||
// The user authenticated with the provider!
|
||||
if authed {
|
||||
break
|
||||
}
|
||||
}
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestGitAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var fetched atomic.Bool
|
||||
return cliui.GitAuth(cmd.Context(), cmd.OutOrStdout(), cliui.GitAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
|
||||
defer fetched.Store(true)
|
||||
return []codersdk.TemplateVersionGitAuth{{
|
||||
ID: "github",
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
Authenticated: fetched.Load(),
|
||||
AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"),
|
||||
}}, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatchContext(ctx, "You must authenticate with")
|
||||
ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
|
||||
ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
|
||||
<-done
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+129
-27
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
@@ -17,11 +18,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 +123,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 +143,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 +167,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 +233,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 +260,85 @@ 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())
|
||||
}
|
||||
|
||||
err = cliui.GitAuth(ctx, cmd.OutOrStdout(), cliui.GitAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
|
||||
return client.TemplateVersionGitAuth(ctx, templateVersion.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("template version git auth: %w", err)
|
||||
}
|
||||
|
||||
// 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 +378,8 @@ PromptParamLoop:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parameters, nil
|
||||
return &buildParameters{
|
||||
parameters: legacyParameters,
|
||||
richParameters: richParameters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+372
-1
@@ -3,15 +3,21 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
@@ -87,7 +93,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 +327,343 @@ 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 TestCreateWithGitAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
echoResponses := &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
GitAuthProviders: []string{"github"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
}},
|
||||
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)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
|
||||
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
<-doneChan
|
||||
}
|
||||
|
||||
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
|
||||
return []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
@@ -356,3 +699,31 @@ func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Resp
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
type oauth2Config struct{}
|
||||
|
||||
func (*oauth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
|
||||
return "/?state=" + url.QueryEscape(state)
|
||||
}
|
||||
|
||||
func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: database.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
|
||||
return &oauth2TokenSource{}
|
||||
}
|
||||
|
||||
type oauth2TokenSource struct{}
|
||||
|
||||
func (*oauth2TokenSource) Token() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: database.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+121
-7
@@ -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",
|
||||
@@ -254,6 +259,17 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
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{
|
||||
@@ -289,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",
|
||||
@@ -356,6 +374,20 @@ 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\".",
|
||||
@@ -446,10 +478,19 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
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",
|
||||
@@ -459,7 +500,7 @@ 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,
|
||||
},
|
||||
@@ -471,6 +512,73 @@ func newConfig() *codersdk.DeploymentConfig {
|
||||
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,
|
||||
},
|
||||
Support: &codersdk.SupportConfig{
|
||||
Links: &codersdk.DeploymentConfigField[[]codersdk.LinkConfig]{
|
||||
Name: "Support links",
|
||||
Usage: "Use custom support links",
|
||||
Flag: "support-links",
|
||||
Default: []codersdk.LinkConfig{},
|
||||
Enterprise: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,12 +645,12 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) {
|
||||
// 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))
|
||||
@@ -550,6 +658,10 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) {
|
||||
// Do not bind to CODER_GITAUTH, instead bind to CODER_GITAUTH_0_*, etc.
|
||||
values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value)
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(values))
|
||||
case []codersdk.LinkConfig:
|
||||
// Do not bind to CODER_SUPPORT_LINKS, instead bind to CODER_SUPPORT_LINKS_0_*, etc.
|
||||
values := readSliceFromViper[codersdk.LinkConfig](vip, prefix, value)
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(values))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", value))
|
||||
}
|
||||
@@ -725,6 +837,8 @@ func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target in
|
||||
_ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage)
|
||||
case []string:
|
||||
_ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage)
|
||||
case []codersdk.LinkConfig:
|
||||
// Ignore this one!
|
||||
case []codersdk.GitAuthConfig:
|
||||
// Ignore this one!
|
||||
default:
|
||||
|
||||
@@ -222,6 +222,29 @@ func TestConfig(t *testing.T) {
|
||||
Regex: "gitlab.com",
|
||||
}}, config.GitAuth.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "Support links",
|
||||
Env: map[string]string{
|
||||
"CODER_SUPPORT_LINKS_0_NAME": "First link",
|
||||
"CODER_SUPPORT_LINKS_0_TARGET": "http://target-link-1",
|
||||
"CODER_SUPPORT_LINKS_0_ICON": "bug",
|
||||
|
||||
"CODER_SUPPORT_LINKS_1_NAME": "Second link",
|
||||
"CODER_SUPPORT_LINKS_1_TARGET": "http://target-link-2",
|
||||
"CODER_SUPPORT_LINKS_1_ICON": "chat",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Len(t, config.Support.Links.Value, 2)
|
||||
require.Equal(t, []codersdk.LinkConfig{{
|
||||
Name: "First link",
|
||||
Target: "http://target-link-1",
|
||||
Icon: "bug",
|
||||
}, {
|
||||
Name: "Second link",
|
||||
Target: "http://target-link-2",
|
||||
Icon: "chat",
|
||||
}}, config.Support.Links.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "Wrong env must not break default values",
|
||||
Env: map[string]string{
|
||||
@@ -232,6 +255,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)
|
||||
})
|
||||
}
|
||||
|
||||
+9
-5
@@ -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"
|
||||
)
|
||||
|
||||
@@ -152,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)
|
||||
|
||||
+9
-9
@@ -54,8 +54,8 @@ 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 {
|
||||
@@ -89,8 +89,8 @@ 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 {
|
||||
@@ -107,7 +107,7 @@ func TestLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
|
||||
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())
|
||||
@@ -143,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]
|
||||
@@ -157,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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
+143
-8
@@ -5,16 +5,22 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kirsle/configdir"
|
||||
"github.com/mattn/go-isatty"
|
||||
@@ -28,6 +34,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 +66,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 +85,12 @@ func Core() []*cobra.Command {
|
||||
login(),
|
||||
logout(),
|
||||
parameters(),
|
||||
ping(),
|
||||
portForward(),
|
||||
publickey(),
|
||||
rename(),
|
||||
resetPassword(),
|
||||
restart(),
|
||||
scaletest(),
|
||||
schedules(),
|
||||
show(),
|
||||
@@ -174,6 +181,21 @@ func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextKeyLogger contextKey = iota
|
||||
)
|
||||
|
||||
func ContextWithLogger(ctx context.Context, l slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, contextKeyLogger, l)
|
||||
}
|
||||
|
||||
func LoggerFromContext(ctx context.Context) (slog.Logger, bool) {
|
||||
l, ok := ctx.Value(contextKeyLogger).(slog.Logger)
|
||||
return l, ok
|
||||
}
|
||||
|
||||
// fixUnknownSubcommandError modifies the provided commands so that the
|
||||
// ones with subcommands output the correct error message when an
|
||||
// unknown subcommand is invoked.
|
||||
@@ -210,12 +232,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
|
||||
@@ -319,7 +351,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
|
||||
@@ -332,7 +364,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
|
||||
}
|
||||
@@ -576,7 +608,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
|
||||
}
|
||||
|
||||
@@ -631,3 +663,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)
|
||||
}
|
||||
|
||||
+104
-12
@@ -2,12 +2,14 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -20,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"
|
||||
)
|
||||
@@ -28,14 +32,17 @@ 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{
|
||||
"CODER_CONFIG_DIR": "/tmp/coder-cli-test-config",
|
||||
"HOME": "~",
|
||||
"CODER_CONFIG_DIR": "~/.config/coderv2",
|
||||
}
|
||||
|
||||
rootClient, replacements := prepareTestData(t)
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
cmd []string
|
||||
@@ -50,9 +57,24 @@ func TestCommandHelp(t *testing.T) {
|
||||
name: "coder server --help",
|
||||
cmd: []string{"server", "--help"},
|
||||
env: map[string]string{
|
||||
"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())
|
||||
@@ -99,19 +121,39 @@ ExtractCommandPathsLoop:
|
||||
|
||||
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'})
|
||||
|
||||
// The `coder templates create --help` command prints the path
|
||||
// to the working directory (--directory flag default value).
|
||||
got = bytes.ReplaceAll(got, []byte(wd), []byte("/tmp/coder-cli-test-workdir"))
|
||||
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 {
|
||||
@@ -142,6 +184,56 @@ func extractVisibleCommandPaths(cmdPath []string, cmds []*cobra.Command) [][]str
|
||||
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) {
|
||||
|
||||
+27
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -503,7 +512,14 @@ It is recommended that all rate limits are disabled on the server before running
|
||||
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")
|
||||
@@ -665,7 +681,7 @@ It is recommended that all rate limits are disabled on the server before running
|
||||
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,
|
||||
@@ -792,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())
|
||||
|
||||
+247
-119
@@ -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,7 +116,7 @@ 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
|
||||
@@ -115,18 +130,11 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
}
|
||||
|
||||
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
|
||||
@@ -263,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,
|
||||
@@ -386,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())
|
||||
|
||||
@@ -455,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,
|
||||
@@ -479,6 +485,13 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
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.
|
||||
@@ -541,75 +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,
|
||||
UsernameField: cfg.OIDC.UsernameField.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()
|
||||
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer pingCancel()
|
||||
|
||||
err = sqlDB.PingContext(pingCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the PostgreSQL version is >=13.0.0!
|
||||
version, err := sqlDB.QueryContext(ctx, "SHOW server_version;")
|
||||
if err != nil {
|
||||
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 = 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)
|
||||
@@ -762,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
|
||||
@@ -998,7 +966,8 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
|
||||
postgresBuiltinURLCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
|
||||
postgresBuiltinServeCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
|
||||
|
||||
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd)
|
||||
createAdminUserCommand := newCreateAdminUserCommand()
|
||||
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd, createAdminUserCommand)
|
||||
|
||||
deployment.AttachFlags(root.Flags(), vip, false)
|
||||
|
||||
@@ -1007,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)
|
||||
@@ -1143,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()))
|
||||
}
|
||||
|
||||
@@ -1150,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 {
|
||||
@@ -1171,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,
|
||||
@@ -1207,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.
|
||||
@@ -1362,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()
|
||||
@@ -1494,14 +1475,32 @@ 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1510,3 +1509,132 @@ func redirectHTTPToAccessURL(handler http.Handler, accessURL *url.URL) http.Hand
|
||||
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)
|
||||
}
|
||||
+235
-24
@@ -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)
|
||||
@@ -120,13 +117,15 @@ func TestServer(t *testing.T) {
|
||||
})
|
||||
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.Execute()
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := pty.ReadLine()
|
||||
got := pty.ReadLine(ctx)
|
||||
if !strings.HasPrefix(got, "postgres://") {
|
||||
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
|
||||
}
|
||||
@@ -288,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},
|
||||
@@ -371,6 +365,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -491,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)
|
||||
|
||||
@@ -525,6 +520,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -539,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
|
||||
}{
|
||||
@@ -547,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,
|
||||
@@ -575,6 +589,10 @@ 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"
|
||||
@@ -593,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)
|
||||
@@ -617,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)
|
||||
}
|
||||
@@ -650,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)
|
||||
}
|
||||
@@ -740,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) {
|
||||
@@ -835,6 +861,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1122,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) {
|
||||
|
||||
+23
-15
@@ -19,9 +19,9 @@ import (
|
||||
|
||||
func speedtest() *cobra.Command {
|
||||
var (
|
||||
direct bool
|
||||
duration time.Duration
|
||||
reverse bool
|
||||
direct bool
|
||||
duration time.Duration
|
||||
direction string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -48,10 +48,13 @@ func speedtest() *cobra.Command {
|
||||
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !xerrors.Is(err, cliui.AgentStartError) {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
logger, ok := LoggerFromContext(ctx)
|
||||
if !ok {
|
||||
logger = slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
}
|
||||
if cliflag.IsSetBool(cmd, varVerbose) {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
@@ -71,7 +74,7 @@ func speedtest() *cobra.Command {
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
dur, p2p, err := conn.Ping(ctx)
|
||||
dur, p2p, _, err := conn.Ping(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -94,17 +97,22 @@ func speedtest() *cobra.Command {
|
||||
} else {
|
||||
conn.AwaitReachable(ctx)
|
||||
}
|
||||
dir := tsspeedtest.Download
|
||||
if reverse {
|
||||
dir = tsspeedtest.Upload
|
||||
var tsDir tsspeedtest.Direction
|
||||
switch direction {
|
||||
case "up":
|
||||
tsDir = tsspeedtest.Upload
|
||||
case "down":
|
||||
tsDir = tsspeedtest.Download
|
||||
default:
|
||||
return xerrors.Errorf("invalid direction: %q", direction)
|
||||
}
|
||||
cmd.Printf("Starting a %ds %s test...\n", int(duration.Seconds()), dir)
|
||||
results, err := conn.Speedtest(ctx, dir, duration)
|
||||
cmd.Printf("Starting a %ds %s test...\n", int(duration.Seconds()), tsDir)
|
||||
results, err := conn.Speedtest(ctx, tsDir, duration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableWriter := cliui.Table()
|
||||
tableWriter.AppendHeader(table.Row{"Interval", "Transfer", "Bandwidth"})
|
||||
tableWriter.AppendHeader(table.Row{"Interval", "Throughput"})
|
||||
startTime := results[0].IntervalStart
|
||||
for _, r := range results {
|
||||
if r.Total {
|
||||
@@ -112,7 +120,6 @@ func speedtest() *cobra.Command {
|
||||
}
|
||||
tableWriter.AppendRow(table.Row{
|
||||
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
|
||||
fmt.Sprintf("%.4f MBits", r.MegaBits()),
|
||||
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
|
||||
})
|
||||
}
|
||||
@@ -122,8 +129,9 @@ func speedtest() *cobra.Command {
|
||||
}
|
||||
cliflag.BoolVarP(cmd.Flags(), &direct, "direct", "d", "", false,
|
||||
"Specifies whether to wait for a direct connection before testing speed.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &reverse, "reverse", "r", "", false,
|
||||
"Specifies whether to run in reverse mode where the client receives and the server sends.")
|
||||
cliflag.StringVarP(cmd.Flags(), &direction, "direction", "", "", "down",
|
||||
"Specifies whether to run in reverse mode where the client receives and the server sends. (up|down)",
|
||||
)
|
||||
cmd.Flags().DurationVarP(&duration, "time", "t", tsspeedtest.DefaultDuration,
|
||||
"Specifies the duration to monitor traffic.")
|
||||
return cmd
|
||||
|
||||
+24
-3
@@ -5,38 +5,59 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestSpeedtest(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("Flaky test - see https://github.com/coder/coder/issues/6321")
|
||||
if testing.Short() {
|
||||
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
|
||||
}
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
})
|
||||
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())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
ctx = cli.ContextWithLogger(ctx, slogtest.Make(t, nil).Named("speedtest").Leveled(slog.LevelDebug))
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
+15
-5
@@ -46,6 +46,7 @@ func ssh() *cobra.Command {
|
||||
forwardGPG bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
noWait bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -90,8 +91,15 @@ func ssh() *cobra.Command {
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
||||
},
|
||||
NoWait: noWait,
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return cliui.Canceled
|
||||
}
|
||||
if xerrors.Is(err, cliui.AgentStartError) {
|
||||
return xerrors.New("Agent startup script exited with non-zero status, use --no-wait to login anyway.")
|
||||
}
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
@@ -242,6 +250,7 @@ func ssh() *cobra.Command {
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
||||
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
||||
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noWait, "no-wait", "", "CODER_SSH_NO_WAIT", false, "Specifies whether to wait for a workspace to become ready before logging in (only applicable when the login before ready option has not been enabled). Note that the workspace agent may still be in the process of executing the startup script and the workspace may be in an incomplete state.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -424,9 +433,10 @@ func runRemoteSSH(sshClient *gossh.Client, stdin io.Reader, cmd string) ([]byte,
|
||||
|
||||
stderr := bytes.NewBuffer(nil)
|
||||
sess.Stdin = stdin
|
||||
sess.Stderr = stderr
|
||||
|
||||
out, err := sess.Output(cmd)
|
||||
// On fish, this was outputting to stderr instead of stdout.
|
||||
// The tests pass differently on different Linux machines,
|
||||
// so it's best we capture the output of both.
|
||||
out, err := sess.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
return out, xerrors.Errorf(
|
||||
"`%s` failed: stderr: %s\n\nstdout: %s:\n\n%w",
|
||||
@@ -448,7 +458,7 @@ func uploadGPGKeys(ctx context.Context, sshClient *gossh.Client) error {
|
||||
//
|
||||
// Note: we sleep after killing the agent because it doesn't always die
|
||||
// immediately.
|
||||
agentSocketBytes, err := runRemoteSSH(sshClient, nil, `
|
||||
agentSocketBytes, err := runRemoteSSH(sshClient, nil, `sh -c '
|
||||
set -eux
|
||||
agent_socket=$(gpgconf --list-dir agent-socket)
|
||||
echo "$agent_socket"
|
||||
@@ -460,7 +470,7 @@ if [ -S "$agent_socket" ]; then
|
||||
fi
|
||||
|
||||
test ! -S "$agent_socket"
|
||||
`)
|
||||
'`)
|
||||
agentSocket := strings.TrimSpace(string(agentSocketBytes))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("check if agent socket is running (check if %q exists): %w", agentSocket, err)
|
||||
|
||||
+162
-147
@@ -24,12 +24,15 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"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"
|
||||
@@ -45,6 +48,7 @@ func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.A
|
||||
}
|
||||
}
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
client.Logger = slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -99,7 +103,7 @@ func TestSSH(t *testing.T) {
|
||||
})
|
||||
pty.ExpectMatch("Waiting")
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
@@ -136,7 +140,7 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.ErrorIs(t, err, cliui.Canceled)
|
||||
})
|
||||
pty.ExpectMatch(wantURL)
|
||||
cancel()
|
||||
@@ -148,7 +152,7 @@ func TestSSH(t *testing.T) {
|
||||
_, _ = tGoContext(t, func(ctx context.Context) {
|
||||
// Run this async so the SSH command has to wait for
|
||||
// the build and agent to connect!
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
@@ -215,7 +219,7 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
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,
|
||||
@@ -274,6 +278,10 @@ func TestSSH(t *testing.T) {
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
|
||||
// Wait for the prompt or any output really to indicate the command has
|
||||
// started and accepting input on stdin.
|
||||
_ = pty.Peek(ctx, 1)
|
||||
|
||||
// Ensure that SSH_AUTH_SOCK is set.
|
||||
// Linux: /tmp/auth-agent3167016167/listener.sock
|
||||
// macOS: /var/folders/ng/m1q0wft14hj0t3rtjxrdnzsr0000gn/T/auth-agent3245553419/listener.sock
|
||||
@@ -289,20 +297,24 @@ func TestSSH(t *testing.T) {
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:paralleltest // This test uses t.Setenv.
|
||||
t.Run("ForwardGPG", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// While GPG forwarding from a Windows client works, we currently do
|
||||
// not support forwarding to a Windows workspace. Our tests use the
|
||||
// same platform for the "client" and "workspace" as they run in the
|
||||
// same process.
|
||||
t.Skip("Test not supported on windows")
|
||||
}
|
||||
//nolint:paralleltest // This test uses t.Setenv, parent test MUST NOT be parallel.
|
||||
func TestSSH_ForwardGPG(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// While GPG forwarding from a Windows client works, we currently do
|
||||
// not support forwarding to a Windows workspace. Our tests use the
|
||||
// same platform for the "client" and "workspace" as they run in the
|
||||
// same process.
|
||||
t.Skip("Test not supported on windows")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
// This key is for dean@coder.com.
|
||||
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
||||
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
// This key is for dean@coder.com.
|
||||
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
||||
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBF6SWkEBEADB8sAhBaT36VQ6HEhAmtKexLldu1HUdXNw16rdF+1wiBzSFfJN
|
||||
aPeX4Y9iFIZgC2wU0wOjJ04BpioyOLtJngbThI5WpeoQ/1yQZOpnDaCMPPLp+uJ+
|
||||
@@ -355,40 +367,40 @@ p7KeSZdlk47pMBGOfnvEmoQ=
|
||||
=OxHv
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
gpgPath, err := exec.LookPath("gpg")
|
||||
if err != nil {
|
||||
t.Skip("gpg not found")
|
||||
}
|
||||
gpgConfPath, err := exec.LookPath("gpgconf")
|
||||
if err != nil {
|
||||
t.Skip("gpgconf not found")
|
||||
}
|
||||
gpgAgentPath, err := exec.LookPath("gpg-agent")
|
||||
if err != nil {
|
||||
t.Skip("gpg-agent not found")
|
||||
}
|
||||
gpgPath, err := exec.LookPath("gpg")
|
||||
if err != nil {
|
||||
t.Skip("gpg not found")
|
||||
}
|
||||
gpgConfPath, err := exec.LookPath("gpgconf")
|
||||
if err != nil {
|
||||
t.Skip("gpgconf not found")
|
||||
}
|
||||
gpgAgentPath, err := exec.LookPath("gpg-agent")
|
||||
if err != nil {
|
||||
t.Skip("gpg-agent not found")
|
||||
}
|
||||
|
||||
// Setup GPG home directory on the "client".
|
||||
gnupgHomeClient := tempDirUnixSocket(t)
|
||||
t.Setenv("GNUPGHOME", gnupgHomeClient)
|
||||
// Setup GPG home directory on the "client".
|
||||
gnupgHomeClient := tempDirUnixSocket(t)
|
||||
t.Setenv("GNUPGHOME", gnupgHomeClient)
|
||||
|
||||
// Get the agent extra socket path.
|
||||
var (
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
)
|
||||
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
|
||||
extraSocketPath := strings.TrimSpace(stdout.String())
|
||||
// Get the agent extra socket path.
|
||||
var (
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
)
|
||||
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
|
||||
extraSocketPath := strings.TrimSpace(stdout.String())
|
||||
|
||||
// Generate private key non-interactively.
|
||||
genKeyScript := `
|
||||
// Generate private key non-interactively.
|
||||
genKeyScript := `
|
||||
Key-Type: 1
|
||||
Key-Length: 2048
|
||||
Subkey-Type: 1
|
||||
@@ -398,115 +410,118 @@ Name-Email: test@coder.com
|
||||
Expire-Date: 0
|
||||
%no-protection
|
||||
`
|
||||
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
|
||||
c.Stdin = strings.NewReader(genKeyScript)
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, "generate key failed: %s", out)
|
||||
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
|
||||
c.Stdin = strings.NewReader(genKeyScript)
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, "generate key failed: %s", out)
|
||||
|
||||
// Import a random public key.
|
||||
stdin := strings.NewReader(randPublicKey + "\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import key failed: %s", out)
|
||||
// Import a random public key.
|
||||
stdin := strings.NewReader(randPublicKey + "\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import key failed: %s", out)
|
||||
|
||||
// Set ultimate trust on imported key.
|
||||
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import ownertrust failed: %s", out)
|
||||
// Set ultimate trust on imported key.
|
||||
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import ownertrust failed: %s", out)
|
||||
|
||||
// Start the GPG agent.
|
||||
agentCmd := exec.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
|
||||
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
|
||||
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
|
||||
require.NoError(t, err, "launch agent failed")
|
||||
defer func() {
|
||||
_ = agentProc.Kill()
|
||||
_ = agentPTY.Close()
|
||||
}()
|
||||
// Start the GPG agent.
|
||||
agentCmd := exec.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
|
||||
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
|
||||
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
|
||||
require.NoError(t, err, "launch agent failed")
|
||||
defer func() {
|
||||
_ = agentProc.Kill()
|
||||
_ = agentPTY.Close()
|
||||
}()
|
||||
|
||||
// Get the agent socket path in the "workspace".
|
||||
gnupgHomeWorkspace := tempDirUnixSocket(t)
|
||||
// Get the agent socket path in the "workspace".
|
||||
gnupgHomeWorkspace := tempDirUnixSocket(t)
|
||||
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
|
||||
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
|
||||
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
|
||||
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
|
||||
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
|
||||
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
|
||||
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GNUPGHOME": gnupgHomeWorkspace,
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
"--forward-gpg",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
// Prevent the test from hanging if the asserts below kill the test
|
||||
// early. This will cause the command to exit with an error, which will
|
||||
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
|
||||
// Without this, the test will hang forever on failure, preventing the
|
||||
// real error from being printed.
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pty.WriteLine("echo hello 'world'")
|
||||
pty.ExpectMatch("hello world")
|
||||
|
||||
// Check the GNUPGHOME was correctly inherited via shell.
|
||||
pty.WriteLine("env && echo env-''-command-done")
|
||||
match := pty.ExpectMatch("env--command-done")
|
||||
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
|
||||
|
||||
// Get the agent extra socket path in the "workspace" via shell.
|
||||
pty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
|
||||
pty.ExpectMatch(workspaceAgentSocketPath)
|
||||
pty.ExpectMatch("gpgconf--agentsocket-command-done")
|
||||
|
||||
// List the keys in the "workspace".
|
||||
pty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
|
||||
listKeysOutput := pty.ExpectMatch("gpg--listkeys-command-done")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
|
||||
|
||||
// Try to sign something. This demonstrates that the forwarding is
|
||||
// working as expected, since the workspace doesn't have access to the
|
||||
// private key directly and must use the forwarded agent.
|
||||
pty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
|
||||
pty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
|
||||
pty.ExpectMatch("Hash:")
|
||||
pty.ExpectMatch("hello world")
|
||||
pty.ExpectMatch("gpg--sign-command-done")
|
||||
|
||||
// And we're done.
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GNUPGHOME": gnupgHomeWorkspace,
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
"--forward-gpg",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
tpty := ptytest.New(t)
|
||||
cmd.SetIn(tpty.Input())
|
||||
cmd.SetOut(tpty.Output())
|
||||
cmd.SetErr(tpty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
// Prevent the test from hanging if the asserts below kill the test
|
||||
// early. This will cause the command to exit with an error, which will
|
||||
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
|
||||
// Without this, the test will hang forever on failure, preventing the
|
||||
// real error from being printed.
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Wait for the prompt or any output really to indicate the command has
|
||||
// started and accepting input on stdin.
|
||||
_ = tpty.Peek(ctx, 1)
|
||||
|
||||
tpty.WriteLine("echo hello 'world'")
|
||||
tpty.ExpectMatch("hello world")
|
||||
|
||||
// Check the GNUPGHOME was correctly inherited via shell.
|
||||
tpty.WriteLine("env && echo env-''-command-done")
|
||||
match := tpty.ExpectMatch("env--command-done")
|
||||
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
|
||||
|
||||
// Get the agent extra socket path in the "workspace" via shell.
|
||||
tpty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
|
||||
tpty.ExpectMatch(workspaceAgentSocketPath)
|
||||
tpty.ExpectMatch("gpgconf--agentsocket-command-done")
|
||||
|
||||
// List the keys in the "workspace".
|
||||
tpty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
|
||||
listKeysOutput := tpty.ExpectMatch("gpg--listkeys-command-done")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
|
||||
|
||||
// Try to sign something. This demonstrates that the forwarding is
|
||||
// working as expected, since the workspace doesn't have access to the
|
||||
// private key directly and must use the forwarded agent.
|
||||
tpty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
|
||||
tpty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
|
||||
tpty.ExpectMatch("Hash:")
|
||||
tpty.ExpectMatch("hello world")
|
||||
tpty.ExpectMatch("gpg--sign-command-done")
|
||||
|
||||
// And we're done.
|
||||
tpty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
}
|
||||
|
||||
// tGoContext runs fn in a goroutine passing a context that will be
|
||||
|
||||
+7
-5
@@ -27,8 +27,9 @@ func state() *cobra.Command {
|
||||
func statePull() *cobra.Command {
|
||||
var buildNumber int
|
||||
cmd := &cobra.Command{
|
||||
Use: "pull <workspace> [file]",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Use: "pull <workspace> [file]",
|
||||
Short: "Pull a Terraform state file from a workspace.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
@@ -58,7 +59,7 @@ func statePull() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.WriteFile(args[1], state, 0600)
|
||||
return os.WriteFile(args[1], state, 0o600)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&buildNumber, "build", "b", 0, "Specify a workspace build to target by name.")
|
||||
@@ -68,8 +69,9 @@ func statePull() *cobra.Command {
|
||||
func statePush() *cobra.Command {
|
||||
var buildNumber int
|
||||
cmd := &cobra.Command{
|
||||
Use: "push <workspace> <file>",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "push <workspace> <file>",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Push a Terraform state file to a workspace.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
|
||||
+44
-45
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -19,16 +18,18 @@ import (
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
)
|
||||
|
||||
func templateCreate() *cobra.Command {
|
||||
var (
|
||||
directory string
|
||||
provisioner string
|
||||
provisionerTags []string
|
||||
parameterFile string
|
||||
variablesFile string
|
||||
variables []string
|
||||
defaultTTL time.Duration
|
||||
|
||||
uploadFlags templateUploadFlags
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create [name]",
|
||||
@@ -45,11 +46,9 @@ func templateCreate() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
var templateName string
|
||||
if len(args) == 0 {
|
||||
templateName = filepath.Base(directory)
|
||||
} else {
|
||||
templateName = args[0]
|
||||
templateName, err := uploadFlags.templateName(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(templateName) > 31 {
|
||||
@@ -62,32 +61,11 @@ func templateCreate() *cobra.Command {
|
||||
}
|
||||
|
||||
// Confirm upload of the directory.
|
||||
prettyDir := prettyDirectoryPath(directory)
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Create and upload %q?", prettyDir),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmYes,
|
||||
})
|
||||
resp, err := uploadFlags.upload(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...")
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
tags, err := ParseProvisionerTags(provisionerTags)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -100,17 +78,21 @@ func templateCreate() *cobra.Command {
|
||||
FileID: resp.ID,
|
||||
ParameterFile: parameterFile,
|
||||
ProvisionerTags: tags,
|
||||
VariablesFile: variablesFile,
|
||||
Variables: variables,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
if !uploadFlags.stdin() {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
createReq := codersdk.CreateTemplateRequest{
|
||||
@@ -134,12 +116,13 @@ func templateCreate() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
currentDirectory, _ := os.Getwd()
|
||||
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
|
||||
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
||||
cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
|
||||
cmd.Flags().StringVarP(&variablesFile, "variables-file", "", "", "Specify a file path with values for Terraform-managed variables.")
|
||||
cmd.Flags().StringArrayVarP(&variables, "variable", "", []string{}, "Specify a set of values for Terraform-managed variables.")
|
||||
cmd.Flags().StringArrayVarP(&provisionerTags, "provisioner-tag", "", []string{}, "Specify a set of tags to target provisioner daemons.")
|
||||
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 24*time.Hour, "Specify a default TTL for workspaces created from this template.")
|
||||
uploadFlags.register(cmd.Flags())
|
||||
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
||||
// This is for testing!
|
||||
err := cmd.Flags().MarkHidden("test.provisioner")
|
||||
if err != nil {
|
||||
@@ -156,6 +139,10 @@ type createValidTemplateVersionArgs struct {
|
||||
Provisioner database.ProvisionerType
|
||||
FileID uuid.UUID
|
||||
ParameterFile string
|
||||
|
||||
VariablesFile string
|
||||
Variables []string
|
||||
|
||||
// Template is only required if updating a template's active version.
|
||||
Template *codersdk.Template
|
||||
// ReuseParameters will attempt to reuse params from the Template field
|
||||
@@ -168,13 +155,25 @@ type createValidTemplateVersionArgs struct {
|
||||
func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVersionArgs, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
||||
client := args.Client
|
||||
|
||||
variableValues, err := loadVariableValuesFromFile(args.VariablesFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
variableValuesFromKeyValues, err := loadVariableValuesFromOptions(args.Variables)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
variableValues = append(variableValues, variableValuesFromKeyValues...)
|
||||
|
||||
req := codersdk.CreateTemplateVersionRequest{
|
||||
Name: args.Name,
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
FileID: args.FileID,
|
||||
Provisioner: codersdk.ProvisionerType(args.Provisioner),
|
||||
ParameterValues: parameters,
|
||||
ProvisionerTags: args.ProvisionerTags,
|
||||
Name: args.Name,
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
FileID: args.FileID,
|
||||
Provisioner: codersdk.ProvisionerType(args.Provisioner),
|
||||
ParameterValues: parameters,
|
||||
ProvisionerTags: args.ProvisionerTags,
|
||||
UserVariableValues: variableValues,
|
||||
}
|
||||
if args.Template != nil {
|
||||
req.TemplateID = args.Template.ID
|
||||
|
||||
+192
-4
@@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -69,7 +70,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Upload", write: "yes"},
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
@@ -84,6 +85,38 @@ func TestTemplateCreate(t *testing.T) {
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("CreateStdin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source, err := echo.Tar(&echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: provisionCompleteWithAgent,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
args := []string{
|
||||
"templates",
|
||||
"create",
|
||||
"my-template",
|
||||
"--directory", "-",
|
||||
"--test.provisioner", string(database.ProvisionerTypeEcho),
|
||||
"--default-ttl", "24h",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(bytes.NewReader(source))
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -108,7 +141,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Upload", write: "yes"},
|
||||
{match: "Enter a value:", write: "bananas"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
@@ -148,7 +181,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Upload", write: "yes"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
@@ -188,7 +221,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
write string
|
||||
}{
|
||||
{
|
||||
match: "Create and upload",
|
||||
match: "Upload",
|
||||
write: "yes",
|
||||
},
|
||||
{
|
||||
@@ -266,6 +299,161 @@ func TestTemplateCreate(t *testing.T) {
|
||||
|
||||
require.EqualError(t, <-execDone, "Template name must be less than 32 characters")
|
||||
})
|
||||
|
||||
t.Run("WithVariablesFileWithoutRequiredValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "first_variable",
|
||||
Description: "This is the first variable",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Name: "second_variable",
|
||||
Description: "This is the first variable",
|
||||
Type: "string",
|
||||
DefaultValue: "abc",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
},
|
||||
}
|
||||
source := clitest.CreateTemplateVersionSource(t,
|
||||
createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
variablesFile, _ := os.CreateTemp(tempDir, "variables*.yaml")
|
||||
_, _ = variablesFile.WriteString(`second_variable: foobar`)
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--variables-file", variablesFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
|
||||
require.Error(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("WithVariablesFileWithTheRequiredValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "first_variable",
|
||||
Description: "This is the first variable",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Name: "second_variable",
|
||||
Description: "This is the second variable",
|
||||
Type: "string",
|
||||
DefaultValue: "abc",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
},
|
||||
}
|
||||
source := clitest.CreateTemplateVersionSource(t,
|
||||
createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
variablesFile, _ := os.CreateTemp(tempDir, "variables*.yaml")
|
||||
_, _ = variablesFile.WriteString(`first_variable: foobar`)
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--variables-file", variablesFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
t.Run("WithVariableOption", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "first_variable",
|
||||
Description: "This is the first variable",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
},
|
||||
}
|
||||
source := clitest.CreateTemplateVersionSource(t,
|
||||
createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--variable", "first_variable=foobar")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
}
|
||||
|
||||
func createTestParseResponse() []*proto.Parse_Response {
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -66,11 +67,11 @@ func templateInit() *cobra.Command {
|
||||
relPath = "./" + relPath
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Extracting %s to %s...\n", cliui.Styles.Field.Render(selectedTemplate.ID), relPath)
|
||||
err = os.MkdirAll(directory, 0700)
|
||||
err = os.MkdirAll(directory, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = provisionersdk.Untar(directory, archive)
|
||||
err = provisionersdk.Untar(directory, bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+10
-5
@@ -5,12 +5,16 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func templateList() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]templateTableRow{}, []string{"name", "last updated", "used by"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all the templates available for the organization",
|
||||
@@ -35,7 +39,8 @@ func templateList() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
out, err := displayTemplates(columns, templates...)
|
||||
rows := templatesToRows(templates...)
|
||||
out, err := formatter.Format(cmd.Context(), rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -44,7 +49,7 @@ func templateList() *cobra.Command {
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "last_updated", "used_by"},
|
||||
"Specify a column to filter in the table.")
|
||||
|
||||
formatter.AttachFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user