Compare commits
558 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95e578ba10 | |||
| 861d4afdd8 | |||
| bc18f6c113 | |||
| 4ee01dc95c | |||
| 55c0b26977 | |||
| 8e69f02695 | |||
| ded931f0f6 | |||
| 2d051094e7 | |||
| aa43f998d4 | |||
| fab8da633b | |||
| 333718d1fa | |||
| 633dfbdb2e | |||
| d9d44c1188 | |||
| 7738274b3e | |||
| f4d16a1ae5 | |||
| 47afafa4d4 | |||
| a29fc7dd6f | |||
| f6f927e44f | |||
| 8683169e71 | |||
| d2bfa2b9a0 | |||
| 390f29cf8c | |||
| caec0b8aae | |||
| 31690c4b3d | |||
| 512fdbf634 | |||
| e40b0778e9 | |||
| e60460b120 | |||
| 5200591264 | |||
| 87d64baf7a | |||
| 34debbf837 | |||
| ccadd0f286 | |||
| 84956c16cc | |||
| ca4fa81570 | |||
| c191692751 | |||
| c2a96bdc7c | |||
| 599699b3a9 | |||
| 96ff400587 | |||
| ce51435507 | |||
| 27e17ff2c3 | |||
| cd807bc0c8 | |||
| 334d9820fa | |||
| 90e2bab078 | |||
| 901045a95f | |||
| a364318462 | |||
| 56f00a82e1 | |||
| 2612e32bac | |||
| 3b52d4f336 | |||
| b5f5740d0b | |||
| e496bdb687 | |||
| a63c97b8de | |||
| 5780006adb | |||
| afcc179244 | |||
| 8f55f5c28b | |||
| 5953a46785 | |||
| a6b7e8c43a | |||
| 04e404e448 | |||
| 5686fc9983 | |||
| 401b9276ae | |||
| 5c1dc1b7fe | |||
| e470162305 | |||
| 1f600fc526 | |||
| b26f30688f | |||
| 6378294071 | |||
| 0ba200c2a1 | |||
| 665b84de0d | |||
| a07209efa1 | |||
| 7d7aa789b3 | |||
| d8762c676f | |||
| b120247213 | |||
| 563c3ade06 | |||
| 6981f89cd8 | |||
| 58d650c2bb | |||
| 1c7adc0ebd | |||
| 3d91fe8895 | |||
| 90da09bc2c | |||
| 872037bf85 | |||
| 175dde1c52 | |||
| 90d18dd2e5 | |||
| 349bfad2e9 | |||
| 311327cb11 | |||
| a8346bd8ea | |||
| 1176256a44 | |||
| b38d1ed4a5 | |||
| 891bbda995 | |||
| 164528176a | |||
| 773580c7c9 | |||
| 42b3d90221 | |||
| f88f273cd6 | |||
| a2d3635207 | |||
| 5e01e6e448 | |||
| 48f9521fcb | |||
| ad0c0df104 | |||
| ffb4cd5962 | |||
| dd4e1f74ff | |||
| b439c3e167 | |||
| 7fa5afa268 | |||
| fc21e159b8 | |||
| 08afe3cfad | |||
| 0b22c88538 | |||
| 8187992e7f | |||
| e0cc4ee7f8 | |||
| e3a965bcc9 | |||
| b287ec5eec | |||
| 6c0f37c28e | |||
| 76bdde7f1b | |||
| d7d210de36 | |||
| 622fc6d9c2 | |||
| c9cbc63cd4 | |||
| c7fb5f960c | |||
| 9822745365 | |||
| 2bd6d2908e | |||
| b71b8daa21 | |||
| 2383f64d89 | |||
| 88e24db643 | |||
| cb7375450b | |||
| a6fa8cac58 | |||
| 8857971552 | |||
| ed9a3b9251 | |||
| 7949db8e03 | |||
| eaacc26da7 | |||
| dab4a0e6ef | |||
| 25e92fd2f4 | |||
| df31636e72 | |||
| 79ae7cd639 | |||
| c1e1c47c45 | |||
| c71fa498b5 | |||
| 250ee17933 | |||
| 1ccbd54ea2 | |||
| f1d7809ef0 | |||
| ad9c9b468f | |||
| ab764db8c8 | |||
| 5460ab4ba6 | |||
| e85a17b0c8 | |||
| 38d278ac46 | |||
| abe1e89f80 | |||
| 5cbe360176 | |||
| 00860cf1c8 | |||
| 120bc4b750 | |||
| 7e854adbb3 | |||
| 71eecb3515 | |||
| 74be9c6c55 | |||
| 97f77c4507 | |||
| d8aee26776 | |||
| e1c755be81 | |||
| aaa3b31a0b | |||
| d05b48267a | |||
| 29d71bb3dd | |||
| f97c22540a | |||
| 247470b1d6 | |||
| 1b35ac80f2 | |||
| fce8a4adf0 | |||
| 2321160c62 | |||
| 8aae0b64d3 | |||
| 4bf012cefb | |||
| 65945aef16 | |||
| cb846bab46 | |||
| de83723310 | |||
| a4d86e9d78 | |||
| 331a49bf75 | |||
| 39510f4163 | |||
| fadeb2ba3a | |||
| 856a8028a5 | |||
| c3fb1b325f | |||
| ca067cf004 | |||
| 090e37fc46 | |||
| 5b07f1e2a3 | |||
| db40c29f26 | |||
| e6d52b07b7 | |||
| e55d921537 | |||
| b1c1e1a8a6 | |||
| 01a6af98b4 | |||
| 446bd30c32 | |||
| f59bf732c9 | |||
| 1c05b46b02 | |||
| ffca3a5fb3 | |||
| a1d2c057a2 | |||
| fe247c86eb | |||
| 25e8abd63e | |||
| b693b9f599 | |||
| 1f9ae15409 | |||
| 95177ad0e5 | |||
| ab90651a7e | |||
| 811a69f371 | |||
| af618477bd | |||
| 107ae55642 | |||
| 854bcce5e0 | |||
| a7c734c60b | |||
| 7076dee522 | |||
| 2f3848e9b2 | |||
| c5475912c9 | |||
| a0e096bcfe | |||
| 3cf235c564 | |||
| f91b3acf93 | |||
| 17bc5794d4 | |||
| e3768495e4 | |||
| b806d1cfcf | |||
| 348530000f | |||
| 7587850a1c | |||
| bc26c4a27f | |||
| aafd2803bb | |||
| 35df1b10d0 | |||
| 813b54942f | |||
| 9b2abf0952 | |||
| 179d9e0d24 | |||
| 7fa6483d84 | |||
| 37c859ec4c | |||
| a65a16122d | |||
| 144f374f60 | |||
| 1db2b12b8e | |||
| 7eb2c2ff6d | |||
| a8433b18e4 | |||
| 4a07fcd9d2 | |||
| 8b125d6c5d | |||
| a666539bfa | |||
| 90901ca129 | |||
| 6023264a81 | |||
| 7f25d31745 | |||
| 11a930e779 | |||
| 65878b04ce | |||
| 50432b89be | |||
| 1b3b0ea962 | |||
| 9a7998301b | |||
| 4c2977050d | |||
| 62a64d5a34 | |||
| 761ed7bf63 | |||
| 2abae42cec | |||
| 3de29307b5 | |||
| c2787e3a8e | |||
| 70b093ff2a | |||
| 1cc10f2ffb | |||
| 1199a9330a | |||
| a78786119d | |||
| 5304b4e483 | |||
| 9d40d2ffdc | |||
| db2bdd1cab | |||
| d67552f852 | |||
| 54bbed8c3c | |||
| 7df1e3bdd6 | |||
| 95626d2076 | |||
| 3b87316ad7 | |||
| 524b14adbc | |||
| 26a725f86d | |||
| 89008125c0 | |||
| fe10ba1157 | |||
| 3b73321a6c | |||
| bb0a996fc2 | |||
| 1bdd2abed7 | |||
| f8494d2bac | |||
| f287889cd7 | |||
| 4c204fc348 | |||
| 23bebb40e2 | |||
| 2d4706ac33 | |||
| f19076cf06 | |||
| ef2e86f309 | |||
| 87ed7a7dba | |||
| 66a6b590a1 | |||
| 248c53d68d | |||
| 0c2b432c1b | |||
| fd02f73708 | |||
| 74632e460c | |||
| 29ced72cda | |||
| 034641dc77 | |||
| 7a8ccda40e | |||
| 964032d783 | |||
| 09f87d1df1 | |||
| cf75d7e1fb | |||
| e0137bcff1 | |||
| d8abe37cd7 | |||
| 8a6635bf5f | |||
| 22e3ff96be | |||
| 02100c64b5 | |||
| 136f23fb4c | |||
| 84dd59ecc2 | |||
| 260b2b2333 | |||
| a3201bd658 | |||
| 1483b42259 | |||
| e78c272a72 | |||
| 02f0968b33 | |||
| 573a8d5717 | |||
| 973df199b0 | |||
| 3cb9b3de24 | |||
| 175a41567e | |||
| 25b05ed8a4 | |||
| 71d1e63af0 | |||
| 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 |
@@ -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,3 +0,0 @@
|
||||
<!--
|
||||
Check if your change requires documentation edits before merging: https://coder.com/docs/coder. Make edits in `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@v4
|
||||
with:
|
||||
go-version: "~1.20"
|
||||
|
||||
# Check for any typos!
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.14.3
|
||||
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:
|
||||
@@ -191,9 +150,9 @@ jobs:
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -215,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Install sqlc
|
||||
run: |
|
||||
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
|
||||
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.17.2/sqlc_1.17.2_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
|
||||
@@ -227,8 +186,9 @@ jobs:
|
||||
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
# protoc must be in lockstep with our dogfood Dockerfile
|
||||
# or the version in the comments will differ.
|
||||
# protoc must be in lockstep with our dogfood Dockerfile or the
|
||||
# version in the comments will differ. This is also defined in
|
||||
# security.yaml
|
||||
set -x
|
||||
cd dogfood
|
||||
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
|
||||
@@ -243,8 +203,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 +239,7 @@ jobs:
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
test-go:
|
||||
name: "test/go"
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -292,18 +250,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
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=GOCACHE::$(go env GOCACHE)"
|
||||
echo "::set-output name=GOMODCACHE::$(go env GOMODCACHE)"
|
||||
echo "GOCACHE=$(go env GOCACHE)" >> ${{ runner.os == 'Windows' && '$env:' || '$' }}GITHUB_OUTPUT
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> ${{ runner.os == 'Windows' && '$env:' || '$' }}GITHUB_OUTPUT
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
@@ -318,7 +273,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
uses: jaxxstorm/action-install-gh-release@v1.10.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -344,7 +299,14 @@ jobs:
|
||||
echo "cover=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=5m -short -failfast $COVERAGE_FLAGS
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" --packages="./..." -- -parallel=8 -timeout=7m -short -failfast $COVERAGE_FLAGS
|
||||
|
||||
- name: Print test stats
|
||||
if: success() || failure()
|
||||
run: |
|
||||
# Artifacts are not available after rerunning a job,
|
||||
# so we need to print the test stats to the log.
|
||||
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
@@ -365,9 +327,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
|
||||
@@ -376,9 +337,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -399,7 +360,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
uses: jaxxstorm/action-install-gh-release@v1.10.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -415,6 +376,13 @@ jobs:
|
||||
run: |
|
||||
make test-postgres
|
||||
|
||||
- name: Print test stats
|
||||
if: success() || failure()
|
||||
run: |
|
||||
# Artifacts are not available after rerunning a job,
|
||||
# so we need to print the test stats to the log.
|
||||
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
@@ -436,7 +404,7 @@ jobs:
|
||||
|
||||
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: |
|
||||
@@ -459,9 +427,9 @@ jobs:
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v1
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -532,8 +500,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
|
||||
@@ -551,12 +518,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:ci
|
||||
- run: yarn test:ci --max-workers ${{ steps.cpu-cores.outputs.count }}
|
||||
working-directory: site
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
@@ -572,16 +539,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
|
||||
|
||||
@@ -594,9 +556,9 @@ jobs:
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-e2e-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -605,7 +567,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "16.16.0"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -633,9 +595,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
|
||||
@@ -662,6 +621,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
|
||||
|
||||
@@ -693,23 +656,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,8 +1,13 @@
|
||||
name: Pull Request
|
||||
name: contrib
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- closed
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
- opened
|
||||
@@ -13,23 +18,52 @@ on:
|
||||
concurrency: pr-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
lint-title:
|
||||
name: Lint title
|
||||
# Dependabot is annoying, but this makes it a bit less so.
|
||||
auto-approve-dependabot:
|
||||
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:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- 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:
|
||||
name: Release labels
|
||||
runs-on: ubuntu-latest
|
||||
# Depend on lint so that title is Conventional Commits-compatible.
|
||||
needs: [lint-title]
|
||||
needs: [title]
|
||||
# Skip tagging for draft PRs.
|
||||
if: ${{ success() && !github.event.pull_request.draft }}
|
||||
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +32,7 @@ env:
|
||||
jobs:
|
||||
release:
|
||||
name: Build and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
|
||||
- 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"
|
||||
@@ -89,9 +90,9 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
@@ -112,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: |
|
||||
@@ -160,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
|
||||
@@ -188,6 +252,8 @@ 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
|
||||
@@ -214,7 +280,7 @@ jobs:
|
||||
./build/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.CODER_GPG_RELEASE_KEY_BASE64 }}
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v1
|
||||
@@ -236,10 +302,11 @@ jobs:
|
||||
helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml
|
||||
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
|
||||
gsutil -h "Cache-Control:no-cache,max-age=0" cp helm/artifacthub-repo.yml gs://helm.coder.com/v2
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -252,6 +319,15 @@ jobs:
|
||||
./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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Security"
|
||||
name: "security"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -6,17 +6,11 @@ permissions:
|
||||
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"
|
||||
# Run every 6 hours Monday-Friday!
|
||||
- cron: "0 0,6,12,18 * * 1-5"
|
||||
|
||||
# Cancel in-progress runs for pull requests when developers push
|
||||
# additional changes
|
||||
@@ -26,36 +20,26 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["go", "javascript"]
|
||||
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
languages: go, javascript
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Go Cache Paths
|
||||
if: matrix.language == 'go'
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Go Mod Cache
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
|
||||
@@ -63,26 +47,33 @@ jobs:
|
||||
|
||||
# Workaround to prevent CodeQL from building the dashboard.
|
||||
- name: Remove Makefile
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
msg="❌ CodeQL Failed\n\nhttps://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
curl \
|
||||
-qfsSL \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"content\": \"$msg\"}" \
|
||||
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
|
||||
|
||||
trivy:
|
||||
name: Trivy
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
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
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
go-version: "~1.20"
|
||||
|
||||
- name: Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -106,16 +97,47 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.30.6
|
||||
- name: Install protoc-gen-go
|
||||
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- name: Install protoc-gen-go-drpc
|
||||
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
# protoc must be in lockstep with our dogfood Dockerfile or the
|
||||
# version in the comments will differ. This is also defined in
|
||||
# ci.yaml.
|
||||
set -x
|
||||
cd dogfood
|
||||
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
|
||||
protoc_path=/usr/local/bin/protoc
|
||||
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
|
||||
chmod +x $protoc_path
|
||||
protoc --version
|
||||
|
||||
- name: Build Coder linux amd64 Docker image
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image_job="build/coder_$(./scripts/version.sh)_linux_amd64.tag"
|
||||
DOCKER_IMAGE_NO_PREREQUISITES=true make -j "$image_job"
|
||||
|
||||
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@7b7aa264d83dc58691451798b4d117d53d21edfe
|
||||
uses: aquasecurity/trivy-action@1f0aa582c8c8f5f7639610d6d38baddfea4fdcee
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
@@ -126,10 +148,22 @@ jobs:
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
retention-days: 7
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
msg="❌ CodeQL Failed\n\nhttps://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
curl \
|
||||
-qfsSL \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"content\": \"$msg\"}" \
|
||||
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
|
||||
|
||||
@@ -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
|
||||
- uses: actions/stale@v8.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"
|
||||
@@ -22,4 +24,5 @@ extend-exclude = [
|
||||
# These files contain base64 strings that confuse the detector
|
||||
"**XService**.ts",
|
||||
"**identity.go",
|
||||
"scripts/ci-report/testdata/**",
|
||||
]
|
||||
|
||||
@@ -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! 👀👀👀
|
||||
+5
-3
@@ -6,7 +6,8 @@
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
gotests_stats.json
|
||||
gotests.json
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
@@ -27,9 +28,10 @@ site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/states/*.json
|
||||
site/playwright-report/*
|
||||
site/.swc
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
# Make target for updating golden files (any dir).
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
|
||||
@@ -215,7 +215,6 @@ linters:
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- dogsled
|
||||
- errcheck
|
||||
- errname
|
||||
@@ -259,4 +258,3 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
|
||||
+5
-3
@@ -9,7 +9,8 @@
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
gotests_stats.json
|
||||
gotests.json
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
@@ -30,9 +31,10 @@ site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/states/*.json
|
||||
site/playwright-report/*
|
||||
site/.swc
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
# Make target for updating golden files (any dir).
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
|
||||
Vendored
+8
-1
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
@@ -88,7 +90,6 @@
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promhttp",
|
||||
"promptui",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
"provisionerdserver",
|
||||
@@ -112,6 +113,7 @@
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tanstack",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
@@ -133,6 +135,7 @@
|
||||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tokenconfig",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
@@ -183,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",
|
||||
|
||||
@@ -368,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
|
||||
@@ -418,6 +424,7 @@ 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 \
|
||||
@@ -437,6 +444,7 @@ 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 \
|
||||
@@ -492,6 +500,11 @@ docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/me
|
||||
cd site
|
||||
yarn run format:write:only ../docs/admin/prometheus.md
|
||||
|
||||
docs/cli.md: scripts/clidocgen/main.go $(GO_SRC_FILES) docs/manifest.json
|
||||
BASE_PATH="." go run ./scripts/clidocgen
|
||||
cd site
|
||||
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
|
||||
@@ -501,13 +514,21 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
|
||||
./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
|
||||
update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(GO_SRC_FILES)
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES)
|
||||
go test ./cli -run=TestCommandHelp -update
|
||||
touch "$@"
|
||||
|
||||
helm/tests/testdata/.gen-golden: $(wildcard helm/tests/testdata/*.golden) $(GO_SRC_FILES)
|
||||
go test ./helm/tests -run=TestUpdateGoldenFiles -update
|
||||
touch "$@"
|
||||
|
||||
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
|
||||
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
|
||||
touch "$@"
|
||||
|
||||
# Generate a prettierrc for the site package that uses relative paths for
|
||||
# overrides. This allows us to share the same prettier config between the
|
||||
# site and the root of the repo.
|
||||
@@ -579,6 +600,7 @@ 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="gotests.json" \
|
||||
--packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
|
||||
-parallel=4 \
|
||||
@@ -603,7 +625,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,49 +1,74 @@
|
||||
# Coder — Your Self-Hosted Remote Development Platform
|
||||
<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://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder)
|
||||
[](./LICENSE)
|
||||
|
||||
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>
|
||||
|
||||
## Highlights
|
||||
## Quickstart
|
||||
|
||||
- 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
|
||||
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).
|
||||
|
||||
## Getting Started
|
||||
```
|
||||
# First, install Coder
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
|
||||
# Start the Coder server (caches data in ~/.cache/coder)
|
||||
coder server
|
||||
|
||||
# Navigate to http://localhost:3000 to create your initial user
|
||||
# Create a Docker template, and provision a workspace
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -57,49 +82,44 @@ coder server
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
> <sup>1</sup> The automatic setup 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/v2/latest/quickstart) for a full walkthrough.
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/guides) for a full walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit our docs [here](https://coder.com/docs/v2).
|
||||
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
|
||||
|
||||
## Templates
|
||||
|
||||
Find our templates [here](./examples/templates).
|
||||
- [**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
|
||||
|
||||
Read the [contributing docs](https://coder.com/docs/v2/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).
|
||||
|
||||
## Comparison
|
||||
## Related
|
||||
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to:
|
||||
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.
|
||||
|
||||
- [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).
|
||||
### Official
|
||||
|
||||
| Tool | Type | Delivery Model | Cost | Internet Access Required | Latency and Data Sovereignty | Security isolation model | Product quality | Service Availability | Environments | IDE |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Unopinionated (whatever/wherever you choose to deploy thus 100% configurable) | [Defect history](https://github.com/coder/coder/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-Hosted | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 | Anything (vim, emacs, theia, code-server, openvscode-server, entire jetbrains suite inc gateway remote development, visual studio code desktop, visual studio for mac, visual studio for windows) you choose to install and deploy |
|
||||
| [code-server](https://coder.com/blog/code-server-multiple-users?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/coder/code-server/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64, arm64 | [code-server](https://github.com/coder/code-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [openvscode-server](https://github.com/gitpod-io/openvscode-server) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/gitpod-io/openvscode-server) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64 | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [Amazon CodeCatalyst](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Pay AWS | Yes | US West (Oregon) | ["all customer multi-tenancy isolation is done through virtual machines" for security reasons](https://devclass.com/2022/12/05/interview-why-aws-prefers-vms-for-code-isolation-and-tips-on-developing-for-lambda/) | N/A | [Service Health](https://health.aws.amazon.com/health/status) | Linux Virtual Machines | Cloud9, Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [CodeAnywhere](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Per user | Yes | N/A | N/A | N/A | N/A | N/A | Theia |
|
||||
| [GitHub Codespaces](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | 2x Azure Compute | Yes | Four regions (US West, US East, Europe West, Southeast Asia) | ["two codespaces are never co-located on the same VM"](https://docs.github.com/en/codespaces/codespaces-reference/security-in-github-codespaces) | N/A | [Incident History](https://www.githubstatus.com/history) | Linux Virtual Machines, [GPUs supported](https://docs.github.com/en/codespaces/developing-in-codespaces/getting-started-with-github-codespaces-for-machine-learning) | Visual Studio Code ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [Gitpod](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | [SaaS](https://news.ycombinator.com/item?id=33907897) | [Credits](https://www.gitpod.io/pricing) | Yes | Two regions (Europe, US) | [All customers intermixed on the same machine isolated via runc](https://kinvolk.io/blog/2020/12/improving-kubernetes-and-container-security-with-user-namespaces/) | [Defect history](https://github.com/gitpod-io/gitpod/issues?q=is%3Aissue+label%3A%22type%3A+bug%22+sort%3Aupdated-desc+) | [Incident history](https://www.gitpodstatus.com/history) | Basic Linux containers, [GPUs](https://github.com/gitpod-io/gitpod/issues/10650) and [kubernetes/k3s](https://github.com/gitpod-io/gitpod/issues/4889) is not yet possible | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) inhibiting functionality of [.NET](https://www.isdotnetopen.com), [Python](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx), [C](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [C++](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [Jupyter](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx) and usage of [GitHub Co-pilot](https://github.com/gitpod-io/gitpod/issues/10032). Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway supported |
|
||||
| [Google Cloud Workstations](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Google | Yes | southamerica-west1, us-east1, us-central1, us-west1, asia-east1, asia-southeast1, europe-north1, europe-southwest1, europe-west1, europe-west2, europe-west3, europe-west4 | N/A | N/A | Not generally available, offered in preview mode. | Linux | code-oss ([with restrictions](https://ghuntley.com/fracture)), Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [JetBrains Space](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS + On-Prem ([Dev environments are not supported](https://www.jetbrains.com/help/space-on-premises/space-on-premises-installation.html)) | Pay JetBrains | Yes | EU Ireland region (eu-west-1) | EC2 | N/A | [Service Health](https://status.jetbrains.space/) | Linux Virtual Machines | JetBrains Suite |
|
||||
| [Microsoft DevBox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Microsoft | Yes | Australia East, Europe West, Japan East, Canada Central, UK South, US East, US East 2, US South Central, and US West 3 | Microsoft Azure Virtual Machine | N/A | Not generally available, offered in preview mode. | Windows Virtual Machine | Any application that runs on Windows via Microsoft Remote Desktop |
|
||||
- [**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).
|
||||
|
||||
_Last updated: 14/12/2022_
|
||||
### 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.
|
||||
|
||||
+819
-179
File diff suppressed because it is too large
Load Diff
+725
-107
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (*agent) statisticsHandler() http.Handler {
|
||||
func (a *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{
|
||||
@@ -19,16 +19,24 @@ func (*agent) statisticsHandler() http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
lp := &listeningPortsHandler{}
|
||||
// Make a copy to ensure the map is not modified after the handler is
|
||||
// created.
|
||||
cpy := make(map[int]string)
|
||||
for k, b := range a.ignorePorts {
|
||||
cpy[k] = b
|
||||
}
|
||||
|
||||
lp := &listeningPortsHandler{ignorePorts: cpy}
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
mut sync.Mutex
|
||||
ports []codersdk.ListeningPort
|
||||
mtime time.Time
|
||||
mut sync.Mutex
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
mtime time.Time
|
||||
ignorePorts map[int]string
|
||||
}
|
||||
|
||||
// handler returns a list of listening ports. This is tested by coderd's
|
||||
@@ -43,7 +51,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,14 @@ 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
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -47,9 +52,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 +63,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),
|
||||
|
||||
+187
-49
@@ -3,16 +3,19 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
@@ -21,52 +24,50 @@ import (
|
||||
"github.com/coder/coder/agent"
|
||||
"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/cli/clibase"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
func (r *RootCmd) workspaceAgent() *clibase.Cmd {
|
||||
var (
|
||||
auth string
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
auth string
|
||||
logDir string
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
sshMaxTimeout time.Duration
|
||||
tailnetListenPort int64
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "agent",
|
||||
Short: `Starts the Coder workspace agent.`,
|
||||
// This command isn't useful to manually execute.
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
go dumpHandler(ctx)
|
||||
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_URL must be set: %w", err)
|
||||
}
|
||||
coderURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
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)
|
||||
agentPorts := map[int]string{}
|
||||
|
||||
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(inv.Stderr), 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)
|
||||
@@ -76,30 +77,62 @@ 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(inv.Stderr), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
|
||||
version := buildinfo.Version()
|
||||
logger.Info(ctx, "starting agent",
|
||||
slog.F("url", coderURL),
|
||||
slog.F("url", r.agentURL),
|
||||
slog.F("auth", auth),
|
||||
slog.F("version", version),
|
||||
)
|
||||
client := codersdk.New(coderURL)
|
||||
client.Logger = logger
|
||||
client := agentsdk.New(r.agentURL)
|
||||
client.SDK.Logger = logger
|
||||
// Set a reasonable timeout so requests can't hang forever!
|
||||
client.HTTPClient.Timeout = 10 * time.Second
|
||||
// The timeout needs to be reasonably long, because requests
|
||||
// with large payloads can take a bit. e.g. startup scripts
|
||||
// may take a while to insert.
|
||||
client.SDK.HTTPClient.Timeout = 30 * time.Second
|
||||
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
_ = pprof.Handler
|
||||
pprofSrvClose := serveHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
// Do a best effort here. If this fails, it's not a big deal.
|
||||
if port, err := urlPort(pprofAddress); err == nil {
|
||||
agentPorts[port] = "pprof"
|
||||
}
|
||||
|
||||
// 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)
|
||||
token, err := inv.ParsedFlags().GetString(varAgentToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err)
|
||||
}
|
||||
@@ -112,8 +145,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.
|
||||
@@ -123,11 +156,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.
|
||||
@@ -137,11 +170,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,11 +188,13 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
|
||||
closer := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
LogDir: logDir,
|
||||
TailnetListenPort: uint16(tailnetListenPort),
|
||||
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 {
|
||||
@@ -171,15 +206,59 @@ func workspaceAgent() *cobra.Command {
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GIT_ASKPASS": executablePath,
|
||||
},
|
||||
AgentPorts: agentPorts,
|
||||
SSHMaxTimeout: sshMaxTimeout,
|
||||
})
|
||||
<-ctx.Done()
|
||||
return closer.Close()
|
||||
},
|
||||
}
|
||||
|
||||
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(), &pprofAddress, "pprof-address", "", "CODER_AGENT_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "auth",
|
||||
Default: "token",
|
||||
Description: "Specify the authentication type to use for the agent.",
|
||||
Env: "CODER_AGENT_AUTH",
|
||||
Value: clibase.StringOf(&auth),
|
||||
},
|
||||
{
|
||||
Flag: "log-dir",
|
||||
Default: os.TempDir(),
|
||||
Description: "Specify the location for the agent log files.",
|
||||
Env: "CODER_AGENT_LOG_DIR",
|
||||
Value: clibase.StringOf(&logDir),
|
||||
},
|
||||
{
|
||||
Flag: "pprof-address",
|
||||
Default: "127.0.0.1:6060",
|
||||
Env: "CODER_AGENT_PPROF_ADDRESS",
|
||||
Value: clibase.StringOf(&pprofAddress),
|
||||
Description: "The address to serve pprof.",
|
||||
},
|
||||
{
|
||||
Flag: "no-reap",
|
||||
|
||||
Env: "",
|
||||
Description: "Do not start a process reaper.",
|
||||
Value: clibase.BoolOf(&noReap),
|
||||
},
|
||||
{
|
||||
Flag: "ssh-max-timeout",
|
||||
Default: "0",
|
||||
Env: "CODER_AGENT_SSH_MAX_TIMEOUT",
|
||||
Description: "Specify the max timeout for a SSH connection.",
|
||||
Value: clibase.DurationOf(&sshMaxTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "tailnet-listen-port",
|
||||
Default: "0",
|
||||
Env: "CODER_AGENT_TAILNET_LISTEN_PORT",
|
||||
Description: "Specify a static port for Tailscale to use for listening.",
|
||||
Value: clibase.Int64Of(&tailnetListenPort),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -205,3 +284,62 @@ func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler,
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
// extractPort handles different url strings.
|
||||
// - localhost:6060
|
||||
// - http://localhost:6060
|
||||
func extractPort(u string) (int, error) {
|
||||
port, firstError := urlPort(u)
|
||||
if firstError == nil {
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// Try with a scheme
|
||||
port, err := urlPort("http://" + u)
|
||||
if err == nil {
|
||||
return port, nil
|
||||
}
|
||||
return -1, xerrors.Errorf("invalid url %q: %w", u, firstError)
|
||||
}
|
||||
|
||||
// urlPort extracts the port from a valid URL.
|
||||
func urlPort(u string) (int, error) {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return -1, xerrors.Errorf("invalid url %q: %w", u, err)
|
||||
}
|
||||
if parsed.Port() != "" {
|
||||
port, err := strconv.ParseInt(parsed.Port(), 10, 64)
|
||||
if err == nil && port > 0 {
|
||||
return int(port), nil
|
||||
}
|
||||
}
|
||||
return -1, xerrors.Errorf("invalid port: %s", u)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_extractPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlString string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
urlString: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "NoScheme",
|
||||
urlString: "localhost:6060",
|
||||
want: 6060,
|
||||
},
|
||||
{
|
||||
name: "WithScheme",
|
||||
urlString: "http://localhost:6060",
|
||||
want: 6060,
|
||||
},
|
||||
{
|
||||
name: "NoPort",
|
||||
urlString: "http://localhost",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "NoPortNoScheme",
|
||||
urlString: "localhost",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "OnlyPort",
|
||||
urlString: "6060",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := extractPort(tt.urlString)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, fmt.Sprintf("extractPort(%v)", tt.urlString))
|
||||
} else {
|
||||
require.NoError(t, err, fmt.Sprintf("extractPort(%v)", tt.urlString))
|
||||
require.Equal(t, tt.want, got, fmt.Sprintf("extractPort(%v)", tt.urlString))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+69
-41
@@ -2,6 +2,8 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,10 +16,50 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
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: echo.ProvisionApplyWithAgent(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()
|
||||
inv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", authToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
pty.ExpectMatch("starting agent")
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
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"
|
||||
@@ -50,16 +92,14 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
|
||||
inv, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
|
||||
inv = inv.WithContext(
|
||||
//nolint:revive,staticcheck
|
||||
context.WithValue(inv.Context(), "azure-client", metadataClient),
|
||||
)
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "azure-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
clitest.Start(t, inv)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -71,9 +111,6 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
require.True(t, dialer.AwaitReachable(context.Background()))
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("AWS", func(t *testing.T) {
|
||||
@@ -108,36 +145,29 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "aws-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
inv, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
|
||||
inv = inv.WithContext(
|
||||
//nolint:revive,staticcheck
|
||||
context.WithValue(inv.Context(), "aws-client", metadataClient),
|
||||
)
|
||||
clitest.Start(t, inv)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
workspace, err := client.Workspace(inv.Context(), workspace.ID)
|
||||
require.NoError(t, err)
|
||||
resources := workspace.LatestBuild.Resources
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
dialer, err := client.DialWorkspaceAgent(inv.Context(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
require.True(t, dialer.AwaitReachable(context.Background()))
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("GoogleCloud", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
validator, metadataClient := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleTokenValidator: validator,
|
||||
IncludeProvisionerDaemon: true,
|
||||
@@ -166,16 +196,18 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "gcp-client", metadata)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
inv, cfg := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
|
||||
ptytest.New(t).Attach(inv)
|
||||
clitest.SetupConfig(t, client, cfg)
|
||||
clitest.Start(t,
|
||||
inv.WithContext(
|
||||
//nolint:revive,staticcheck
|
||||
context.WithValue(context.Background(), "gcp-client", metadataClient),
|
||||
),
|
||||
)
|
||||
|
||||
ctx := inv.Context()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -202,9 +234,5 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_, err = uuid.Parse(strings.TrimSpace(string(token)))
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package clibase offers an all-in-one solution for a highly configurable CLI
|
||||
// application. Within Coder, we use it for all of our subcommands, which
|
||||
// demands more functionality than cobra/viber offers.
|
||||
//
|
||||
// The Command interface is loosely based on the chi middleware pattern and
|
||||
// http.Handler/HandlerFunc.
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
// Group describes a hierarchy of groups that an option or command belongs to.
|
||||
type Group struct {
|
||||
Parent *Group `json:"parent,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Children []Group `json:"children,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Group) AddChild(child Group) {
|
||||
child.Parent = g
|
||||
g.Children = append(g.Children, child)
|
||||
}
|
||||
|
||||
// Ancestry returns the group and all of its parents, in order.
|
||||
func (g *Group) Ancestry() []Group {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := []Group{*g}
|
||||
for p := g.Parent; p != nil; p = p.Parent {
|
||||
// Prepend to the slice so that the order is correct.
|
||||
groups = append([]Group{*p}, groups...)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (g *Group) FullName() string {
|
||||
var names []string
|
||||
for _, g := range g.Ancestry() {
|
||||
names = append(names, g.Name)
|
||||
}
|
||||
return strings.Join(names, " / ")
|
||||
}
|
||||
|
||||
// Annotations is an arbitrary key-mapping used to extend the Option and Command types.
|
||||
// Its methods won't panic if the map is nil.
|
||||
type Annotations map[string]string
|
||||
|
||||
// Mark sets a value on the annotations map, creating one
|
||||
// if it doesn't exist. Mark does not mutate the original and
|
||||
// returns a copy. It is suitable for chaining.
|
||||
func (a Annotations) Mark(key string, value string) Annotations {
|
||||
var aa Annotations
|
||||
if a != nil {
|
||||
aa = maps.Clone(a)
|
||||
} else {
|
||||
aa = make(Annotations)
|
||||
}
|
||||
aa[key] = value
|
||||
return aa
|
||||
}
|
||||
|
||||
// IsSet returns true if the key is set in the annotations map.
|
||||
func (a Annotations) IsSet(key string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := a[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get retrieves a key from the map, returning false if the key is not found
|
||||
// or the map is nil.
|
||||
func (a Annotations) Get(key string) (string, bool) {
|
||||
if a == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := a[key]
|
||||
return v, ok
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Cmd describes an executable command.
|
||||
type Cmd struct {
|
||||
// Parent is the direct parent of the command.
|
||||
Parent *Cmd
|
||||
// Children is a list of direct descendants.
|
||||
Children []*Cmd
|
||||
// Use is provided in form "command [flags] [args...]".
|
||||
Use string
|
||||
|
||||
// Aliases is a list of alternative names for the command.
|
||||
Aliases []string
|
||||
|
||||
// Short is a one-line description of the command.
|
||||
Short string
|
||||
|
||||
// Hidden determines whether the command should be hidden from help.
|
||||
Hidden bool
|
||||
|
||||
// RawArgs determines whether the command should receive unparsed arguments.
|
||||
// No flags are parsed when set, and the command is responsible for parsing
|
||||
// its own flags.
|
||||
RawArgs bool
|
||||
|
||||
// Long is a detailed description of the command,
|
||||
// presented on its help page. It may contain examples.
|
||||
Long string
|
||||
Options OptionSet
|
||||
Annotations Annotations
|
||||
|
||||
// Middleware is called before the Handler.
|
||||
// Use Chain() to combine multiple middlewares.
|
||||
Middleware MiddlewareFunc
|
||||
Handler HandlerFunc
|
||||
HelpHandler HandlerFunc
|
||||
}
|
||||
|
||||
// AddSubcommands adds the given subcommands, setting their
|
||||
// Parent field automatically.
|
||||
func (c *Cmd) AddSubcommands(cmds ...*Cmd) {
|
||||
for _, cmd := range cmds {
|
||||
cmd.Parent = c
|
||||
c.Children = append(c.Children, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Walk calls fn for the command and all its children.
|
||||
func (c *Cmd) Walk(fn func(*Cmd)) {
|
||||
fn(c)
|
||||
for _, child := range c.Children {
|
||||
child.Parent = c
|
||||
child.Walk(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareAll performs initialization and linting on the command and all its children.
|
||||
func (c *Cmd) PrepareAll() error {
|
||||
if c.Use == "" {
|
||||
return xerrors.New("command must have a Use field so that it has a name")
|
||||
}
|
||||
var merr error
|
||||
|
||||
slices.SortFunc(c.Options, func(a, b Option) bool {
|
||||
return a.Flag < b.Flag
|
||||
})
|
||||
for _, opt := range c.Options {
|
||||
if opt.Name == "" {
|
||||
switch {
|
||||
case opt.Flag != "":
|
||||
opt.Name = opt.Flag
|
||||
case opt.Env != "":
|
||||
opt.Name = opt.Env
|
||||
case opt.YAML != "":
|
||||
opt.Name = opt.YAML
|
||||
default:
|
||||
merr = errors.Join(merr, xerrors.Errorf("option must have a Name, Flag, Env or YAML field"))
|
||||
}
|
||||
}
|
||||
if opt.Description != "" {
|
||||
// Enforce that description uses sentence form.
|
||||
if unicode.IsLower(rune(opt.Description[0])) {
|
||||
merr = errors.Join(merr, xerrors.Errorf("option %q description should start with a capital letter", opt.Name))
|
||||
}
|
||||
if !strings.HasSuffix(opt.Description, ".") {
|
||||
merr = errors.Join(merr, xerrors.Errorf("option %q description should end with a period", opt.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(c.Children, func(a, b *Cmd) bool {
|
||||
return a.Name() < b.Name()
|
||||
})
|
||||
for _, child := range c.Children {
|
||||
child.Parent = c
|
||||
err := child.PrepareAll()
|
||||
if err != nil {
|
||||
merr = errors.Join(merr, xerrors.Errorf("command %v: %w", child.Name(), err))
|
||||
}
|
||||
}
|
||||
return merr
|
||||
}
|
||||
|
||||
// Name returns the first word in the Use string.
|
||||
func (c *Cmd) Name() string {
|
||||
return strings.Split(c.Use, " ")[0]
|
||||
}
|
||||
|
||||
// FullName returns the full invocation name of the command,
|
||||
// as seen on the command line.
|
||||
func (c *Cmd) FullName() string {
|
||||
var names []string
|
||||
if c.Parent != nil {
|
||||
names = append(names, c.Parent.FullName())
|
||||
}
|
||||
names = append(names, c.Name())
|
||||
return strings.Join(names, " ")
|
||||
}
|
||||
|
||||
// FullName returns usage of the command, preceded
|
||||
// by the usage of its parents.
|
||||
func (c *Cmd) FullUsage() string {
|
||||
var uses []string
|
||||
if c.Parent != nil {
|
||||
uses = append(uses, c.Parent.FullName())
|
||||
}
|
||||
uses = append(uses, c.Use)
|
||||
return strings.Join(uses, " ")
|
||||
}
|
||||
|
||||
// Invoke creates a new invocation of the command, with
|
||||
// stdio discarded.
|
||||
//
|
||||
// The returned invocation is not live until Run() is called.
|
||||
func (c *Cmd) Invoke(args ...string) *Invocation {
|
||||
return &Invocation{
|
||||
Command: c,
|
||||
Args: args,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
Stdin: strings.NewReader(""),
|
||||
}
|
||||
}
|
||||
|
||||
// Invocation represents an instance of a command being executed.
|
||||
type Invocation struct {
|
||||
ctx context.Context
|
||||
Command *Cmd
|
||||
parsedFlags *pflag.FlagSet
|
||||
Args []string
|
||||
// Environ is a list of environment variables. Use EnvsWithPrefix to parse
|
||||
// os.Environ.
|
||||
Environ Environ
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Stdin io.Reader
|
||||
}
|
||||
|
||||
// WithOS returns the invocation as a main package, filling in the invocation's unset
|
||||
// fields with OS defaults.
|
||||
func (inv *Invocation) WithOS() *Invocation {
|
||||
return inv.with(func(i *Invocation) {
|
||||
i.Stdout = os.Stdout
|
||||
i.Stderr = os.Stderr
|
||||
i.Stdin = os.Stdin
|
||||
i.Args = os.Args[1:]
|
||||
i.Environ = ParseEnviron(os.Environ(), "")
|
||||
})
|
||||
}
|
||||
|
||||
func (inv *Invocation) Context() context.Context {
|
||||
if inv.ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return inv.ctx
|
||||
}
|
||||
|
||||
func (inv *Invocation) ParsedFlags() *pflag.FlagSet {
|
||||
if inv.parsedFlags == nil {
|
||||
panic("flags not parsed, has Run() been called?")
|
||||
}
|
||||
return inv.parsedFlags
|
||||
}
|
||||
|
||||
type runState struct {
|
||||
allArgs []string
|
||||
commandDepth int
|
||||
|
||||
flagParseErr error
|
||||
}
|
||||
|
||||
func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet {
|
||||
fs2 := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
fs2.Usage = func() {}
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Name == without {
|
||||
return
|
||||
}
|
||||
fs2.AddFlag(f)
|
||||
})
|
||||
return fs2
|
||||
}
|
||||
|
||||
// run recursively executes the command and its children.
|
||||
// allArgs is wired through the stack so that global flags can be accepted
|
||||
// anywhere in the command invocation.
|
||||
func (inv *Invocation) run(state *runState) error {
|
||||
err := inv.Command.Options.ParseEnv(inv.Environ)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parsing env: %w", err)
|
||||
}
|
||||
|
||||
// Now the fun part, argument parsing!
|
||||
|
||||
children := make(map[string]*Cmd)
|
||||
for _, child := range inv.Command.Children {
|
||||
child.Parent = inv.Command
|
||||
for _, name := range append(child.Aliases, child.Name()) {
|
||||
if _, ok := children[name]; ok {
|
||||
return xerrors.Errorf("duplicate command name: %s", name)
|
||||
}
|
||||
children[name] = child
|
||||
}
|
||||
}
|
||||
|
||||
if inv.parsedFlags == nil {
|
||||
inv.parsedFlags = pflag.NewFlagSet(inv.Command.Name(), pflag.ContinueOnError)
|
||||
// We handle Usage ourselves.
|
||||
inv.parsedFlags.Usage = func() {}
|
||||
}
|
||||
|
||||
// If we find a duplicate flag, we want the deeper command's flag to override
|
||||
// the shallow one. Unfortunately, pflag has no way to remove a flag, so we
|
||||
// have to create a copy of the flagset without a value.
|
||||
inv.Command.Options.FlagSet().VisitAll(func(f *pflag.Flag) {
|
||||
if inv.parsedFlags.Lookup(f.Name) != nil {
|
||||
inv.parsedFlags = copyFlagSetWithout(inv.parsedFlags, f.Name)
|
||||
}
|
||||
inv.parsedFlags.AddFlag(f)
|
||||
})
|
||||
|
||||
var parsedArgs []string
|
||||
|
||||
if !inv.Command.RawArgs {
|
||||
// Flag parsing will fail on intermediate commands in the command tree,
|
||||
// so we check the error after looking for a child command.
|
||||
state.flagParseErr = inv.parsedFlags.Parse(state.allArgs)
|
||||
parsedArgs = inv.parsedFlags.Args()
|
||||
}
|
||||
|
||||
// Set defaults for flags that weren't set by the user.
|
||||
skipDefaults := make(map[int]struct{}, len(inv.Command.Options))
|
||||
for i, opt := range inv.Command.Options {
|
||||
if fl := inv.parsedFlags.Lookup(opt.Flag); fl != nil && fl.Changed {
|
||||
skipDefaults[i] = struct{}{}
|
||||
}
|
||||
if opt.envChanged {
|
||||
skipDefaults[i] = struct{}{}
|
||||
}
|
||||
}
|
||||
err = inv.Command.Options.SetDefaults(skipDefaults)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("setting defaults: %w", err)
|
||||
}
|
||||
|
||||
// Run child command if found (next child only)
|
||||
// We must do subcommand detection after flag parsing so we don't mistake flag
|
||||
// values for subcommand names.
|
||||
if len(parsedArgs) > state.commandDepth {
|
||||
nextArg := parsedArgs[state.commandDepth]
|
||||
if child, ok := children[nextArg]; ok {
|
||||
child.Parent = inv.Command
|
||||
inv.Command = child
|
||||
state.commandDepth++
|
||||
return inv.run(state)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag parse errors are irrelevant for raw args commands.
|
||||
if !inv.Command.RawArgs && state.flagParseErr != nil && !errors.Is(state.flagParseErr, pflag.ErrHelp) {
|
||||
return xerrors.Errorf(
|
||||
"parsing flags (%v) for %q: %w",
|
||||
state.allArgs,
|
||||
inv.Command.FullName(), state.flagParseErr,
|
||||
)
|
||||
}
|
||||
|
||||
if inv.Command.RawArgs {
|
||||
// If we're at the root command, then the name is omitted
|
||||
// from the arguments, so we can just use the entire slice.
|
||||
if state.commandDepth == 0 {
|
||||
inv.Args = state.allArgs
|
||||
} else {
|
||||
argPos, err := findArg(inv.Command.Name(), state.allArgs, inv.parsedFlags)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
inv.Args = state.allArgs[argPos+1:]
|
||||
}
|
||||
} else {
|
||||
// In non-raw-arg mode, we want to skip over flags.
|
||||
inv.Args = parsedArgs[state.commandDepth:]
|
||||
}
|
||||
|
||||
mw := inv.Command.Middleware
|
||||
if mw == nil {
|
||||
mw = Chain()
|
||||
}
|
||||
|
||||
ctx := inv.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
inv = inv.WithContext(ctx)
|
||||
|
||||
if inv.Command.Handler == nil || errors.Is(state.flagParseErr, pflag.ErrHelp) {
|
||||
if inv.Command.HelpHandler == nil {
|
||||
return xerrors.Errorf("no handler or help for command %s", inv.Command.FullName())
|
||||
}
|
||||
return inv.Command.HelpHandler(inv)
|
||||
}
|
||||
|
||||
err = mw(inv.Command.Handler)(inv)
|
||||
if err != nil {
|
||||
return &RunCommandError{
|
||||
Cmd: inv.Command,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RunCommandError struct {
|
||||
Cmd *Cmd
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RunCommandError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *RunCommandError) Error() string {
|
||||
return fmt.Sprintf("running command %q: %+v", e.Cmd.FullName(), e.Err)
|
||||
}
|
||||
|
||||
// findArg returns the index of the first occurrence of arg in args, skipping
|
||||
// over all flags.
|
||||
func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
if arg == want {
|
||||
return i, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// This is a flag!
|
||||
if strings.Contains(arg, "=") {
|
||||
// The flag contains the value in the same arg, just skip.
|
||||
continue
|
||||
}
|
||||
|
||||
// We need to check if NoOptValue is set, then we should not wait
|
||||
// for the next arg to be the value.
|
||||
f := fs.Lookup(strings.TrimLeft(arg, "-"))
|
||||
if f == nil {
|
||||
return -1, xerrors.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if f.NoOptDefVal != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if i == len(args)-1 {
|
||||
return -1, xerrors.Errorf("flag %s requires a value", arg)
|
||||
}
|
||||
|
||||
// Skip the value.
|
||||
i++
|
||||
}
|
||||
|
||||
return -1, xerrors.Errorf("arg %s not found", want)
|
||||
}
|
||||
|
||||
// Run executes the command.
|
||||
// If two command share a flag name, the first command wins.
|
||||
//
|
||||
//nolint:revive
|
||||
func (inv *Invocation) Run() (err error) {
|
||||
defer func() {
|
||||
// Pflag is panicky, so additional context is helpful in tests.
|
||||
if flag.Lookup("test.v") == nil {
|
||||
return
|
||||
}
|
||||
if r := recover(); r != nil {
|
||||
err = xerrors.Errorf("panic recovered for %s: %v", inv.Command.FullName(), r)
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
err = inv.run(&runState{
|
||||
allArgs: inv.Args,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// WithContext returns a copy of the Invocation with the given context.
|
||||
func (inv *Invocation) WithContext(ctx context.Context) *Invocation {
|
||||
return inv.with(func(i *Invocation) {
|
||||
i.ctx = ctx
|
||||
})
|
||||
}
|
||||
|
||||
// with returns a copy of the Invocation with the given function applied.
|
||||
func (inv *Invocation) with(fn func(*Invocation)) *Invocation {
|
||||
i2 := *inv
|
||||
fn(&i2)
|
||||
return &i2
|
||||
}
|
||||
|
||||
// MiddlewareFunc returns the next handler in the chain,
|
||||
// or nil if there are no more.
|
||||
type MiddlewareFunc func(next HandlerFunc) HandlerFunc
|
||||
|
||||
func chain(ms ...MiddlewareFunc) MiddlewareFunc {
|
||||
return MiddlewareFunc(func(next HandlerFunc) HandlerFunc {
|
||||
if len(ms) > 0 {
|
||||
return chain(ms[1:]...)(ms[0](next))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Chain returns a Handler that first calls middleware in order.
|
||||
//
|
||||
//nolint:revive
|
||||
func Chain(ms ...MiddlewareFunc) MiddlewareFunc {
|
||||
// We need to reverse the array to provide top-to-bottom execution
|
||||
// order when defining a command.
|
||||
reversed := make([]MiddlewareFunc, len(ms))
|
||||
for i := range ms {
|
||||
reversed[len(ms)-1-i] = ms[i]
|
||||
}
|
||||
return chain(reversed...)
|
||||
}
|
||||
|
||||
func RequireNArgs(want int) MiddlewareFunc {
|
||||
return RequireRangeArgs(want, want)
|
||||
}
|
||||
|
||||
// RequireRangeArgs returns a Middleware that requires the number of arguments
|
||||
// to be between start and end (inclusive). If end is -1, then the number of
|
||||
// arguments must be at least start.
|
||||
func RequireRangeArgs(start, end int) MiddlewareFunc {
|
||||
if start < 0 {
|
||||
panic("start must be >= 0")
|
||||
}
|
||||
return func(next HandlerFunc) HandlerFunc {
|
||||
return func(i *Invocation) error {
|
||||
got := len(i.Args)
|
||||
switch {
|
||||
case start == end && got != start:
|
||||
switch start {
|
||||
case 0:
|
||||
if len(i.Command.Children) > 0 {
|
||||
return xerrors.Errorf("unrecognized subcommand %q", i.Args[0])
|
||||
}
|
||||
return xerrors.Errorf("wanted no args but got %v %v", got, i.Args)
|
||||
default:
|
||||
return xerrors.Errorf(
|
||||
"wanted %v args but got %v %v",
|
||||
start,
|
||||
got,
|
||||
i.Args,
|
||||
)
|
||||
}
|
||||
case start > 0 && end == -1:
|
||||
switch {
|
||||
case got < start:
|
||||
return xerrors.Errorf(
|
||||
"wanted at least %v args but got %v",
|
||||
start,
|
||||
got,
|
||||
)
|
||||
default:
|
||||
return next(i)
|
||||
}
|
||||
case start > end:
|
||||
panic("start must be <= end")
|
||||
case got < start || got > end:
|
||||
return xerrors.Errorf(
|
||||
"wanted between %v and %v args but got %v",
|
||||
start, end,
|
||||
got,
|
||||
)
|
||||
default:
|
||||
return next(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandlerFunc handles an Invocation of a command.
|
||||
type HandlerFunc func(i *Invocation) error
|
||||
@@ -0,0 +1,596 @@
|
||||
package clibase_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
// ioBufs is the standard input, output, and error for a command.
|
||||
type ioBufs struct {
|
||||
Stdin bytes.Buffer
|
||||
Stdout bytes.Buffer
|
||||
Stderr bytes.Buffer
|
||||
}
|
||||
|
||||
// fakeIO sets Stdin, Stdout, and Stderr to buffers.
|
||||
func fakeIO(i *clibase.Invocation) *ioBufs {
|
||||
var b ioBufs
|
||||
i.Stdout = &b.Stdout
|
||||
i.Stderr = &b.Stderr
|
||||
i.Stdin = &b.Stdin
|
||||
return &b
|
||||
}
|
||||
|
||||
func TestCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := func() *clibase.Cmd {
|
||||
var (
|
||||
verbose bool
|
||||
lower bool
|
||||
prefix string
|
||||
)
|
||||
return &clibase.Cmd{
|
||||
Use: "root [subcommand]",
|
||||
Options: clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "verbose",
|
||||
Flag: "verbose",
|
||||
Value: clibase.BoolOf(&verbose),
|
||||
},
|
||||
clibase.Option{
|
||||
Name: "prefix",
|
||||
Flag: "prefix",
|
||||
Value: clibase.StringOf(&prefix),
|
||||
},
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "toupper [word]",
|
||||
Short: "Converts a word to upper case",
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(1),
|
||||
),
|
||||
Aliases: []string{"up"},
|
||||
Options: clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "lower",
|
||||
Flag: "lower",
|
||||
Value: clibase.BoolOf(&lower),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte(prefix))
|
||||
w := i.Args[0]
|
||||
if lower {
|
||||
w = strings.ToLower(w)
|
||||
} else {
|
||||
w = strings.ToUpper(w)
|
||||
}
|
||||
_, _ = i.Stdout.Write(
|
||||
[]byte(
|
||||
w,
|
||||
),
|
||||
)
|
||||
if verbose {
|
||||
i.Stdout.Write([]byte("!!!"))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("SimpleOK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke("toupper", "hello")
|
||||
io := fakeIO(i)
|
||||
i.Run()
|
||||
require.Equal(t, "HELLO", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("Alias", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"up", "hello",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
i.Run()
|
||||
require.Equal(t, "HELLO", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("NoSubcommand", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"na",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Empty(t, io.Stdout.String())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BadArgs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"toupper",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Empty(t, io.Stdout.String())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("UnknownFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"toupper", "--unknown",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Empty(t, io.Stdout.String())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Verbose", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"--verbose", "toupper", "hello",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "HELLO!!!", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("Verbose=", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"--verbose=true", "toupper", "hello",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "HELLO!!!", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("PrefixSpace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"--prefix", "conv: ", "toupper", "hello",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "conv: HELLO", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("GlobalFlagsAnywhere", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"toupper", "--prefix", "conv: ", "hello", "--verbose",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "conv: HELLO!!!", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("LowerVerbose", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"toupper", "--verbose", "hello", "--lower",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "hello!!!", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("ParsedFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"toupper", "--verbose", "hello", "--lower",
|
||||
)
|
||||
_ = fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t,
|
||||
"true",
|
||||
i.ParsedFlags().Lookup("verbose").Value.String(),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("NoDeepChild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"root", "level", "level", "toupper", "--verbose", "hello", "--lower",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
require.Error(t, i.Run(), fio.Stdout.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommand_DeepNest(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "1",
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "2",
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "3",
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte("3"))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
inv := cmd.Invoke("2", "3")
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "3", stdio.Stdout.String())
|
||||
}
|
||||
|
||||
func TestCommand_FlagOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
var flag string
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "1",
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "flag",
|
||||
Flag: "f",
|
||||
Value: clibase.DiscardValue,
|
||||
},
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "2",
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "flag",
|
||||
Flag: "f",
|
||||
Value: clibase.StringOf(&flag),
|
||||
},
|
||||
},
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := cmd.Invoke("2", "--f", "mhmm").Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "mhmm", flag)
|
||||
}
|
||||
|
||||
func TestCommand_MiddlewareOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mw := func(letter string) clibase.MiddlewareFunc {
|
||||
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
|
||||
return (func(i *clibase.Invocation) error {
|
||||
_, _ = i.Stdout.Write([]byte(letter))
|
||||
return next(i)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "toupper [word]",
|
||||
Short: "Converts a word to upper case",
|
||||
Middleware: clibase.Chain(
|
||||
mw("A"),
|
||||
mw("B"),
|
||||
mw("C"),
|
||||
),
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
i := cmd.Invoke(
|
||||
"hello", "world",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "ABC", io.Stdout.String())
|
||||
}
|
||||
|
||||
func TestCommand_RawArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := func() *clibase.Cmd {
|
||||
return &clibase.Cmd{
|
||||
Use: "root",
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "password",
|
||||
Flag: "password",
|
||||
Value: clibase.StringOf(new(string)),
|
||||
},
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "sushi <args...>",
|
||||
Short: "Throws back raw output",
|
||||
RawArgs: true,
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
if v := i.ParsedFlags().Lookup("password").Value.String(); v != "codershack" {
|
||||
return xerrors.Errorf("password %q is wrong!", v)
|
||||
}
|
||||
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
// Flag parsed before the raw arg command should still work.
|
||||
t.Parallel()
|
||||
|
||||
i := cmd().Invoke(
|
||||
"--password", "codershack", "sushi", "hello", "--verbose", "world",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.NoError(t, i.Run())
|
||||
require.Equal(t, "hello --verbose world", io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("BadFlag", func(t *testing.T) {
|
||||
// Verbose before the raw arg command should fail.
|
||||
t.Parallel()
|
||||
|
||||
i := cmd().Invoke(
|
||||
"--password", "codershack", "--verbose", "sushi", "hello", "world",
|
||||
)
|
||||
io := fakeIO(i)
|
||||
require.Error(t, i.Run())
|
||||
require.Empty(t, io.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("NoPassword", func(t *testing.T) {
|
||||
// Flag parsed before the raw arg command should still work.
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"sushi", "hello", "--verbose", "world",
|
||||
)
|
||||
_ = fakeIO(i)
|
||||
require.Error(t, i.Run())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommand_RootRaw(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &clibase.Cmd{
|
||||
RawArgs: true,
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
inv := cmd.Invoke("hello", "--verbose", "--friendly")
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "hello --verbose --friendly", stdio.Stdout.String())
|
||||
}
|
||||
|
||||
func TestCommand_HyphenHyphen(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
inv := cmd.Invoke("--", "--verbose", "--friendly")
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "--verbose --friendly", stdio.Stdout.String())
|
||||
}
|
||||
|
||||
func TestCommand_ContextCancels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotCtx context.Context
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
gotCtx = i.Context()
|
||||
if err := gotCtx.Err(); err != nil {
|
||||
return xerrors.Errorf("unexpected context error: %w", i.Context().Err())
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := cmd.Invoke().Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Error(t, gotCtx.Err())
|
||||
}
|
||||
|
||||
func TestCommand_Help(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := func() *clibase.Cmd {
|
||||
return &clibase.Cmd{
|
||||
Use: "root",
|
||||
HelpHandler: (func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte("abdracadabra"))
|
||||
return nil
|
||||
}),
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
return xerrors.New("should not be called")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("NoHandler", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := cmd()
|
||||
c.HelpHandler = nil
|
||||
err := c.Invoke("--help").Run()
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Long", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := cmd().Invoke("--help")
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stdio.Stdout.String(), "abdracadabra")
|
||||
})
|
||||
|
||||
t.Run("Short", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := cmd().Invoke("-h")
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stdio.Stdout.String(), "abdracadabra")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommand_SliceFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := func(want ...string) *clibase.Cmd {
|
||||
var got []string
|
||||
return &clibase.Cmd{
|
||||
Use: "root",
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "arr",
|
||||
Flag: "arr",
|
||||
Default: "bad,bad,bad",
|
||||
Value: clibase.StringArrayOf(&got),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
require.Equal(t, want, got)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
err := cmd("good", "good", "good").Invoke("--arr", "good", "--arr", "good", "--arr", "good").Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cmd("bad", "bad", "bad").Invoke().Run()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCommand_EmptySlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := func(want ...string) *clibase.Cmd {
|
||||
var got []string
|
||||
return &clibase.Cmd{
|
||||
Use: "root",
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "arr",
|
||||
Flag: "arr",
|
||||
Default: "def,def,def",
|
||||
Env: "ARR",
|
||||
Value: clibase.StringArrayOf(&got),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
require.Equal(t, want, got)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Base-case, uses default.
|
||||
err := cmd("def", "def", "def").Invoke().Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Empty-env uses default, too.
|
||||
inv := cmd("def", "def", "def").Invoke()
|
||||
inv.Environ.Set("ARR", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reset to nothing at all via flag.
|
||||
inv = cmd().Invoke("--arr", "")
|
||||
inv.Environ.Set("ARR", "cant see")
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reset to a specific value with flag.
|
||||
inv = cmd("great").Invoke("--arr", "great")
|
||||
inv.Environ.Set("ARR", "")
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCommand_DefaultsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got string
|
||||
cmd := &clibase.Cmd{
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "url",
|
||||
Flag: "url",
|
||||
Default: "def.com",
|
||||
Env: "URL",
|
||||
Value: clibase.StringOf(&got),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
_, _ = fmt.Fprintf(i.Stdout, "%s", got)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
// Base case
|
||||
inv := cmd.Invoke()
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "def.com", stdio.Stdout.String())
|
||||
|
||||
// Flag overrides
|
||||
inv = cmd.Invoke("--url", "good.com")
|
||||
stdio = fakeIO(inv)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "good.com", stdio.Stdout.String())
|
||||
|
||||
// Env overrides
|
||||
inv = cmd.Invoke()
|
||||
inv.Environ.Set("URL", "good.com")
|
||||
stdio = fakeIO(inv)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "good.com", stdio.Stdout.String())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package clibase
|
||||
|
||||
import "strings"
|
||||
|
||||
// name returns the name of the environment variable.
|
||||
func envName(line string) string {
|
||||
return strings.ToUpper(
|
||||
strings.SplitN(line, "=", 2)[0],
|
||||
)
|
||||
}
|
||||
|
||||
// value returns the value of the environment variable.
|
||||
func envValue(line string) string {
|
||||
tokens := strings.SplitN(line, "=", 2)
|
||||
if len(tokens) < 2 {
|
||||
return ""
|
||||
}
|
||||
return tokens[1]
|
||||
}
|
||||
|
||||
// Var represents a single environment variable of form
|
||||
// NAME=VALUE.
|
||||
type EnvVar struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type Environ []EnvVar
|
||||
|
||||
func (e Environ) ToOS() []string {
|
||||
var env []string
|
||||
for _, v := range e {
|
||||
env = append(env, v.Name+"="+v.Value)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func (e Environ) Lookup(name string) (string, bool) {
|
||||
for _, v := range e {
|
||||
if v.Name == name {
|
||||
return v.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (e Environ) Get(name string) string {
|
||||
v, _ := e.Lookup(name)
|
||||
return v
|
||||
}
|
||||
|
||||
func (e *Environ) Set(name, value string) {
|
||||
for i, v := range *e {
|
||||
if v.Name == name {
|
||||
(*e)[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
*e = append(*e, EnvVar{Name: name, Value: value})
|
||||
}
|
||||
|
||||
// ParseEnviron returns all environment variables starting with
|
||||
// prefix without said prefix.
|
||||
func ParseEnviron(environ []string, prefix string) Environ {
|
||||
var filtered []EnvVar
|
||||
for _, line := range environ {
|
||||
name := envName(line)
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
filtered = append(filtered, EnvVar{
|
||||
Name: strings.TrimPrefix(name, prefix),
|
||||
Value: envValue(line),
|
||||
})
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package clibase_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
func TestFilterNamePrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
environ []string
|
||||
prefix string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want clibase.Environ
|
||||
}{
|
||||
{"empty", args{[]string{}, "SHIRE"}, nil},
|
||||
{
|
||||
"ONE",
|
||||
args{
|
||||
[]string{
|
||||
"SHIRE_BRANDYBUCK=hmm",
|
||||
},
|
||||
"SHIRE_",
|
||||
},
|
||||
[]clibase.EnvVar{
|
||||
{Name: "BRANDYBUCK", Value: "hmm"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := clibase.ParseEnviron(tt.args.environ, tt.args.prefix); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("FilterNamePrefix() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Option is a configuration option for a CLI application.
|
||||
type Option struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Flag is the long name of the flag used to configure this option. If unset,
|
||||
// flag configuring is disabled.
|
||||
Flag string `json:"flag,omitempty"`
|
||||
// FlagShorthand is the one-character shorthand for the flag. If unset, no
|
||||
// shorthand is used.
|
||||
FlagShorthand string `json:"flag_shorthand,omitempty"`
|
||||
|
||||
// Env is the environment variable used to configure this option. If unset,
|
||||
// environment configuring is disabled.
|
||||
Env string `json:"env,omitempty"`
|
||||
|
||||
// YAML is the YAML key used to configure this option. If unset, YAML
|
||||
// configuring is disabled.
|
||||
YAML string `json:"yaml,omitempty"`
|
||||
|
||||
// Default is parsed into Value if set.
|
||||
Default string `json:"default,omitempty"`
|
||||
// Value includes the types listed in values.go.
|
||||
Value pflag.Value `json:"value,omitempty"`
|
||||
|
||||
// Annotations enable extensions to clibase higher up in the stack. It's useful for
|
||||
// help formatting and documentation generation.
|
||||
Annotations Annotations `json:"annotations,omitempty"`
|
||||
|
||||
// Group is a group hierarchy that helps organize this option in help, configs
|
||||
// and other documentation.
|
||||
Group *Group `json:"group,omitempty"`
|
||||
|
||||
// UseInstead is a list of options that should be used instead of this one.
|
||||
// The field is used to generate a deprecation warning.
|
||||
UseInstead []Option `json:"use_instead,omitempty"`
|
||||
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
|
||||
envChanged bool
|
||||
}
|
||||
|
||||
// OptionSet is a group of options that can be applied to a command.
|
||||
type OptionSet []Option
|
||||
|
||||
// Add adds the given Options to the OptionSet.
|
||||
func (s *OptionSet) Add(opts ...Option) {
|
||||
*s = append(*s, opts...)
|
||||
}
|
||||
|
||||
// FlagSet returns a pflag.FlagSet for the OptionSet.
|
||||
func (s *OptionSet) FlagSet() *pflag.FlagSet {
|
||||
if s == nil {
|
||||
return &pflag.FlagSet{}
|
||||
}
|
||||
|
||||
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
for _, opt := range *s {
|
||||
if opt.Flag == "" {
|
||||
continue
|
||||
}
|
||||
var noOptDefValue string
|
||||
{
|
||||
no, ok := opt.Value.(NoOptDefValuer)
|
||||
if ok {
|
||||
noOptDefValue = no.NoOptDefValue()
|
||||
}
|
||||
}
|
||||
|
||||
val := opt.Value
|
||||
if val == nil {
|
||||
val = DiscardValue
|
||||
}
|
||||
|
||||
fs.AddFlag(&pflag.Flag{
|
||||
Name: opt.Flag,
|
||||
Shorthand: opt.FlagShorthand,
|
||||
Usage: opt.Description,
|
||||
Value: val,
|
||||
DefValue: "",
|
||||
Changed: false,
|
||||
Deprecated: "",
|
||||
NoOptDefVal: noOptDefValue,
|
||||
Hidden: opt.Hidden,
|
||||
})
|
||||
}
|
||||
fs.Usage = func() {
|
||||
_, _ = os.Stderr.WriteString("Override (*FlagSet).Usage() to print help text.\n")
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// ParseEnv parses the given environment variables into the OptionSet.
|
||||
// Use EnvsWithPrefix to filter out prefixes.
|
||||
func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
// We parse environment variables first instead of using a nested loop to
|
||||
// avoid N*M complexity when there are a lot of options and environment
|
||||
// variables.
|
||||
envs := make(map[string]string)
|
||||
for _, v := range vs {
|
||||
envs[v.Name] = v.Value
|
||||
}
|
||||
|
||||
for i, opt := range *s {
|
||||
if opt.Env == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
envVal, ok := envs[opt.Env]
|
||||
// Currently, empty values are treated as if the environment variable is
|
||||
// unset. This behavior is technically not correct as there is now no
|
||||
// way for a user to change a Default value to an empty string from
|
||||
// the environment. Unfortunately, we have old configuration files
|
||||
// that rely on the faulty behavior.
|
||||
//
|
||||
// TODO: We should remove this hack in May 2023, when deployments
|
||||
// have had months to migrate to the new behavior.
|
||||
if !ok || envVal == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
opt.envChanged = true
|
||||
(*s)[i] = opt
|
||||
if err := opt.Value.Set(envVal); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// SetDefaults sets the default values for each Option, skipping values
|
||||
// that have already been set as indicated by the skip map.
|
||||
func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
for i, opt := range *s {
|
||||
// Skip values that may have already been set by the user.
|
||||
if len(skip) > 0 {
|
||||
if _, ok := skip[i]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if opt.Default == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if opt.Value == nil {
|
||||
merr = multierror.Append(
|
||||
merr,
|
||||
xerrors.Errorf(
|
||||
"parse %q: no Value field set\nFull opt: %+v",
|
||||
opt.Name, opt,
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if err := opt.Value.Set(opt.Default); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package clibase_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
func TestOptionSet_ParseFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SimpleString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var workspaceName clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
Flag: "workspace-name",
|
||||
FlagShorthand: "n",
|
||||
},
|
||||
}
|
||||
|
||||
var err error
|
||||
err = os.FlagSet().Parse([]string{"--workspace-name", "foo"})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, "foo", workspaceName)
|
||||
|
||||
err = os.FlagSet().Parse([]string{"-n", "f"})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, "f", workspaceName)
|
||||
})
|
||||
|
||||
t.Run("StringArray", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var names clibase.StringArray
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "name",
|
||||
Value: &names,
|
||||
Flag: "name",
|
||||
FlagShorthand: "n",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.FlagSet().Parse([]string{"--name", "foo", "--name", "bar"})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []string{"foo", "bar"}, names)
|
||||
})
|
||||
|
||||
t.Run("ExtraFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var workspaceName clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
},
|
||||
}
|
||||
|
||||
err := os.FlagSet().Parse([]string{"--some-unknown", "foo"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SimpleString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var workspaceName clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
Env: "WORKSPACE_NAME",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.ParseEnv([]clibase.EnvVar{
|
||||
{Name: "WORKSPACE_NAME", Value: "foo"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, "foo", workspaceName)
|
||||
})
|
||||
|
||||
t.Run("EmptyValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var workspaceName clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
Default: "defname",
|
||||
Env: "WORKSPACE_NAME",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv(clibase.ParseEnviron([]string{"CODER_WORKSPACE_NAME="}, "CODER_"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, "defname", workspaceName)
|
||||
})
|
||||
|
||||
t.Run("StringSlice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var actual clibase.StringArray
|
||||
expected := []string{"foo", "bar", "baz"}
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "name",
|
||||
Value: &actual,
|
||||
Env: "NAMES",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv([]clibase.EnvVar{
|
||||
{Name: "NAMES", Value: "foo,bar,baz"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("StructMapStringString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var actual clibase.Struct[map[string]string]
|
||||
expected := map[string]string{"foo": "bar", "baz": "zap"}
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "labels",
|
||||
Value: &actual,
|
||||
Env: "LABELS",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv([]clibase.EnvVar{
|
||||
{Name: "LABELS", Value: `{"foo":"bar","baz":"zap"}`},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, expected, actual.Value)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// NoOptDefValuer describes behavior when no
|
||||
// option is passed into the flag.
|
||||
//
|
||||
// This is useful for boolean or otherwise binary flags.
|
||||
type NoOptDefValuer interface {
|
||||
NoOptDefValue() string
|
||||
}
|
||||
|
||||
// values.go contains a standard set of value types that can be used as
|
||||
// Option Values.
|
||||
|
||||
type Int64 int64
|
||||
|
||||
func Int64Of(i *int64) *Int64 {
|
||||
return (*Int64)(i)
|
||||
}
|
||||
|
||||
func (i *Int64) Set(s string) error {
|
||||
ii, err := strconv.ParseInt(s, 10, 64)
|
||||
*i = Int64(ii)
|
||||
return err
|
||||
}
|
||||
|
||||
func (i Int64) Value() int64 {
|
||||
return int64(i)
|
||||
}
|
||||
|
||||
func (i Int64) String() string {
|
||||
return strconv.Itoa(int(i))
|
||||
}
|
||||
|
||||
func (Int64) Type() string {
|
||||
return "int"
|
||||
}
|
||||
|
||||
type Bool bool
|
||||
|
||||
func BoolOf(b *bool) *Bool {
|
||||
return (*Bool)(b)
|
||||
}
|
||||
|
||||
func (b *Bool) Set(s string) error {
|
||||
if s == "" {
|
||||
*b = Bool(false)
|
||||
return nil
|
||||
}
|
||||
bb, err := strconv.ParseBool(s)
|
||||
*b = Bool(bb)
|
||||
return err
|
||||
}
|
||||
|
||||
func (*Bool) NoOptDefValue() string {
|
||||
return "true"
|
||||
}
|
||||
|
||||
func (b Bool) String() string {
|
||||
return strconv.FormatBool(bool(b))
|
||||
}
|
||||
|
||||
func (b Bool) Value() bool {
|
||||
return bool(b)
|
||||
}
|
||||
|
||||
func (Bool) Type() string {
|
||||
return "bool"
|
||||
}
|
||||
|
||||
type String string
|
||||
|
||||
func StringOf(s *string) *String {
|
||||
return (*String)(s)
|
||||
}
|
||||
|
||||
func (*String) NoOptDefValue() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *String) Set(v string) error {
|
||||
*s = String(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s String) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s String) Value() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (String) Type() string {
|
||||
return "string"
|
||||
}
|
||||
|
||||
var _ pflag.SliceValue = &StringArray{}
|
||||
|
||||
// StringArray is a slice of strings that implements pflag.Value and pflag.SliceValue.
|
||||
type StringArray []string
|
||||
|
||||
func StringArrayOf(ss *[]string) *StringArray {
|
||||
return (*StringArray)(ss)
|
||||
}
|
||||
|
||||
func (s *StringArray) Append(v string) error {
|
||||
*s = append(*s, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StringArray) Replace(vals []string) error {
|
||||
*s = vals
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StringArray) GetSlice() []string {
|
||||
return *s
|
||||
}
|
||||
|
||||
func readAsCSV(v string) ([]string, error) {
|
||||
return csv.NewReader(strings.NewReader(v)).Read()
|
||||
}
|
||||
|
||||
func writeAsCSV(vals []string) string {
|
||||
var sb strings.Builder
|
||||
err := csv.NewWriter(&sb).Write(vals)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: %s", err)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *StringArray) Set(v string) error {
|
||||
if v == "" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
ss, err := readAsCSV(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = append(*s, ss...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StringArray) String() string {
|
||||
return writeAsCSV([]string(s))
|
||||
}
|
||||
|
||||
func (s StringArray) Value() []string {
|
||||
return []string(s)
|
||||
}
|
||||
|
||||
func (StringArray) Type() string {
|
||||
return "string-array"
|
||||
}
|
||||
|
||||
type Duration time.Duration
|
||||
|
||||
func DurationOf(d *time.Duration) *Duration {
|
||||
return (*Duration)(d)
|
||||
}
|
||||
|
||||
func (d *Duration) Set(v string) error {
|
||||
dd, err := time.ParseDuration(v)
|
||||
*d = Duration(dd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Duration) Value() time.Duration {
|
||||
return time.Duration(*d)
|
||||
}
|
||||
|
||||
func (d *Duration) String() string {
|
||||
return time.Duration(*d).String()
|
||||
}
|
||||
|
||||
func (Duration) Type() string {
|
||||
return "duration"
|
||||
}
|
||||
|
||||
type URL url.URL
|
||||
|
||||
func URLOf(u *url.URL) *URL {
|
||||
return (*URL)(u)
|
||||
}
|
||||
|
||||
func (u *URL) Set(v string) error {
|
||||
uu, err := url.Parse(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*u = URL(*uu)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *URL) String() string {
|
||||
uu := url.URL(*u)
|
||||
return uu.String()
|
||||
}
|
||||
|
||||
func (u *URL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(u.String())
|
||||
}
|
||||
|
||||
func (u *URL) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
err := json.Unmarshal(b, &s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.Set(s)
|
||||
}
|
||||
|
||||
func (*URL) Type() string {
|
||||
return "url"
|
||||
}
|
||||
|
||||
func (u *URL) Value() *url.URL {
|
||||
return (*url.URL)(u)
|
||||
}
|
||||
|
||||
// HostPort is a host:port pair.
|
||||
type HostPort struct {
|
||||
Host string
|
||||
Port string
|
||||
}
|
||||
|
||||
func (hp *HostPort) Set(v string) error {
|
||||
if v == "" {
|
||||
return xerrors.Errorf("must not be empty")
|
||||
}
|
||||
var err error
|
||||
hp.Host, hp.Port, err = net.SplitHostPort(v)
|
||||
return err
|
||||
}
|
||||
|
||||
func (hp *HostPort) String() string {
|
||||
if hp.Host == "" && hp.Port == "" {
|
||||
return ""
|
||||
}
|
||||
// Warning: net.JoinHostPort must be used over concatenation to support
|
||||
// IPv6 addresses.
|
||||
return net.JoinHostPort(hp.Host, hp.Port)
|
||||
}
|
||||
|
||||
func (hp *HostPort) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(hp.String())
|
||||
}
|
||||
|
||||
func (hp *HostPort) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
err := json.Unmarshal(b, &s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
hp.Host = ""
|
||||
hp.Port = ""
|
||||
return nil
|
||||
}
|
||||
return hp.Set(s)
|
||||
}
|
||||
|
||||
func (*HostPort) Type() string {
|
||||
return "host:port"
|
||||
}
|
||||
|
||||
var (
|
||||
_ yaml.Marshaler = new(Struct[struct{}])
|
||||
_ yaml.Unmarshaler = new(Struct[struct{}])
|
||||
)
|
||||
|
||||
// Struct is a special value type that encodes an arbitrary struct.
|
||||
// It implements the flag.Value interface, but in general these values should
|
||||
// only be accepted via config for ergonomics.
|
||||
//
|
||||
// The string encoding type is YAML.
|
||||
type Struct[T any] struct {
|
||||
Value T
|
||||
}
|
||||
|
||||
func (s *Struct[T]) Set(v string) error {
|
||||
return yaml.Unmarshal([]byte(v), &s.Value)
|
||||
}
|
||||
|
||||
func (s *Struct[T]) String() string {
|
||||
byt, err := yaml.Marshal(s.Value)
|
||||
if err != nil {
|
||||
return "decode failed: " + err.Error()
|
||||
}
|
||||
return string(byt)
|
||||
}
|
||||
|
||||
func (s *Struct[T]) MarshalYAML() (interface{}, error) {
|
||||
var n yaml.Node
|
||||
err := n.Encode(s.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
|
||||
return n.Decode(&s.Value)
|
||||
}
|
||||
|
||||
func (s *Struct[T]) Type() string {
|
||||
return fmt.Sprintf("struct[%T]", s.Value)
|
||||
}
|
||||
|
||||
func (s *Struct[T]) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.Value)
|
||||
}
|
||||
|
||||
func (s *Struct[T]) UnmarshalJSON(b []byte) error {
|
||||
return json.Unmarshal(b, &s.Value)
|
||||
}
|
||||
|
||||
// DiscardValue does nothing but implements the pflag.Value interface.
|
||||
// It's useful in cases where you want to accept an option, but access the
|
||||
// underlying value directly instead of through the Option methods.
|
||||
var DiscardValue discardValue
|
||||
|
||||
type discardValue struct{}
|
||||
|
||||
func (discardValue) Set(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (discardValue) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (discardValue) Type() string {
|
||||
return "discard"
|
||||
}
|
||||
|
||||
var _ pflag.Value = (*Enum)(nil)
|
||||
|
||||
type Enum struct {
|
||||
Choices []string
|
||||
Value *string
|
||||
}
|
||||
|
||||
func EnumOf(v *string, choices ...string) *Enum {
|
||||
return &Enum{
|
||||
Choices: choices,
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enum) Set(v string) error {
|
||||
for _, c := range e.Choices {
|
||||
if v == c {
|
||||
*e.Value = v
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return xerrors.Errorf("invalid choice: %s, should be one of %v", v, e.Choices)
|
||||
}
|
||||
|
||||
func (e *Enum) Type() string {
|
||||
return fmt.Sprintf("enum[%v]", strings.Join(e.Choices, "|"))
|
||||
}
|
||||
|
||||
func (e *Enum) String() string {
|
||||
return *e.Value
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"github.com/iancoleman/strcase"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// deepMapNode returns the mapping node at the given path,
|
||||
// creating it if it doesn't exist.
|
||||
func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node {
|
||||
if len(path) == 0 {
|
||||
return n
|
||||
}
|
||||
|
||||
// Name is every two nodes.
|
||||
for i := 0; i < len(n.Content)-1; i += 2 {
|
||||
if n.Content[i].Value == path[0] {
|
||||
// Found matching name, recurse.
|
||||
return deepMapNode(n.Content[i+1], path[1:], headComment)
|
||||
}
|
||||
}
|
||||
|
||||
// Not found, create it.
|
||||
nameNode := yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: path[0],
|
||||
HeadComment: headComment,
|
||||
}
|
||||
valueNode := yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
}
|
||||
n.Content = append(n.Content, &nameNode)
|
||||
n.Content = append(n.Content, &valueNode)
|
||||
return deepMapNode(&valueNode, path[1:], headComment)
|
||||
}
|
||||
|
||||
// ToYAML converts the option set to a YAML node, that can be
|
||||
// converted into bytes via yaml.Marshal.
|
||||
//
|
||||
// The node is returned to enable post-processing higher up in
|
||||
// the stack.
|
||||
func (s OptionSet) ToYAML() (*yaml.Node, error) {
|
||||
root := yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
}
|
||||
|
||||
for _, opt := range s {
|
||||
if opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
nameNode := yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: opt.YAML,
|
||||
HeadComment: wordwrap.WrapString(opt.Description, 80),
|
||||
}
|
||||
var valueNode yaml.Node
|
||||
if m, ok := opt.Value.(yaml.Marshaler); ok {
|
||||
v, err := m.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf(
|
||||
"marshal %q: %w", opt.Name, err,
|
||||
)
|
||||
}
|
||||
valueNode, ok = v.(yaml.Node)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf(
|
||||
"marshal %q: unexpected underlying type %T",
|
||||
opt.Name, v,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
valueNode = yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: opt.Value.String(),
|
||||
}
|
||||
}
|
||||
var group []string
|
||||
for _, g := range opt.Group.Ancestry() {
|
||||
if g.Name == "" {
|
||||
return nil, xerrors.Errorf(
|
||||
"group name is empty for %q, groups: %+v",
|
||||
opt.Name,
|
||||
opt.Group,
|
||||
)
|
||||
}
|
||||
group = append(group, strcase.ToLowerCamel(g.Name))
|
||||
}
|
||||
var groupDesc string
|
||||
if opt.Group != nil {
|
||||
groupDesc = wordwrap.WrapString(opt.Group.Description, 80)
|
||||
}
|
||||
parentValueNode := deepMapNode(
|
||||
&root, group,
|
||||
groupDesc,
|
||||
)
|
||||
parentValueNode.Content = append(
|
||||
parentValueNode.Content,
|
||||
&nameNode,
|
||||
&valueNode,
|
||||
)
|
||||
}
|
||||
return &root, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package clibase_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
func TestOption_ToYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RequireKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var workspaceName clibase.String
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
Default: "billie",
|
||||
},
|
||||
}
|
||||
|
||||
node, err := os.ToYAML()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, node.Content, 0)
|
||||
})
|
||||
|
||||
t.Run("SimpleString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var workspaceName clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Workspace Name",
|
||||
Value: &workspaceName,
|
||||
Default: "billie",
|
||||
Description: "The workspace's name.",
|
||||
Group: &clibase.Group{Name: "Names"},
|
||||
YAML: "workspaceName",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := os.ToYAML()
|
||||
require.NoError(t, err)
|
||||
// Visually inspect for now.
|
||||
byt, err := yaml.Marshal(n)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Raw YAML:\n%s", string(byt))
|
||||
})
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// Package cliflag extends flagset with environment variable defaults.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// cliflag.String(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard")
|
||||
//
|
||||
// Will produce the following usage docs:
|
||||
//
|
||||
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
|
||||
package cliflag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
// IsSetBool returns the value of the boolean flag if it is set.
|
||||
// It returns false if the flag isn't set or if any error occurs attempting
|
||||
// to parse the value of the flag.
|
||||
func IsSetBool(cmd *cobra.Command, name string) bool {
|
||||
val, ok := IsSet(cmd, name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
b, err := strconv.ParseBool(val)
|
||||
return err == nil && b
|
||||
}
|
||||
|
||||
// IsSet returns the string value of the flag and whether it was set.
|
||||
func IsSet(cmd *cobra.Command, name string) (string, bool) {
|
||||
flag := cmd.Flag(name)
|
||||
if flag == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return flag.Value.String(), flag.Changed
|
||||
}
|
||||
|
||||
// String sets a string flag on the given flag set.
|
||||
func String(flagset *pflag.FlagSet, name, shorthand, env, def, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
if !ok || v == "" {
|
||||
v = def
|
||||
}
|
||||
flagset.StringP(name, shorthand, v, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// StringVarP sets a string flag on the given flag set.
|
||||
func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string, env string, def string, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
if !ok || v == "" {
|
||||
v = def
|
||||
}
|
||||
flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func StringArray(flagset *pflag.FlagSet, name, shorthand, env string, def []string, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
if !ok || v == "" {
|
||||
if v == "" {
|
||||
def = []string{}
|
||||
} else {
|
||||
def = strings.Split(v, ",")
|
||||
}
|
||||
}
|
||||
flagset.StringArrayP(name, shorthand, def, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if ok {
|
||||
if val == "" {
|
||||
def = []string{}
|
||||
} else {
|
||||
def = strings.Split(val, ",")
|
||||
}
|
||||
}
|
||||
flagset.StringArrayVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// Uint8VarP sets a uint8 flag on the given flag set.
|
||||
func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string, env string, def uint8, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
vi64, err := strconv.ParseUint(val, 10, 8)
|
||||
if err != nil {
|
||||
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// IntVarP sets a uint8 flag on the given flag set.
|
||||
func IntVarP(flagset *pflag.FlagSet, ptr *int, name string, shorthand string, env string, def int, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
vi64, err := strconv.ParseUint(val, 10, 8)
|
||||
if err != nil {
|
||||
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.IntVarP(ptr, name, shorthand, int(vi64), fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func Bool(flagset *pflag.FlagSet, name, shorthand, env string, def bool, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.BoolP(name, shorthand, valb, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// BoolVarP sets a bool flag on the given flag set.
|
||||
func BoolVarP(flagset *pflag.FlagSet, ptr *bool, name string, shorthand string, env string, def bool, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.BoolVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// DurationVarP sets a time.Duration flag on the given flag set.
|
||||
func DurationVarP(flagset *pflag.FlagSet, ptr *time.Duration, name string, shorthand string, env string, def time.Duration, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.DurationVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func fmtUsage(u string, env string) string {
|
||||
if env != "" {
|
||||
// Avoid double dotting.
|
||||
dot := "."
|
||||
if strings.HasSuffix(u, ".") {
|
||||
dot = ""
|
||||
}
|
||||
u = fmt.Sprintf("%s%s\n"+cliui.Styles.Placeholder.Render("Consumes $%s"), u, dot, env)
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package cliflag_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
// Testcliflag cannot run in parallel because it uses t.Setenv.
|
||||
//
|
||||
//nolint:paralleltest
|
||||
func TestCliflag(t *testing.T) {
|
||||
t.Run("StringDefault", func(t *testing.T) {
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
cliflag.String(flagset, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringEnvVar", func(t *testing.T) {
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.String(10)
|
||||
cliflag.String(flagset, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("StringVarPDefault", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringVarPEnvVar", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.String(10)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("EmptyEnvVar", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, _, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, name, shorthand, "", def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.NotContains(t, flagset.FlagUsages(), "Consumes")
|
||||
})
|
||||
|
||||
t.Run("StringArrayDefault", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def := []string{"hello"}
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
|
||||
t.Run("StringArrayEnvVar", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
t.Setenv(env, "wow,test")
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"wow", "test"}, got)
|
||||
})
|
||||
|
||||
t.Run("StringArrayEnvVarEmpty", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
t.Setenv(env, "")
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{}, got)
|
||||
})
|
||||
|
||||
t.Run("UInt8Default", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(def), got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("UInt8EnvVar", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Int63n(10)
|
||||
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
|
||||
def, _ := cryptorand.Int()
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(envValue), got)
|
||||
})
|
||||
|
||||
t.Run("UInt8FailParse", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(def), got)
|
||||
})
|
||||
|
||||
t.Run("IntDefault", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(def), got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("IntEnvVar", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Int63n(10)
|
||||
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
|
||||
def, _ := cryptorand.Int()
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(envValue), got)
|
||||
})
|
||||
|
||||
t.Run("IntFailParse", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(def), got)
|
||||
})
|
||||
|
||||
t.Run("BoolDefault", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("BoolEnvVar", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Bool()
|
||||
t.Setenv(env, strconv.FormatBool(envValue))
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("BoolFailParse", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
|
||||
t.Run("DurationDefault", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("DurationEnvVar", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Duration()
|
||||
t.Setenv(env, envValue.String())
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("DurationFailParse", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
}
|
||||
|
||||
func randomFlag() (*pflag.FlagSet, string, string, string, string) {
|
||||
fsname, _ := cryptorand.String(10)
|
||||
flagset := pflag.NewFlagSet(fsname, pflag.PanicOnError)
|
||||
name, _ := cryptorand.String(10)
|
||||
shorthand, _ := cryptorand.String(1)
|
||||
env, _ := cryptorand.String(10)
|
||||
usage, _ := cryptorand.String(10)
|
||||
|
||||
return flagset, name, shorthand, env, usage
|
||||
}
|
||||
+155
-16
@@ -3,43 +3,71 @@ package clitest
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory.
|
||||
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
|
||||
return NewWithSubcommands(t, cli.AGPL(), args...)
|
||||
func New(t *testing.T, args ...string) (*clibase.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewWithCommand(t, cmd, args...)
|
||||
}
|
||||
|
||||
func NewWithSubcommands(
|
||||
t *testing.T, subcommands []*cobra.Command, args ...string,
|
||||
) (*cobra.Command, config.Root) {
|
||||
cmd := cli.Root(subcommands)
|
||||
dir := t.TempDir()
|
||||
root := config.Root(dir)
|
||||
cmd.SetArgs(append([]string{"--global-config", dir}, args...))
|
||||
type logWriter struct {
|
||||
prefix string
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// We could consider using writers
|
||||
// that log via t.Log here instead.
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
func (l *logWriter) Write(p []byte) (n int, err error) {
|
||||
trimmed := strings.TrimSpace(string(p))
|
||||
if trimmed == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
l.t.Log(
|
||||
l.prefix + ": " + trimmed,
|
||||
)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
return cmd, root
|
||||
func NewWithCommand(
|
||||
t *testing.T, cmd *clibase.Cmd, args ...string,
|
||||
) (*clibase.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
i := &clibase.Invocation{
|
||||
Command: cmd,
|
||||
Args: append([]string{"--global-config", string(configDir)}, args...),
|
||||
Stdin: io.LimitReader(nil, 0),
|
||||
Stdout: (&logWriter{prefix: "stdout", t: t}),
|
||||
Stderr: (&logWriter{prefix: "stderr", t: t}),
|
||||
}
|
||||
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
|
||||
|
||||
// These can be overridden by the test.
|
||||
return i, configDir
|
||||
}
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
@@ -78,7 +106,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:
|
||||
@@ -98,3 +126,114 @@ func extractTar(t *testing.T, data []byte, directory string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the command in a goroutine and cleans it up when
|
||||
// the test completed.
|
||||
func Start(t *testing.T, inv *clibase.Invocation) {
|
||||
t.Helper()
|
||||
|
||||
closeCh := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closeCh)
|
||||
err := StartWithWaiter(t, inv).Wait()
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return
|
||||
default:
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
<-closeCh
|
||||
})
|
||||
}
|
||||
|
||||
// Run runs the command and asserts that there is no error.
|
||||
func Run(t *testing.T, inv *clibase.Invocation) {
|
||||
t.Helper()
|
||||
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type ErrorWaiter struct {
|
||||
waitOnce sync.Once
|
||||
cachedError error
|
||||
|
||||
c <-chan error
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) Wait() error {
|
||||
w.waitOnce.Do(func() {
|
||||
var ok bool
|
||||
w.cachedError, ok = <-w.c
|
||||
if !ok {
|
||||
panic("unexpoected channel close")
|
||||
}
|
||||
})
|
||||
return w.cachedError
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) RequireSuccess() {
|
||||
require.NoError(w.t, w.Wait())
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) RequireError() {
|
||||
require.Error(w.t, w.Wait())
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) RequireContains(s string) {
|
||||
require.ErrorContains(w.t, w.Wait(), s)
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) RequireIs(want error) {
|
||||
require.ErrorIs(w.t, w.Wait(), want)
|
||||
}
|
||||
|
||||
func (w *ErrorWaiter) RequireAs(want interface{}) {
|
||||
require.ErrorAs(w.t, w.Wait(), want)
|
||||
}
|
||||
|
||||
// StartWithWaiter runs the command in a goroutine but returns the error
|
||||
// instead of asserting it. This is useful for testing error cases.
|
||||
func StartWithWaiter(t *testing.T, inv *clibase.Invocation) *ErrorWaiter {
|
||||
t.Helper()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
var cleaningUp atomic.Bool
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
cancel func()
|
||||
)
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(testutil.WaitMedium))
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(inv.Context())
|
||||
}
|
||||
|
||||
inv = inv.WithContext(ctx)
|
||||
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
err := inv.Run()
|
||||
if cleaningUp.Load() && errors.Is(err, context.DeadlineExceeded) {
|
||||
// If we're cleaning up, this error is likely related to the
|
||||
// CLI teardown process. E.g., the server could be slow to shut
|
||||
// down Postgres.
|
||||
t.Logf("command %q timed out during test cleanup", inv.Command.FullName())
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
// Don't exit test routine until server is done.
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
cleaningUp.Store(true)
|
||||
<-errCh
|
||||
})
|
||||
return &ErrorWaiter{c: errCh, t: t}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,9 @@ func TestCli(t *testing.T) {
|
||||
t.Parallel()
|
||||
clitest.CreateTemplateVersionSource(t, nil)
|
||||
client := coderdtest.New(t, nil)
|
||||
cmd, config := clitest.New(t)
|
||||
i, config := clitest.New(t)
|
||||
clitest.SetupConfig(t, client, config)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
_ = cmd.Execute()
|
||||
}()
|
||||
pty := ptytest.New(t).Attach(i)
|
||||
clitest.Start(t, i)
|
||||
pty.ExpectMatch("coder")
|
||||
}
|
||||
|
||||
+159
-41
@@ -10,16 +10,24 @@ 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")
|
||||
AgentShuttingDown = xerrors.New("agent is shutting down")
|
||||
)
|
||||
|
||||
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 +44,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 +79,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 +137,33 @@ 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
|
||||
case codersdk.WorkspaceAgentLifecycleShuttingDown, codersdk.WorkspaceAgentLifecycleShutdownTimeout,
|
||||
codersdk.WorkspaceAgentLifecycleShutdownError, codersdk.WorkspaceAgentLifecycleOff:
|
||||
showMessage()
|
||||
return AgentShuttingDown
|
||||
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 +171,94 @@ 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:
|
||||
switch agent.LifecycleState {
|
||||
case codersdk.WorkspaceAgentLifecycleShutdownTimeout:
|
||||
m.Spin = ""
|
||||
m.Prompt = "The workspace is shutting down, but is taking longer than expected to shut down and the agent shutdown script is still executing."
|
||||
m.Troubleshoot = true
|
||||
case codersdk.WorkspaceAgentLifecycleShutdownError:
|
||||
m.Spin = ""
|
||||
m.Prompt = "The workspace ran into a problem while shutting down, the agent shutdown script exited with non-zero status."
|
||||
m.Troubleshoot = true
|
||||
case codersdk.WorkspaceAgentLifecycleShuttingDown:
|
||||
m.Spin = ""
|
||||
m.Prompt = "The workspace is shutting down."
|
||||
case codersdk.WorkspaceAgentLifecycleOff:
|
||||
m.Spin = ""
|
||||
m.Prompt = "The workspace is not running."
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
+292
-28
@@ -5,10 +5,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
@@ -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 {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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
|
||||
@@ -38,41 +44,44 @@ func TestAgent(t *testing.T) {
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
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 {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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
|
||||
},
|
||||
@@ -83,17 +92,272 @@ func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
||||
},
|
||||
}
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
done <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
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 := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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)
|
||||
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
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 := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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)
|
||||
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
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 := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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)
|
||||
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
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 <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "starting - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "start timeout - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "start error - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
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 := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
err := cliui.Agent(inv.Context(), inv.Stdout, 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
|
||||
},
|
||||
}
|
||||
|
||||
inv := cmd.Invoke()
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
ptty.Attach(inv)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
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 <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "starting - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "start timeout - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "start error - should exit early")
|
||||
|
||||
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||
go func() { done <- inv.WithContext(ctx).Run() }()
|
||||
require.NoError(t, <-done, "ready - should exit early")
|
||||
}
|
||||
|
||||
+7
-5
@@ -49,10 +49,12 @@ var Styles = struct {
|
||||
Keyword: defaultStyles.Keyword,
|
||||
Paragraph: defaultStyles.Paragraph,
|
||||
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#4d46b3"}),
|
||||
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
|
||||
Prompt: defaultStyles.Prompt.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: defaultStyles.FocusedPrompt.Copy().Foreground(lipgloss.Color("#651fff")),
|
||||
Fuchsia: defaultStyles.SelectedMenuItem.Copy(),
|
||||
Logo: defaultStyles.Logo.SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
|
||||
Wrap: lipgloss.NewStyle().Width(80),
|
||||
Logo: defaultStyles.Logo.Copy().SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(
|
||||
lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"},
|
||||
),
|
||||
Wrap: lipgloss.NewStyle().Width(80),
|
||||
}
|
||||
|
||||
@@ -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,57 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"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 := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var fetched atomic.Bool
|
||||
return cliui.GitAuth(inv.Context(), inv.Stdout, 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
inv := cmd.Invoke().WithContext(ctx)
|
||||
|
||||
ptty.Attach(inv)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := inv.Run()
|
||||
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
|
||||
}
|
||||
+43
-5
@@ -10,17 +10,22 @@ import (
|
||||
|
||||
// cliMessage provides a human-readable message for CLI errors and messages.
|
||||
type cliMessage struct {
|
||||
Level string
|
||||
Style lipgloss.Style
|
||||
Header string
|
||||
Prefix string
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// String formats the CLI message for consumption by a human.
|
||||
func (m cliMessage) String() string {
|
||||
var str strings.Builder
|
||||
_, _ = fmt.Fprintf(&str, "%s\r\n",
|
||||
Styles.Bold.Render(m.Header))
|
||||
|
||||
if m.Prefix != "" {
|
||||
_, _ = str.WriteString(m.Style.Bold(true).Render(m.Prefix))
|
||||
}
|
||||
|
||||
_, _ = str.WriteString(m.Style.Bold(false).Render(m.Header))
|
||||
_, _ = str.WriteString("\r\n")
|
||||
for _, line := range m.Lines {
|
||||
_, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line)
|
||||
}
|
||||
@@ -30,9 +35,42 @@ func (m cliMessage) String() string {
|
||||
// Warn writes a log to the writer provided.
|
||||
func Warn(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Level: "warning",
|
||||
Style: Styles.Warn,
|
||||
Style: Styles.Warn.Copy(),
|
||||
Prefix: "WARN: ",
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
}.String())
|
||||
}
|
||||
|
||||
// Warn writes a formatted log to the writer provided.
|
||||
func Warnf(wtr io.Writer, fmtStr string, args ...interface{}) {
|
||||
Warn(wtr, fmt.Sprintf(fmtStr, args...))
|
||||
}
|
||||
|
||||
// Info writes a log to the writer provided.
|
||||
func Info(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
}.String())
|
||||
}
|
||||
|
||||
// Infof writes a formatted log to the writer provided.
|
||||
func Infof(wtr io.Writer, fmtStr string, args ...interface{}) {
|
||||
Info(wtr, fmt.Sprintf(fmtStr, args...))
|
||||
}
|
||||
|
||||
// Error writes a log to the writer provided.
|
||||
func Error(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Style: Styles.Error.Copy(),
|
||||
Prefix: "ERROR: ",
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
}.String())
|
||||
}
|
||||
|
||||
// Errorf writes a formatted log to the writer provided.
|
||||
func Errorf(wtr io.Writer, fmtStr string, args ...interface{}) {
|
||||
Error(wtr, fmt.Sprintf(fmtStr, args...))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
type OutputFormat interface {
|
||||
ID() string
|
||||
AttachOptions(opts *clibase.OptionSet)
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// AttachOptions attaches the --output flag to the given command, and any
|
||||
// additional flags required by the output formatters.
|
||||
func (f *OutputFormatter) AttachOptions(opts *clibase.OptionSet) {
|
||||
for _, format := range f.formats {
|
||||
format.AttachOptions(opts)
|
||||
}
|
||||
|
||||
formatNames := make([]string, 0, len(f.formats))
|
||||
for _, format := range f.formats {
|
||||
formatNames = append(formatNames, format.ID())
|
||||
}
|
||||
|
||||
*opts = append(*opts,
|
||||
clibase.Option{
|
||||
Flag: "output",
|
||||
FlagShorthand: "o",
|
||||
Default: f.formats[0].ID(),
|
||||
Value: clibase.StringOf(&f.formatID),
|
||||
Description: "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"
|
||||
}
|
||||
|
||||
// AttachOptions implements OutputFormat.
|
||||
func (f *tableFormat) AttachOptions(opts *clibase.OptionSet) {
|
||||
*opts = append(*opts,
|
||||
clibase.Option{
|
||||
Flag: "column",
|
||||
FlagShorthand: "c",
|
||||
Default: strings.Join(f.defaultColumns, ","),
|
||||
Value: clibase.StringArrayOf(&f.columns),
|
||||
Description: "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"
|
||||
}
|
||||
|
||||
// AttachOptions implements OutputFormat.
|
||||
func (jsonFormat) AttachOptions(_ *clibase.OptionSet) {}
|
||||
|
||||
// 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,136 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
type format struct {
|
||||
id string
|
||||
attachOptionsFn func(opts *clibase.OptionSet)
|
||||
formatFn func(ctx context.Context, data any) (string, error)
|
||||
}
|
||||
|
||||
var _ cliui.OutputFormat = &format{}
|
||||
|
||||
func (f *format) ID() string {
|
||||
return f.id
|
||||
}
|
||||
|
||||
func (f *format) AttachOptions(opts *clibase.OptionSet) {
|
||||
if f.attachOptionsFn != nil {
|
||||
f.attachOptionsFn(opts)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
attachOptionsFn: func(opts *clibase.OptionSet) {
|
||||
opts.Add(clibase.Option{
|
||||
Name: "foo",
|
||||
Flag: "foo",
|
||||
FlagShorthand: "f",
|
||||
Value: clibase.DiscardValue,
|
||||
Description: "foo flag 1234",
|
||||
})
|
||||
},
|
||||
formatFn: func(_ context.Context, _ any) (string, error) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
return "foo", nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
cmd := &clibase.Cmd{}
|
||||
f.AttachOptions(&cmd.Options)
|
||||
|
||||
fs := cmd.Options.FlagSet()
|
||||
|
||||
selected, err := fs.GetString("output")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "json", selected)
|
||||
usage := fs.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, fs.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, fs.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))
|
||||
})
|
||||
}
|
||||
+49
-23
@@ -1,19 +1,19 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render("var."+parameterSchema.Name))
|
||||
func ParameterSchema(inv *clibase.Invocation, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, Styles.Bold.Render("var."+parameterSchema.Name))
|
||||
if parameterSchema.Description != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -27,15 +27,15 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
|
||||
var value string
|
||||
if len(options) > 0 {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
value, err = Select(cmd, SelectOptions{
|
||||
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
|
||||
value, err = Select(inv, SelectOptions{
|
||||
Options: options,
|
||||
Default: parameterSchema.DefaultSourceValue,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(value))
|
||||
}
|
||||
} else {
|
||||
text := "Enter a value"
|
||||
@@ -44,7 +44,7 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
|
||||
}
|
||||
text += ":"
|
||||
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
value, err = Prompt(inv, PromptOptions{
|
||||
Text: Styles.Bold.Render(text),
|
||||
})
|
||||
value = strings.TrimSpace(value)
|
||||
@@ -61,36 +61,62 @@ 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.Description != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.Description, "\n"), "\n "))+"\n")
|
||||
func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
|
||||
label := templateVersionParameter.Name
|
||||
if templateVersionParameter.DisplayName != "" {
|
||||
label = templateVersionParameter.DisplayName
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout, Styles.Bold.Render(label))
|
||||
if templateVersionParameter.DescriptionPlaintext != "" {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
var err error
|
||||
var value string
|
||||
if len(templateVersionParameter.Options) > 0 {
|
||||
if templateVersionParameter.Type == "list(string)" {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
|
||||
|
||||
var options []string
|
||||
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
values, err := MultiSelect(inv, options)
|
||||
if err == nil {
|
||||
v, err := json.Marshal(&values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(strings.Join(values, ", ")))
|
||||
value = string(v)
|
||||
}
|
||||
} else if len(templateVersionParameter.Options) > 0 {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
|
||||
var richParameterOption *codersdk.TemplateVersionParameterOption
|
||||
richParameterOption, err = RichSelect(cmd, RichSelectOptions{
|
||||
richParameterOption, err = RichSelect(inv, 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))
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(richParameterOption.Name))
|
||||
value = richParameterOption.Value
|
||||
}
|
||||
} else {
|
||||
text := "Enter a value"
|
||||
if templateVersionParameter.DefaultValue != "" {
|
||||
if !templateVersionParameter.Required {
|
||||
text += fmt.Sprintf(" (default: %q)", templateVersionParameter.DefaultValue)
|
||||
}
|
||||
text += ":"
|
||||
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
value, err = Prompt(inv, PromptOptions{
|
||||
Text: Styles.Bold.Render(text),
|
||||
Validate: func(value string) error {
|
||||
return validateRichPrompt(value, templateVersionParameter)
|
||||
@@ -111,8 +137,8 @@ func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.Templat
|
||||
}
|
||||
|
||||
func validateRichPrompt(value string, p codersdk.TemplateVersionParameter) error {
|
||||
return codersdk.ValidateWorkspaceBuildParameter(p, codersdk.WorkspaceBuildParameter{
|
||||
return codersdk.ValidateWorkspaceBuildParameter(p, &codersdk.WorkspaceBuildParameter{
|
||||
Name: p.Name,
|
||||
Value: value,
|
||||
})
|
||||
}, nil)
|
||||
}
|
||||
|
||||
+34
-17
@@ -11,8 +11,9 @@ import (
|
||||
|
||||
"github.com/bgentry/speakeasy"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
// PromptOptions supply a set of options to the prompt.
|
||||
@@ -26,8 +27,16 @@ type PromptOptions struct {
|
||||
|
||||
const skipPromptFlag = "yes"
|
||||
|
||||
func AllowSkipPrompt(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolP(skipPromptFlag, "y", false, "Bypass prompts")
|
||||
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
|
||||
// prompts.
|
||||
func SkipPromptOption() clibase.Option {
|
||||
return clibase.Option{
|
||||
Flag: skipPromptFlag,
|
||||
FlagShorthand: "y",
|
||||
Description: "Bypass prompts.",
|
||||
// Discard
|
||||
Value: clibase.BoolOf(new(bool)),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -36,17 +45,17 @@ const (
|
||||
)
|
||||
|
||||
// Prompt asks the user for input.
|
||||
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
|
||||
// If the cmd has a "yes" flag for skipping confirm prompts, honor it.
|
||||
// If it's not a "Confirm" prompt, then don't skip. As the default value of
|
||||
// "yes" makes no sense.
|
||||
if opts.IsConfirm && cmd.Flags().Lookup(skipPromptFlag) != nil {
|
||||
if skip, _ := cmd.Flags().GetBool(skipPromptFlag); skip {
|
||||
if opts.IsConfirm && inv.ParsedFlags().Lookup(skipPromptFlag) != nil {
|
||||
if skip, _ := inv.ParsedFlags().GetBool(skipPromptFlag); skip {
|
||||
return ConfirmYes, nil
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
|
||||
_, _ = fmt.Fprint(inv.Stdout, Styles.FocusedPrompt.String()+opts.Text+" ")
|
||||
if opts.IsConfirm {
|
||||
if len(opts.Default) == 0 {
|
||||
opts.Default = ConfirmYes
|
||||
@@ -58,19 +67,24 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
} else {
|
||||
renderedNo = Styles.Bold.Render(ConfirmNo)
|
||||
}
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
|
||||
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
|
||||
} else if opts.Default != "" {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
}
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
|
||||
if inv.Stdin == nil {
|
||||
panic("inv.Stdin is nil")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
lineCh := make(chan string)
|
||||
|
||||
go func() {
|
||||
var line string
|
||||
var err error
|
||||
|
||||
inFile, isInputFile := cmd.InOrStdin().(*os.File)
|
||||
inFile, isInputFile := inv.Stdin.(*os.File)
|
||||
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
|
||||
// we don't install a signal handler here because speakeasy has its own
|
||||
line, err = speakeasy.Ask("")
|
||||
@@ -78,7 +92,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
defer signal.Stop(interrupt)
|
||||
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
reader := bufio.NewReader(inv.Stdin)
|
||||
line, err = reader.ReadString('\n')
|
||||
|
||||
// Check if the first line beings with JSON object or array chars.
|
||||
@@ -96,7 +110,10 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
if line == "" {
|
||||
line = opts.Default
|
||||
}
|
||||
lineCh <- line
|
||||
select {
|
||||
case <-inv.Context().Done():
|
||||
case lineCh <- line:
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -109,16 +126,16 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
if opts.Validate != nil {
|
||||
err := opts.Validate(line)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
|
||||
return Prompt(cmd, opts)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, defaultStyles.Error.Render(err.Error()))
|
||||
return Prompt(inv, opts)
|
||||
}
|
||||
}
|
||||
return line, nil
|
||||
case <-cmd.Context().Done():
|
||||
return "", cmd.Context().Err()
|
||||
case <-inv.Context().Done():
|
||||
return "", inv.Context().Err()
|
||||
case <-interrupt:
|
||||
// Print a newline so that any further output starts properly on a new line.
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
return "", Canceled
|
||||
}
|
||||
}
|
||||
|
||||
+24
-18
@@ -8,10 +8,10 @@ import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
@@ -77,9 +77,9 @@ func TestPrompt(t *testing.T) {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "ShouldNotSeeThis",
|
||||
IsConfirm: true,
|
||||
}, func(cmd *cobra.Command) {
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cmd.SetArgs([]string{"-y"})
|
||||
}, func(inv *clibase.Invocation) {
|
||||
inv.Command.Options = append(inv.Command.Options, cliui.SkipPromptOption())
|
||||
inv.Args = []string{"-y"}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
@@ -145,23 +145,25 @@ func TestPrompt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, cmdOpt func(cmd *cobra.Command)) (string, error) {
|
||||
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *clibase.Invocation)) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var err error
|
||||
value, err = cliui.Prompt(cmd, opts)
|
||||
value, err = cliui.Prompt(inv, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
inv := cmd.Invoke()
|
||||
// Optionally modify the cmd
|
||||
if cmdOpt != nil {
|
||||
cmdOpt(cmd)
|
||||
if invOpt != nil {
|
||||
invOpt(inv)
|
||||
}
|
||||
cmd.SetOut(ptty.Output())
|
||||
cmd.SetErr(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
inv.Stdout = ptty.Output()
|
||||
inv.Stderr = ptty.Output()
|
||||
inv.Stdin = ptty.Input()
|
||||
return value, inv.WithContext(context.Background()).Run()
|
||||
}
|
||||
|
||||
func TestPasswordTerminalState(t *testing.T) {
|
||||
@@ -208,13 +210,17 @@ func TestPasswordTerminalState(t *testing.T) {
|
||||
|
||||
// nolint:unused
|
||||
func passwordHelper() {
|
||||
cmd := &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Password:",
|
||||
Secret: true,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.ExecuteContext(context.Background())
|
||||
err := cmd.Invoke().WithOS().Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ type ProvisionerJobOptions struct {
|
||||
Silent bool
|
||||
}
|
||||
|
||||
type ProvisionerJobError struct {
|
||||
Message string
|
||||
Code codersdk.JobErrorCode
|
||||
}
|
||||
|
||||
var _ error = new(ProvisionerJobError)
|
||||
|
||||
func (err *ProvisionerJobError) Error() string {
|
||||
return err.Message
|
||||
}
|
||||
|
||||
// ProvisionerJob renders a provisioner job with interactive cancellation.
|
||||
func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
@@ -181,7 +192,10 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
return nil
|
||||
case codersdk.ProvisionerJobFailed:
|
||||
}
|
||||
err = xerrors.New(job.Error)
|
||||
err = &ProvisionerJobError{
|
||||
Message: job.Error,
|
||||
Code: job.ErrorCode,
|
||||
}
|
||||
jobMutex.Unlock()
|
||||
flushLogBuffer()
|
||||
return err
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
@@ -125,9 +125,9 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
}
|
||||
jobLock := sync.Mutex{}
|
||||
logs := make(chan codersdk.ProvisionerJobLog, 1)
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
return cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
jobLock.Lock()
|
||||
@@ -145,13 +145,14 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
})
|
||||
},
|
||||
}
|
||||
inv := cmd.Invoke()
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
ptty.Attach(inv)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(context.Background())
|
||||
err := inv.WithContext(context.Background()).Run()
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, cliui.Canceled)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+45
-7
@@ -8,9 +8,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/cli/clibase"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -35,6 +35,21 @@ func init() {
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end }}`
|
||||
|
||||
survey.MultiSelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
|
||||
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
|
||||
{{- color "reset"}}
|
||||
{{- " "}}{{- .CurrentOpt.Value}}
|
||||
{{end}}
|
||||
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- "\n"}}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end}}`
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
@@ -53,7 +68,7 @@ type RichSelectOptions struct {
|
||||
}
|
||||
|
||||
// RichSelect displays a list of user options including name and description.
|
||||
func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
|
||||
func RichSelect(inv *clibase.Invocation, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
|
||||
opts := make([]string, len(richOptions.Options))
|
||||
for i, option := range richOptions.Options {
|
||||
line := option.Name
|
||||
@@ -63,7 +78,7 @@ func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.Te
|
||||
opts[i] = line
|
||||
}
|
||||
|
||||
selected, err := Select(cmd, SelectOptions{
|
||||
selected, err := Select(inv, SelectOptions{
|
||||
Options: opts,
|
||||
Default: richOptions.Default,
|
||||
Size: richOptions.Size,
|
||||
@@ -82,7 +97,7 @@ func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.Te
|
||||
}
|
||||
|
||||
// Select displays a list of user options.
|
||||
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
func Select(inv *clibase.Invocation, opts SelectOptions) (string, error) {
|
||||
// The survey library used *always* fails when testing on Windows,
|
||||
// as it requires a live TTY (can't be a conpty). We should fork
|
||||
// this library to add a dummy fallback, that simply reads/writes
|
||||
@@ -108,16 +123,39 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
is.Help.Text = ""
|
||||
}
|
||||
}), survey.WithStdio(fileReadWriter{
|
||||
Reader: cmd.InOrStdin(),
|
||||
Reader: inv.Stdin,
|
||||
}, fileReadWriter{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}, cmd.OutOrStdout()))
|
||||
Writer: inv.Stdout,
|
||||
}, inv.Stdout))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func MultiSelect(inv *clibase.Invocation, items []string) ([]string, error) {
|
||||
// Similar hack is applied to Select()
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
prompt := &survey.MultiSelect{
|
||||
Options: items,
|
||||
Default: items,
|
||||
}
|
||||
|
||||
var values []string
|
||||
err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{
|
||||
Reader: inv.Stdin,
|
||||
}, fileReadWriter{
|
||||
Writer: inv.Stdout,
|
||||
}, inv.Stdout))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return nil, Canceled
|
||||
}
|
||||
return values, err
|
||||
}
|
||||
|
||||
type fileReadWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
|
||||
+48
-16
@@ -1,13 +1,12 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
@@ -32,16 +31,16 @@ func TestSelect(t *testing.T) {
|
||||
|
||||
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var err error
|
||||
value, err = cliui.Select(cmd, opts)
|
||||
value, err = cliui.Select(inv, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
return value, inv.Run()
|
||||
}
|
||||
|
||||
func TestRichSelect(t *testing.T) {
|
||||
@@ -56,11 +55,11 @@ func TestRichSelect(t *testing.T) {
|
||||
{
|
||||
Name: "A-Name",
|
||||
Value: "A-Value",
|
||||
Description: "A-Description",
|
||||
Description: "A-Description.",
|
||||
}, {
|
||||
Name: "B-Name",
|
||||
Value: "B-Value",
|
||||
Description: "B-Description",
|
||||
Description: "B-Description.",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -73,16 +72,49 @@ func TestRichSelect(t *testing.T) {
|
||||
|
||||
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)
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
richOption, err := cliui.RichSelect(inv, opts)
|
||||
if err == nil {
|
||||
value = richOption.Value
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
return value, inv.Run()
|
||||
}
|
||||
|
||||
func TestMultiSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("MultiSelect", func(t *testing.T) {
|
||||
items := []string{"aaa", "bbb", "ccc"}
|
||||
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan []string)
|
||||
go func() {
|
||||
resp, err := newMultiSelect(ptty, items)
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
require.Equal(t, items, <-msgChan)
|
||||
})
|
||||
}
|
||||
|
||||
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
|
||||
var values []string
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
selectedItems, err := cliui.MultiSelect(inv, items)
|
||||
if err == nil {
|
||||
values = selectedItems
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
inv := cmd.Invoke()
|
||||
ptty.Attach(inv)
|
||||
return values, inv.Run()
|
||||
}
|
||||
|
||||
+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()
|
||||
|
||||
+35
-6
@@ -4,6 +4,9 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kirsle/configdir"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,58 +16,80 @@ const (
|
||||
// Root represents the configuration directory.
|
||||
type Root string
|
||||
|
||||
// mustNotBeEmpty prevents us from accidentally writing configuration to the
|
||||
// current directory. This is primarily valuable in development, where we may
|
||||
// accidentally use an empty root.
|
||||
func (r Root) mustNotEmpty() {
|
||||
if r == "" {
|
||||
panic("config root must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (r Root) Session() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(string(r), "session"))
|
||||
}
|
||||
|
||||
// ReplicaID is a unique identifier for the Coder server.
|
||||
func (r Root) ReplicaID() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(string(r), "replica_id"))
|
||||
}
|
||||
|
||||
func (r Root) URL() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(string(r), "url"))
|
||||
}
|
||||
|
||||
func (r Root) Organization() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(string(r), "organization"))
|
||||
}
|
||||
|
||||
func (r Root) DotfilesURL() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(string(r), "dotfilesurl"))
|
||||
}
|
||||
|
||||
func (r Root) PostgresPath() string {
|
||||
r.mustNotEmpty()
|
||||
return filepath.Join(string(r), "postgres")
|
||||
}
|
||||
|
||||
func (r Root) PostgresPassword() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(r.PostgresPath(), "password"))
|
||||
}
|
||||
|
||||
func (r Root) PostgresPort() File {
|
||||
r.mustNotEmpty()
|
||||
return File(filepath.Join(r.PostgresPath(), "port"))
|
||||
}
|
||||
|
||||
func (r Root) DeploymentConfigPath() string {
|
||||
return filepath.Join(string(r), "server.yaml")
|
||||
}
|
||||
|
||||
// File provides convenience methods for interacting with *os.File.
|
||||
type File string
|
||||
|
||||
// Delete deletes the file.
|
||||
func (f File) Delete() error {
|
||||
if f == "" {
|
||||
return xerrors.Errorf("empty file path")
|
||||
}
|
||||
return os.Remove(string(f))
|
||||
}
|
||||
|
||||
// Write writes the string to the file.
|
||||
func (f File) Write(s string) error {
|
||||
return write(string(f), 0600, []byte(s))
|
||||
if f == "" {
|
||||
return xerrors.Errorf("empty file path")
|
||||
}
|
||||
return write(string(f), 0o600, []byte(s))
|
||||
}
|
||||
|
||||
// Read reads the file to a string.
|
||||
func (f File) Read() (string, error) {
|
||||
if f == "" {
|
||||
return "", xerrors.Errorf("empty file path")
|
||||
}
|
||||
byt, err := read(string(f))
|
||||
return string(byt), err
|
||||
}
|
||||
@@ -72,7 +97,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
|
||||
}
|
||||
@@ -98,3 +123,7 @@ func read(path string) ([]byte, error) {
|
||||
defer fi.Close()
|
||||
return io.ReadAll(fi)
|
||||
}
|
||||
|
||||
func DefaultDir() string {
|
||||
return configdir.LocalConfig("coderv2")
|
||||
}
|
||||
|
||||
+225
-67
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -17,12 +18,11 @@ import (
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/pkg/diff"
|
||||
"github.com/pkg/diff/write"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
@@ -48,6 +48,52 @@ type sshConfigOptions struct {
|
||||
sshOptions []string
|
||||
}
|
||||
|
||||
// addOptions expects options in the form of "option=value" or "option value".
|
||||
// It will override any existing option with the same key to prevent duplicates.
|
||||
// Invalid options will return an error.
|
||||
func (o *sshConfigOptions) addOptions(options ...string) error {
|
||||
for _, option := range options {
|
||||
err := o.addOption(option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *sshConfigOptions) addOption(option string) error {
|
||||
key, value, err := codersdk.ParseSSHConfigOption(option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, existing := range o.sshOptions {
|
||||
// Override existing option if they share the same key.
|
||||
// This is case-insensitive. Parsing each time might be a little slow,
|
||||
// but it is ok.
|
||||
existingKey, _, err := codersdk.ParseSSHConfigOption(existing)
|
||||
if err != nil {
|
||||
// Don't mess with original values if there is an error.
|
||||
// This could have come from the user's manual edits.
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(existingKey, key) {
|
||||
if value == "" {
|
||||
// Delete existing option.
|
||||
o.sshOptions = append(o.sshOptions[:i], o.sshOptions[i+1:]...)
|
||||
} else {
|
||||
// Override existing option.
|
||||
o.sshOptions[i] = option
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Only append the option if it is not empty.
|
||||
if value != "" {
|
||||
o.sshOptions = append(o.sshOptions, option)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o sshConfigOptions) equal(other sshConfigOptions) bool {
|
||||
// Compare without side-effects or regard to order.
|
||||
opt1 := slices.Clone(o.sshOptions)
|
||||
@@ -132,19 +178,21 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r
|
||||
}
|
||||
}
|
||||
|
||||
func configSSH() *cobra.Command {
|
||||
func (r *RootCmd) configSSH() *clibase.Cmd {
|
||||
var (
|
||||
sshConfigFile string
|
||||
sshConfigOpts sshConfigOptions
|
||||
usePreviousOpts bool
|
||||
dryRun bool
|
||||
skipProxyCommand bool
|
||||
userHostPrefix string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "config-ssh",
|
||||
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
|
||||
Example: formatExamples(
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
|
||||
Command: "coder config-ssh -o ForwardAgent=yes",
|
||||
@@ -154,20 +202,18 @@ func configSSH() *cobra.Command {
|
||||
Command: "coder config-ssh --dry-run",
|
||||
},
|
||||
),
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(inv.Context(), client)
|
||||
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(cmd.Context(), client)
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
out := inv.Stdout
|
||||
if dryRun {
|
||||
// Print everything except diff to stderr so
|
||||
// that it's possible to capture the diff.
|
||||
out = cmd.OutOrStderr()
|
||||
out = inv.Stderr
|
||||
}
|
||||
coderBinary, err := currentBinPath(out)
|
||||
if err != nil {
|
||||
@@ -178,7 +224,7 @@ func configSSH() *cobra.Command {
|
||||
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
|
||||
}
|
||||
|
||||
root := createConfig(cmd)
|
||||
root := r.createConfig()
|
||||
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape global config for ssh failed: %w", err)
|
||||
@@ -206,7 +252,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
|
||||
}
|
||||
@@ -216,6 +266,13 @@ func configSSH() *cobra.Command {
|
||||
if usePreviousOpts && lastConfig != nil {
|
||||
sshConfigOpts = *lastConfig
|
||||
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
|
||||
for _, v := range sshConfigOpts.sshOptions {
|
||||
// If the user passes an invalid option, we should catch
|
||||
// this early.
|
||||
if _, _, err := codersdk.ParseSSHConfigOption(v); err != nil {
|
||||
return xerrors.Errorf("invalid option from flag: %w", err)
|
||||
}
|
||||
}
|
||||
newOpts := sshConfigOpts.asList()
|
||||
newOptsMsg := "\n\n New options: none"
|
||||
if len(newOpts) > 0 {
|
||||
@@ -227,7 +284,7 @@ func configSSH() *cobra.Command {
|
||||
oldOptsMsg = fmt.Sprintf("\n\n Previous options:\n * %s", strings.Join(oldOpts, "\n * "))
|
||||
}
|
||||
|
||||
line, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
line, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("New options differ from previous options:%s%s\n\n Use new options?", newOptsMsg, oldOptsMsg),
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -241,7 +298,7 @@ func configSSH() *cobra.Command {
|
||||
changes = append(changes, "Use new SSH options")
|
||||
}
|
||||
// Only print when prompts are shown.
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
|
||||
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
}
|
||||
}
|
||||
@@ -249,7 +306,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)
|
||||
|
||||
@@ -262,6 +322,25 @@ func configSSH() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch workspace configs failed: %w", err)
|
||||
}
|
||||
|
||||
coderdConfig, err := client.SSHConfiguration(inv.Context())
|
||||
if err != nil {
|
||||
// If the error is 404, this deployment does not support
|
||||
// this endpoint yet. Do not error, just assume defaults.
|
||||
// TODO: Remove this in 2 months (May 31, 2023). Just return the error
|
||||
// and remove this 404 check.
|
||||
var sdkErr *codersdk.Error
|
||||
if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) {
|
||||
return xerrors.Errorf("fetch coderd config failed: %w", err)
|
||||
}
|
||||
coderdConfig.HostnamePrefix = "coder."
|
||||
}
|
||||
|
||||
if userHostPrefix != "" {
|
||||
// Override with user flag.
|
||||
coderdConfig.HostnamePrefix = userHostPrefix
|
||||
}
|
||||
|
||||
// Ensure stable sorting of output.
|
||||
slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool {
|
||||
return a.Name < b.Name
|
||||
@@ -269,35 +348,59 @@ func configSSH() *cobra.Command {
|
||||
for _, wc := range workspaceConfigs {
|
||||
sort.Strings(wc.Hosts)
|
||||
// Write agent configuration.
|
||||
for _, hostname := range wc.Hosts {
|
||||
configOptions := []string{
|
||||
"Host coder." + hostname,
|
||||
}
|
||||
for _, option := range sshConfigOpts.sshOptions {
|
||||
configOptions = append(configOptions, "\t"+option)
|
||||
}
|
||||
configOptions = append(configOptions,
|
||||
"\tHostName coder."+hostname,
|
||||
"\tConnectTimeout=0",
|
||||
"\tStrictHostKeyChecking=no",
|
||||
for _, workspaceHostname := range wc.Hosts {
|
||||
sshHostname := fmt.Sprintf("%s%s", coderdConfig.HostnamePrefix, workspaceHostname)
|
||||
defaultOptions := []string{
|
||||
"HostName " + sshHostname,
|
||||
"ConnectTimeout=0",
|
||||
"StrictHostKeyChecking=no",
|
||||
// Without this, the "REMOTE HOST IDENTITY CHANGED"
|
||||
// message will appear.
|
||||
"\tUserKnownHostsFile=/dev/null",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
|
||||
// message from appearing on every SSH. This happens because we ignore the known hosts.
|
||||
"\tLogLevel ERROR",
|
||||
)
|
||||
if !skipProxyCommand {
|
||||
configOptions = append(
|
||||
configOptions,
|
||||
fmt.Sprintf(
|
||||
"\tProxyCommand %s --global-config %s ssh --stdio %s",
|
||||
escapedCoderBinary, escapedGlobalConfig, hostname,
|
||||
),
|
||||
)
|
||||
"LogLevel ERROR",
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
|
||||
if !skipProxyCommand {
|
||||
defaultOptions = append(defaultOptions, fmt.Sprintf(
|
||||
"ProxyCommand %s --global-config %s ssh --stdio %s",
|
||||
escapedCoderBinary, escapedGlobalConfig, workspaceHostname,
|
||||
))
|
||||
}
|
||||
|
||||
var configOptions sshConfigOptions
|
||||
// Add standard options.
|
||||
err := configOptions.addOptions(defaultOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Override with deployment options
|
||||
for k, v := range coderdConfig.SSHConfigOptions {
|
||||
opt := fmt.Sprintf("%s %s", k, v)
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
// Override with flag options
|
||||
for _, opt := range sshConfigOpts.sshOptions {
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add flag config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
hostBlock := []string{
|
||||
"Host " + sshHostname,
|
||||
}
|
||||
// Prefix with '\t'
|
||||
for _, v := range configOptions.sshOptions {
|
||||
hostBlock = append(hostBlock, "\t"+v)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(hostBlock, "\n"))
|
||||
_ = buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
@@ -320,21 +423,21 @@ func configSSH() *cobra.Command {
|
||||
if dryRun {
|
||||
_, _ = fmt.Fprintf(out, "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n", strings.Join(changes, "\n * "))
|
||||
|
||||
color := isTTYOut(cmd)
|
||||
color := isTTYOut(inv)
|
||||
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("diff failed: %w", err)
|
||||
}
|
||||
if len(diff) > 0 {
|
||||
// Write diff to stdout.
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s", diff)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s", diff)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -342,7 +445,7 @@ func configSSH() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
// Only print when prompts are shown.
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
|
||||
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
}
|
||||
}
|
||||
@@ -352,24 +455,62 @@ func configSSH() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write ssh config failed: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "Updated %q\n", sshConfigFile)
|
||||
}
|
||||
|
||||
if len(workspaceConfigs) > 0 {
|
||||
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name)
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, workspaceConfigs[0].Name)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
|
||||
cmd.Flags().StringArrayVarP(&sshConfigOpts.sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
|
||||
cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Perform a trial run with no changes made, showing a diff at the end.")
|
||||
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
|
||||
_ = cmd.Flags().MarkHidden("skip-proxy-command")
|
||||
cliflag.BoolVarP(cmd.Flags(), &usePreviousOpts, "use-previous-options", "", "CODER_SSH_USE_PREVIOUS_OPTIONS", false, "Specifies whether or not to keep options from previous run of config-ssh.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "ssh-config-file",
|
||||
Env: "CODER_SSH_CONFIG_FILE",
|
||||
Default: sshDefaultConfigFileName,
|
||||
Description: "Specifies the path to an SSH config.",
|
||||
Value: clibase.StringOf(&sshConfigFile),
|
||||
},
|
||||
{
|
||||
Flag: "ssh-option",
|
||||
FlagShorthand: "o",
|
||||
Env: "CODER_SSH_CONFIG_OPTS",
|
||||
Description: "Specifies additional SSH options to embed in each host stanza.",
|
||||
Value: clibase.StringArrayOf(&sshConfigOpts.sshOptions),
|
||||
},
|
||||
{
|
||||
Flag: "dry-run",
|
||||
FlagShorthand: "n",
|
||||
Env: "CODER_SSH_DRY_RUN",
|
||||
Description: "Perform a trial run with no changes made, showing a diff at the end.",
|
||||
Value: clibase.BoolOf(&dryRun),
|
||||
},
|
||||
{
|
||||
Flag: "skip-proxy-command",
|
||||
Env: "CODER_SSH_SKIP_PROXY_COMMAND",
|
||||
Description: "Specifies whether the ProxyCommand option should be skipped. Useful for testing.",
|
||||
Value: clibase.BoolOf(&skipProxyCommand),
|
||||
Hidden: true,
|
||||
},
|
||||
{
|
||||
Flag: "use-previous-options",
|
||||
Env: "CODER_SSH_USE_PREVIOUS_OPTIONS",
|
||||
Description: "Specifies whether or not to keep options from previous run of config-ssh.",
|
||||
Value: clibase.BoolOf(&usePreviousOpts),
|
||||
},
|
||||
{
|
||||
Flag: "ssh-host-prefix",
|
||||
Env: "",
|
||||
Description: "Override the default host prefix.",
|
||||
Value: clibase.StringOf(&userHostPrefix),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -418,22 +559,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 +602,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
|
||||
|
||||
@@ -5,12 +5,132 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
@@ -60,3 +180,80 @@ func Test_sshConfigExecEscape(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sshConfigOptions_addOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Start []string
|
||||
Add []string
|
||||
Expect []string
|
||||
ExpectError bool
|
||||
}{
|
||||
{
|
||||
Name: "Empty",
|
||||
},
|
||||
{
|
||||
Name: "AddOne",
|
||||
Add: []string{"foo bar"},
|
||||
Expect: []string{
|
||||
"foo bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Replace",
|
||||
Start: []string{
|
||||
"foo bar",
|
||||
},
|
||||
Add: []string{"Foo baz"},
|
||||
Expect: []string{
|
||||
"Foo baz",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "AddAndReplace",
|
||||
Start: []string{
|
||||
"a b",
|
||||
"foo bar",
|
||||
"buzz bazz",
|
||||
},
|
||||
Add: []string{
|
||||
"b c",
|
||||
"A hello",
|
||||
"hello world",
|
||||
},
|
||||
Expect: []string{
|
||||
"foo bar",
|
||||
"buzz bazz",
|
||||
"b c",
|
||||
"A hello",
|
||||
"hello world",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Error",
|
||||
Add: []string{"novalue"},
|
||||
ExpectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
tt := tt
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
o := sshConfigOptions{
|
||||
sshOptions: tt.Start,
|
||||
}
|
||||
err := o.addOptions(tt.Add...)
|
||||
if tt.ExpectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
sort.Strings(tt.Expect)
|
||||
sort.Strings(o.sshOptions)
|
||||
require.Equal(t, tt.Expect, o.sshOptions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+70
-45
@@ -25,6 +25,7 @@ import (
|
||||
"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"
|
||||
@@ -63,7 +64,20 @@ func sshConfigFileRead(t *testing.T, name string) string {
|
||||
func TestConfigSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
const hostname = "test-coder."
|
||||
const expectedKey = "ConnectionAttempts"
|
||||
const removeKey = "ConnectionTimeout"
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ConfigSSH: codersdk.SSHConfigResponse{
|
||||
HostnamePrefix: hostname,
|
||||
SSHConfigOptions: map[string]string{
|
||||
// Something we can test for
|
||||
expectedKey: "3",
|
||||
removeKey: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -82,29 +96,13 @@ func TestConfigSSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}},
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
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)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
@@ -153,21 +151,17 @@ func TestConfigSSH(t *testing.T) {
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
cmd, root := clitest.New(t, "config-ssh",
|
||||
inv, root := clitest.New(t, "config-ssh",
|
||||
"--ssh-option", "HostName "+tcpAddr.IP.String(),
|
||||
"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"--ssh-config-file", sshConfigFile,
|
||||
"--skip-proxy-command")
|
||||
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)
|
||||
}()
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
matches := []struct {
|
||||
match, write string
|
||||
@@ -179,15 +173,20 @@ func TestConfigSSH(t *testing.T) {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
<-doneChan
|
||||
waiter.RequireSuccess()
|
||||
|
||||
fileContents, err := os.ReadFile(sshConfigFile)
|
||||
require.NoError(t, err, "read ssh config file")
|
||||
require.Contains(t, string(fileContents), expectedKey, "ssh config file contains expected key")
|
||||
require.NotContains(t, string(fileContents), removeKey, "ssh config file should not have removed key")
|
||||
|
||||
home := filepath.Dir(filepath.Dir(sshConfigFile))
|
||||
// #nosec
|
||||
sshCmd := exec.Command("ssh", "-F", sshConfigFile, "coder."+workspace.Name, "echo", "test")
|
||||
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+workspace.Name, "echo", "test")
|
||||
pty = ptytest.New(t)
|
||||
// Set HOME because coder config is included from ~/.ssh/coder.
|
||||
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
|
||||
sshCmd.Stderr = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
data, err := sshCmd.Output()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(data)))
|
||||
@@ -529,6 +528,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
|
||||
@@ -556,14 +585,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
"--ssh-config-file", sshConfigName,
|
||||
}
|
||||
args = append(args, tt.args...)
|
||||
cmd, root := clitest.New(t, args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
done := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
if !tt.wantErr {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
@@ -673,17 +702,13 @@ func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
|
||||
inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
|
||||
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)
|
||||
}()
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
|
||||
matches := []struct {
|
||||
match, write string
|
||||
@@ -695,7 +720,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
<-doneChan
|
||||
pty.ExpectMatch("Updated")
|
||||
|
||||
var expectedHosts []string
|
||||
for _, hostnamePattern := range tt.expected {
|
||||
|
||||
+112
-56
@@ -1,21 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func create() *cobra.Command {
|
||||
func (r *RootCmd) create() *clibase.Cmd {
|
||||
var (
|
||||
parameterFile string
|
||||
richParameterFile string
|
||||
@@ -24,30 +24,27 @@ func create() *cobra.Command {
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "create [name]",
|
||||
Short: "Create a workspace",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
Middleware: clibase.Chain(r.InitClient(client)),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
organization, err := CurrentOrganization(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := CurrentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(args) >= 1 {
|
||||
workspaceName = args[0]
|
||||
if len(inv.Args) >= 1 {
|
||||
workspaceName = inv.Args[0]
|
||||
}
|
||||
|
||||
if workspaceName == "" {
|
||||
workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Specify a name for your workspace:",
|
||||
Validate: func(workspaceName string) error {
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
@@ -59,16 +56,16 @@ func create() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
|
||||
var template codersdk.Template
|
||||
if templateName == "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
|
||||
|
||||
templates, err := client.TemplatesByOrganization(cmd.Context(), organization.ID)
|
||||
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -97,7 +94,7 @@ func create() *cobra.Command {
|
||||
}
|
||||
|
||||
// Move the cursor up a single line for nicer display!
|
||||
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
option, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: templateNames,
|
||||
HideSearch: true,
|
||||
})
|
||||
@@ -107,7 +104,7 @@ func create() *cobra.Command {
|
||||
|
||||
template = templateByName[option]
|
||||
} else {
|
||||
template, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
|
||||
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
@@ -122,7 +119,7 @@ func create() *cobra.Command {
|
||||
schedSpec = ptr.Ref(sched.String())
|
||||
}
|
||||
|
||||
buildParams, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
|
||||
buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Template: template,
|
||||
ExistingParams: []codersdk.Parameter{},
|
||||
ParameterFile: parameterFile,
|
||||
@@ -130,10 +127,10 @@ func create() *cobra.Command {
|
||||
NewWorkspaceName: workspaceName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -141,34 +138,69 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
var ttlMillis *int64
|
||||
if stopAfter > 0 {
|
||||
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
|
||||
} else if template.MaxTTLMillis > 0 {
|
||||
ttlMillis = &template.MaxTTLMillis
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
|
||||
TTLMillis: ttlMillis,
|
||||
ParameterValues: buildParams.parameters,
|
||||
RichParameterValues: buildParams.richParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("create workspace: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID)
|
||||
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("watch build: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been created at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Options = append(cmd.Options,
|
||||
clibase.Option{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_TEMPLATE_NAME",
|
||||
Description: "Specify a template name.",
|
||||
Value: clibase.StringOf(&templateName),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "parameter-file",
|
||||
Env: "CODER_PARAMETER_FILE",
|
||||
Description: "Specify a file path with parameter values.",
|
||||
Value: clibase.StringOf(¶meterFile),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "rich-parameter-file",
|
||||
Env: "CODER_RICH_PARAMETER_FILE",
|
||||
Description: "Specify a file path with values for rich parameters defined in the template.",
|
||||
Value: clibase.StringOf(&richParameterFile),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "start-at",
|
||||
Env: "CODER_WORKSPACE_START_AT",
|
||||
Description: "Specify the workspace autostart schedule. Check coder schedule start --help for the syntax.",
|
||||
Value: clibase.StringOf(&startAt),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "stop-after",
|
||||
Env: "CODER_WORKSPACE_STOP_AFTER",
|
||||
Description: "Specify a duration after which the workspace should shut down (e.g. 8h).",
|
||||
Value: clibase.DurationOf(&stopAfter),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -192,8 +224,23 @@ type buildParameters struct {
|
||||
|
||||
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
|
||||
// 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()
|
||||
func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) {
|
||||
ctx := inv.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
|
||||
@@ -210,7 +257,7 @@ func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWo
|
||||
useParamFile := false
|
||||
if args.ParameterFile != "" {
|
||||
useParamFile = true
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(args.ParameterFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -224,7 +271,7 @@ PromptParamLoop:
|
||||
continue
|
||||
}
|
||||
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")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, 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
|
||||
}
|
||||
|
||||
@@ -239,7 +286,7 @@ PromptParamLoop:
|
||||
}
|
||||
}
|
||||
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
parameterValue, err := getParameterValueFromMapOrInput(inv, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,11 +300,11 @@ PromptParamLoop:
|
||||
}
|
||||
|
||||
if disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
}
|
||||
|
||||
// Rich parameters
|
||||
templateVersionParameters, err := client.TemplateVersionRichParameters(cmd.Context(), templateVersion.ID)
|
||||
templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
|
||||
}
|
||||
@@ -266,7 +313,7 @@ PromptParamLoop:
|
||||
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")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, 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
|
||||
@@ -277,7 +324,7 @@ PromptParamLoop:
|
||||
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")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, 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
|
||||
}
|
||||
|
||||
@@ -293,11 +340,11 @@ 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)))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, 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)
|
||||
parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(inv, parameterMapFromFile, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -306,11 +353,20 @@ PromptRichParamLoop:
|
||||
}
|
||||
|
||||
if disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
}
|
||||
|
||||
err = cliui.GitAuth(ctx, inv.Stdout, 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{
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
ParameterValues: legacyParameters,
|
||||
RichParameterValues: richParameters,
|
||||
@@ -318,16 +374,16 @@ PromptRichParamLoop:
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
@@ -338,19 +394,19 @@ PromptRichParamLoop:
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
}
|
||||
|
||||
return &buildParameters{
|
||||
|
||||
+213
-63
@@ -3,7 +3,9 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
@@ -39,15 +42,13 @@ func TestCreate(t *testing.T) {
|
||||
"--start-at", "9:30AM Mon-Fri US/Central",
|
||||
"--stop-after", "8h",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
@@ -78,6 +79,51 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InheritStopAfterFromTemplate", 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, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: provisionCompleteWithAgent,
|
||||
ProvisionPlan: provisionCompleteWithAgent,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours
|
||||
ctr.DefaultTTLMillis = &defaultTTLMillis
|
||||
})
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "Confirm create", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
waiter.RequireSuccess()
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err, "expected workspace to be created")
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
assert.Equal(t, *ws.TTLMillis, template.DefaultTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("CreateFromListWithSkip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -85,14 +131,14 @@ func TestCreate(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "-y")
|
||||
inv, 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() {
|
||||
defer done()
|
||||
err := cmd.ExecuteContext(cmdCtx)
|
||||
err := inv.WithContext(cmdCtx).Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// No pty interaction needed since we use the -y skip prompt flag
|
||||
@@ -107,15 +153,13 @@ func TestCreate(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "")
|
||||
inv, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []string{
|
||||
@@ -130,7 +174,7 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(cmd.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
ws, err := client.WorkspaceByOwnerAndName(inv.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
|
||||
@@ -151,15 +195,13 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "")
|
||||
inv, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -196,15 +238,13 @@ func TestCreate(t *testing.T) {
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bingo\"\nusername: \"boingo\"")
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
inv, root := clitest.New(t, "create", "", "--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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -241,15 +281,13 @@ func TestCreate(t *testing.T) {
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("username: \"boingo\"")
|
||||
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
inv, root := clitest.New(t, "create", "", "--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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
@@ -309,13 +347,11 @@ func TestCreate(t *testing.T) {
|
||||
require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status, "job is not failed")
|
||||
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name())
|
||||
inv, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name(), "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
ptytest.New(t).Attach(inv)
|
||||
|
||||
err = cmd.Execute()
|
||||
err = inv.Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "dry-run workspace")
|
||||
})
|
||||
@@ -330,6 +366,7 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
firstParameterValue = "1"
|
||||
|
||||
secondParameterName = "second_parameter"
|
||||
secondParameterDisplayName = "Second Parameter"
|
||||
secondParameterDescription = "This is second parameter"
|
||||
secondParameterValue = "2"
|
||||
|
||||
@@ -346,12 +383,13 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
Complete: &proto.Provision_Complete{
|
||||
Parameters: []*proto.RichParameter{
|
||||
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
|
||||
{Name: secondParameterName, Description: secondParameterDescription, Mutable: true},
|
||||
{Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true},
|
||||
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{},
|
||||
@@ -369,20 +407,19 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
inv, 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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDisplayName, "",
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
"Confirm create?", "yes",
|
||||
@@ -391,7 +428,10 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
@@ -413,16 +453,14 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
firstParameterName + ": " + firstParameterValue + "\n" +
|
||||
secondParameterName + ": " + secondParameterValue + "\n" +
|
||||
immutableParameterName + ": " + immutableParameterValue)
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
inv, 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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -446,6 +484,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
stringParameterName = "string_parameter"
|
||||
stringParameterValue = "abc"
|
||||
|
||||
listOfStringsParameterName = "list_of_strings_parameter"
|
||||
|
||||
numberParameterName = "number_parameter"
|
||||
numberParameterValue = "7"
|
||||
|
||||
@@ -461,6 +501,10 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
{Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"},
|
||||
}
|
||||
|
||||
listOfStringsRichParameters := []*proto.RichParameter{
|
||||
{Name: listOfStringsParameterName, Type: "list(string)", Mutable: true, DefaultValue: `["aaa","bbb","ccc"]`},
|
||||
}
|
||||
|
||||
boolRichParameters := []*proto.RichParameter{
|
||||
{Name: boolParameterName, Type: "bool", Mutable: true},
|
||||
}
|
||||
@@ -475,7 +519,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
Parameters: richParameters,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
@@ -496,15 +541,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
inv, 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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -533,15 +576,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
inv, 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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -573,15 +614,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
inv, 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())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -599,6 +638,117 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ValidateListOfStrings", 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(listOfStringsRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
clitest.Start(t, inv)
|
||||
|
||||
matches := []string{
|
||||
listOfStringsParameterName, "",
|
||||
"aaa, bbb, ccc", "",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateListOfStrings_YAMLFile", 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(listOfStringsRichParameters))
|
||||
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(listOfStringsParameterName + `:
|
||||
- ddd
|
||||
- eee
|
||||
- fff`)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
|
||||
matches := []string{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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: &testutil.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)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
clitest.Start(t, inv)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
|
||||
|
||||
+23
-29
@@ -4,23 +4,25 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// nolint
|
||||
func deleteWorkspace() *cobra.Command {
|
||||
func (r *RootCmd) deleteWorkspace() *clibase.Cmd {
|
||||
var orphan bool
|
||||
cmd := &cobra.Command{
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "delete <workspace>",
|
||||
Short: "Delete a workspace",
|
||||
Aliases: []string{"rm"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm delete workspace?",
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
@@ -29,25 +31,13 @@ func deleteWorkspace() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var state []byte
|
||||
|
||||
if orphan {
|
||||
cliui.Warn(
|
||||
cmd.ErrOrStderr(),
|
||||
"Orphaning workspace requires template edit permission",
|
||||
)
|
||||
}
|
||||
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: state,
|
||||
Orphan: orphan,
|
||||
@@ -56,19 +46,23 @@ func deleteWorkspace() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID)
|
||||
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been deleted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been deleted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&orphan, "orphan", false,
|
||||
`Delete a workspace without deleting its resources. This can delete a
|
||||
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
|
||||
)
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "orphan",
|
||||
Description: "Delete a workspace without deleting its resources. This can delete a workspace in a broken state, but may also lead to unaccounted cloud resources.",
|
||||
|
||||
Value: clibase.BoolOf(&orphan),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
+16
-22
@@ -25,21 +25,19 @@ func TestDelete(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)
|
||||
cmd, root := clitest.New(t, "delete", workspace.Name, "-y")
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
@@ -52,23 +50,21 @@ func TestDelete(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)
|
||||
cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
||||
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stderr = pty.Output()
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
@@ -77,7 +73,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)
|
||||
|
||||
@@ -87,22 +83,20 @@ func TestDelete(t *testing.T) {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
|
||||
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
pty.ExpectMatch("workspace has been deleted")
|
||||
<-doneChan
|
||||
|
||||
workspace, err = client.Workspace(context.Background(), workspace.ID)
|
||||
@@ -112,12 +106,12 @@ func TestDelete(t *testing.T) {
|
||||
t.Run("InvalidWorkspaceIdentifier", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
cmd, root := clitest.New(t, "delete", "a/b/c", "-y")
|
||||
inv, root := clitest.New(t, "delete", "a/b/c", "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.ErrorContains(t, err, "invalid workspace name: \"a/b/c\"")
|
||||
}()
|
||||
<-doneChan
|
||||
|
||||
@@ -1,830 +0,0 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func newConfig() *codersdk.DeploymentConfig {
|
||||
return &codersdk.DeploymentConfig{
|
||||
AccessURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Access URL",
|
||||
Usage: "External URL to access your deployment. This must be accessible by all provisioned workspaces.",
|
||||
Flag: "access-url",
|
||||
},
|
||||
WildcardAccessURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Wildcard Access URL",
|
||||
Usage: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
|
||||
Flag: "wildcard-access-url",
|
||||
},
|
||||
// DEPRECATED: Use HTTPAddress or TLS.Address instead.
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "Bind address of the server.",
|
||||
Flag: "address",
|
||||
Shorthand: "a",
|
||||
// Deprecated, so we don't have a default. If set, it will overwrite
|
||||
// HTTPAddress and TLS.Address and print a warning.
|
||||
Hidden: true,
|
||||
Default: "",
|
||||
},
|
||||
HTTPAddress: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "HTTP bind address of the server. Unset to disable the HTTP endpoint.",
|
||||
Flag: "http-address",
|
||||
Default: "127.0.0.1:3000",
|
||||
},
|
||||
AutobuildPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Autobuild Poll Interval",
|
||||
Usage: "Interval to poll for scheduled workspace builds.",
|
||||
Flag: "autobuild-poll-interval",
|
||||
Hidden: true,
|
||||
Default: time.Minute,
|
||||
},
|
||||
DERP: &codersdk.DERP{
|
||||
Server: &codersdk.DERPServerConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "DERP Server Enable",
|
||||
Usage: "Whether to enable or disable the embedded DERP relay server.",
|
||||
Flag: "derp-server-enable",
|
||||
Default: true,
|
||||
},
|
||||
RegionID: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "DERP Server Region ID",
|
||||
Usage: "Region ID to use for the embedded DERP server.",
|
||||
Flag: "derp-server-region-id",
|
||||
Default: 999,
|
||||
},
|
||||
RegionCode: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Region Code",
|
||||
Usage: "Region code to use for the embedded DERP server.",
|
||||
Flag: "derp-server-region-code",
|
||||
Default: "coder",
|
||||
},
|
||||
RegionName: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Region Name",
|
||||
Usage: "Region name that for the embedded DERP server.",
|
||||
Flag: "derp-server-region-name",
|
||||
Default: "Coder Embedded Relay",
|
||||
},
|
||||
STUNAddresses: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "DERP Server STUN Addresses",
|
||||
Usage: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.",
|
||||
Flag: "derp-server-stun-addresses",
|
||||
Default: []string{"stun.l.google.com:19302"},
|
||||
},
|
||||
RelayURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Relay URL",
|
||||
Usage: "An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability.",
|
||||
Flag: "derp-server-relay-url",
|
||||
Enterprise: true,
|
||||
},
|
||||
},
|
||||
Config: &codersdk.DERPConfig{
|
||||
URL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Config URL",
|
||||
Usage: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
Flag: "derp-config-url",
|
||||
},
|
||||
Path: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Config Path",
|
||||
Usage: "Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
Flag: "derp-config-path",
|
||||
},
|
||||
},
|
||||
},
|
||||
GitAuth: &codersdk.DeploymentConfigField[[]codersdk.GitAuthConfig]{
|
||||
Name: "Git Auth",
|
||||
Usage: "Automatically authenticate Git inside workspaces.",
|
||||
Flag: "gitauth",
|
||||
Default: []codersdk.GitAuthConfig{},
|
||||
},
|
||||
Prometheus: &codersdk.PrometheusConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Prometheus Enable",
|
||||
Usage: "Serve prometheus metrics on the address defined by prometheus address.",
|
||||
Flag: "prometheus-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Prometheus Address",
|
||||
Usage: "The bind address to serve prometheus metrics.",
|
||||
Flag: "prometheus-address",
|
||||
Default: "127.0.0.1:2112",
|
||||
},
|
||||
},
|
||||
Pprof: &codersdk.PprofConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Pprof Enable",
|
||||
Usage: "Serve pprof metrics on the address defined by pprof address.",
|
||||
Flag: "pprof-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Pprof Address",
|
||||
Usage: "The bind address to serve pprof.",
|
||||
Flag: "pprof-address",
|
||||
Default: "127.0.0.1:6060",
|
||||
},
|
||||
},
|
||||
ProxyTrustedHeaders: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Proxy Trusted Headers",
|
||||
Flag: "proxy-trusted-headers",
|
||||
Usage: "Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client-Ip, X-Forwarded-For",
|
||||
},
|
||||
ProxyTrustedOrigins: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Proxy Trusted Origins",
|
||||
Flag: "proxy-trusted-origins",
|
||||
Usage: "Origin addresses to respect \"proxy-trusted-headers\". e.g. 192.168.1.0/24",
|
||||
},
|
||||
CacheDirectory: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Cache Directory",
|
||||
Usage: "The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.",
|
||||
Flag: "cache-dir",
|
||||
Default: DefaultCacheDir(),
|
||||
},
|
||||
InMemoryDatabase: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "In Memory Database",
|
||||
Usage: "Controls whether data will be stored in an in-memory database.",
|
||||
Flag: "in-memory",
|
||||
Hidden: true,
|
||||
},
|
||||
PostgresURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Postgres Connection URL",
|
||||
Usage: "URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\".",
|
||||
Flag: "postgres-url",
|
||||
Secret: true,
|
||||
},
|
||||
OAuth2: &codersdk.OAuth2Config{
|
||||
Github: &codersdk.OAuth2GithubConfig{
|
||||
ClientID: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Client ID",
|
||||
Usage: "Client ID for Login with GitHub.",
|
||||
Flag: "oauth2-github-client-id",
|
||||
},
|
||||
ClientSecret: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Client Secret",
|
||||
Usage: "Client secret for Login with GitHub.",
|
||||
Flag: "oauth2-github-client-secret",
|
||||
Secret: true,
|
||||
},
|
||||
AllowedOrgs: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OAuth2 GitHub Allowed Orgs",
|
||||
Usage: "Organizations the user must be a member of to Login with GitHub.",
|
||||
Flag: "oauth2-github-allowed-orgs",
|
||||
},
|
||||
AllowedTeams: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OAuth2 GitHub Allowed Teams",
|
||||
Usage: "Teams inside organizations the user must be a member of to Login with GitHub. Structured as: <organization-name>/<team-slug>.",
|
||||
Flag: "oauth2-github-allowed-teams",
|
||||
},
|
||||
AllowSignups: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OAuth2 GitHub Allow Signups",
|
||||
Usage: "Whether new users can sign up with GitHub.",
|
||||
Flag: "oauth2-github-allow-signups",
|
||||
},
|
||||
AllowEveryone: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OAuth2 GitHub Allow Everyone",
|
||||
Usage: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
|
||||
Flag: "oauth2-github-allow-everyone",
|
||||
},
|
||||
EnterpriseBaseURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Enterprise Base URL",
|
||||
Usage: "Base URL of a GitHub Enterprise deployment to use for Login with GitHub.",
|
||||
Flag: "oauth2-github-enterprise-base-url",
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDC: &codersdk.OIDCConfig{
|
||||
AllowSignups: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OIDC Allow Signups",
|
||||
Usage: "Whether new users can sign up with OIDC.",
|
||||
Flag: "oidc-allow-signups",
|
||||
Default: true,
|
||||
},
|
||||
ClientID: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Client ID",
|
||||
Usage: "Client ID to use for Login with OIDC.",
|
||||
Flag: "oidc-client-id",
|
||||
},
|
||||
ClientSecret: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Client Secret",
|
||||
Usage: "Client secret to use for Login with OIDC.",
|
||||
Flag: "oidc-client-secret",
|
||||
Secret: true,
|
||||
},
|
||||
EmailDomain: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OIDC Email Domain",
|
||||
Usage: "Email domains that clients logging in with OIDC must match.",
|
||||
Flag: "oidc-email-domain",
|
||||
},
|
||||
IssuerURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Issuer URL",
|
||||
Usage: "Issuer URL to use for Login with OIDC.",
|
||||
Flag: "oidc-issuer-url",
|
||||
},
|
||||
Scopes: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OIDC Scopes",
|
||||
Usage: "Scopes to grant when authenticating with OIDC.",
|
||||
Flag: "oidc-scopes",
|
||||
Default: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
IgnoreEmailVerified: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OIDC Ignore Email Verified",
|
||||
Usage: "Ignore the email_verified claim from the upstream provider.",
|
||||
Flag: "oidc-ignore-email-verified",
|
||||
Default: false,
|
||||
},
|
||||
UsernameField: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Username Field",
|
||||
Usage: "OIDC claim field to use as the username.",
|
||||
Flag: "oidc-username-field",
|
||||
Default: "preferred_username",
|
||||
},
|
||||
},
|
||||
|
||||
Telemetry: &codersdk.TelemetryConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Telemetry Enable",
|
||||
Usage: "Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.",
|
||||
Flag: "telemetry",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
Trace: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Telemetry Trace",
|
||||
Usage: "Whether Opentelemetry traces are sent to Coder. Coder collects anonymized application tracing to help improve our product. Disabling telemetry also disables this option.",
|
||||
Flag: "telemetry-trace",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
URL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Telemetry URL",
|
||||
Usage: "URL to send telemetry.",
|
||||
Flag: "telemetry-url",
|
||||
Hidden: true,
|
||||
Default: "https://telemetry.coder.com",
|
||||
},
|
||||
},
|
||||
TLS: &codersdk.TLSConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "TLS Enable",
|
||||
Usage: "Whether TLS will be enabled.",
|
||||
Flag: "tls-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Address",
|
||||
Usage: "HTTPS bind address of the server.",
|
||||
Flag: "tls-address",
|
||||
Default: "127.0.0.1:3443",
|
||||
},
|
||||
RedirectHTTP: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Redirect HTTP to HTTPS",
|
||||
Usage: "Whether HTTP requests will be redirected to the access URL (if it's a https URL and TLS is enabled). Requests to local IP addresses are never redirected regardless of this setting.",
|
||||
Flag: "tls-redirect-http-to-https",
|
||||
Default: true,
|
||||
},
|
||||
CertFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Certificate Files",
|
||||
Usage: "Path to each certificate for TLS. It requires a PEM-encoded file. To configure the listener to use a CA certificate, concatenate the primary certificate and the CA certificate together. The primary certificate should appear first in the combined file.",
|
||||
Flag: "tls-cert-file",
|
||||
},
|
||||
ClientCAFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client CA Files",
|
||||
Usage: "PEM-encoded Certificate Authority file used for checking the authenticity of client",
|
||||
Flag: "tls-client-ca-file",
|
||||
},
|
||||
ClientAuth: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Auth",
|
||||
Usage: "Policy the server will follow for TLS Client Authentication. Accepted values are \"none\", \"request\", \"require-any\", \"verify-if-given\", or \"require-and-verify\".",
|
||||
Flag: "tls-client-auth",
|
||||
Default: "none",
|
||||
},
|
||||
KeyFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Key Files",
|
||||
Usage: "Paths to the private keys for each of the certificates. It requires a PEM-encoded file.",
|
||||
Flag: "tls-key-file",
|
||||
},
|
||||
MinVersion: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Minimum Version",
|
||||
Usage: "Minimum supported version of TLS. Accepted values are \"tls10\", \"tls11\", \"tls12\" or \"tls13\"",
|
||||
Flag: "tls-min-version",
|
||||
Default: "tls12",
|
||||
},
|
||||
ClientCertFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Cert File",
|
||||
Usage: "Path to certificate for client TLS authentication. It requires a PEM-encoded file.",
|
||||
Flag: "tls-client-cert-file",
|
||||
},
|
||||
ClientKeyFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Key File",
|
||||
Usage: "Path to key for client TLS authentication. It requires a PEM-encoded file.",
|
||||
Flag: "tls-client-key-file",
|
||||
},
|
||||
},
|
||||
Trace: &codersdk.TraceConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Trace Enable",
|
||||
Usage: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md",
|
||||
Flag: "trace",
|
||||
},
|
||||
HoneycombAPIKey: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Trace Honeycomb API Key",
|
||||
Usage: "Enables trace exporting to Honeycomb.io using the provided API Key.",
|
||||
Flag: "trace-honeycomb-api-key",
|
||||
Secret: true,
|
||||
},
|
||||
CaptureLogs: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Capture Logs in Traces",
|
||||
Usage: "Enables capturing of logs as events in traces. This is useful for debugging, but may result in a very large amount of events being sent to the tracing backend which may incur significant costs. If the verbose flag was supplied, debug-level logs will be included.",
|
||||
Flag: "trace-logs",
|
||||
},
|
||||
},
|
||||
SecureAuthCookie: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Secure Auth Cookie",
|
||||
Usage: "Controls if the 'Secure' property is set on browser session cookies.",
|
||||
Flag: "secure-auth-cookie",
|
||||
},
|
||||
SSHKeygenAlgorithm: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "SSH Keygen Algorithm",
|
||||
Usage: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",
|
||||
Flag: "ssh-keygen-algorithm",
|
||||
Default: "ed25519",
|
||||
},
|
||||
MetricsCacheRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Metrics Cache Refresh Interval",
|
||||
Usage: "How frequently metrics are refreshed",
|
||||
Flag: "metrics-cache-refresh-interval",
|
||||
Hidden: true,
|
||||
Default: time.Hour,
|
||||
},
|
||||
AgentStatRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Agent Stat Refresh Interval",
|
||||
Usage: "How frequently agent stats are recorded",
|
||||
Flag: "agent-stats-refresh-interval",
|
||||
Hidden: true,
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
AgentFallbackTroubleshootingURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Agent Fallback Troubleshooting URL",
|
||||
Usage: "URL to use for agent troubleshooting when not set in the template",
|
||||
Flag: "agent-fallback-troubleshooting-url",
|
||||
Hidden: true,
|
||||
Default: "https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates",
|
||||
},
|
||||
AuditLogging: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Audit Logging",
|
||||
Usage: "Specifies whether audit logging is enabled.",
|
||||
Flag: "audit-logging",
|
||||
Default: true,
|
||||
Enterprise: true,
|
||||
},
|
||||
BrowserOnly: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Browser Only",
|
||||
Usage: "Whether Coder only allows connections to workspaces via the browser.",
|
||||
Flag: "browser-only",
|
||||
Enterprise: true,
|
||||
},
|
||||
SCIMAPIKey: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "SCIM API Key",
|
||||
Usage: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
|
||||
Flag: "scim-auth-header",
|
||||
Enterprise: true,
|
||||
Secret: true,
|
||||
},
|
||||
Provisioner: &codersdk.ProvisionerConfig{
|
||||
Daemons: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "Provisioner Daemons",
|
||||
Usage: "Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this.",
|
||||
Flag: "provisioner-daemons",
|
||||
Default: 3,
|
||||
},
|
||||
DaemonPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Poll Interval",
|
||||
Usage: "Time to wait before polling for a new job.",
|
||||
Flag: "provisioner-daemon-poll-interval",
|
||||
Default: time.Second,
|
||||
},
|
||||
DaemonPollJitter: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Poll Jitter",
|
||||
Usage: "Random jitter added to the poll interval.",
|
||||
Flag: "provisioner-daemon-poll-jitter",
|
||||
Default: 100 * time.Millisecond,
|
||||
},
|
||||
ForceCancelInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Force Cancel Interval",
|
||||
Usage: "Time to force cancel provisioning tasks that are stuck.",
|
||||
Flag: "provisioner-force-cancel-interval",
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
},
|
||||
RateLimit: &codersdk.RateLimitConfig{
|
||||
DisableAll: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable All Rate Limits",
|
||||
Usage: "Disables all rate limits. This is not recommended in production.",
|
||||
Flag: "dangerous-disable-rate-limits",
|
||||
Default: false,
|
||||
},
|
||||
API: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "API Rate Limit",
|
||||
Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints have separate strict rate limits regardless of this value to prevent denial-of-service or brute force attacks.",
|
||||
// Change the env from the auto-generated CODER_RATE_LIMIT_API to the
|
||||
// old value to avoid breaking existing deployments.
|
||||
EnvOverride: "CODER_API_RATE_LIMIT",
|
||||
Flag: "api-rate-limit",
|
||||
Default: 512,
|
||||
},
|
||||
},
|
||||
// DEPRECATED: use Experiments instead.
|
||||
Experimental: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Experimental",
|
||||
Usage: "Enable experimental features. Experimental features are not ready for production.",
|
||||
Flag: "experimental",
|
||||
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",
|
||||
Usage: "Periodically check for new releases of Coder and inform the owner. The check is performed once per day.",
|
||||
Flag: "update-check",
|
||||
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
|
||||
},
|
||||
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Max Token Lifetime",
|
||||
Usage: "The maximum lifetime duration for any user creating a token.",
|
||||
Flag: "max-token-lifetime",
|
||||
Default: 24 * 30 * time.Hour,
|
||||
},
|
||||
Swagger: &codersdk.SwaggerConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Enable swagger endpoint",
|
||||
Usage: "Expose the swagger endpoint via /swagger.",
|
||||
Flag: "swagger-enable",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func Config(flagset *pflag.FlagSet, vip *viper.Viper) (*codersdk.DeploymentConfig, error) {
|
||||
dc := newConfig()
|
||||
flg, err := flagset.GetString(config.FlagName)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get global config from flag: %w", err)
|
||||
}
|
||||
vip.SetEnvPrefix("coder")
|
||||
|
||||
if flg != "" {
|
||||
vip.SetConfigFile(flg + "/server.yaml")
|
||||
err = vip.ReadInConfig()
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
return dc, xerrors.Errorf("reading deployment config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
setConfig("", vip, &dc)
|
||||
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func setConfig(prefix string, vip *viper.Viper, target interface{}) {
|
||||
val := reflect.Indirect(reflect.ValueOf(target))
|
||||
typ := val.Type()
|
||||
if typ.Kind() != reflect.Struct {
|
||||
val = val.Elem()
|
||||
typ = val.Type()
|
||||
}
|
||||
|
||||
// Ensure that we only bind env variables to proper fields,
|
||||
// otherwise Viper will get confused if the parent struct is
|
||||
// assigned a value.
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Value").Interface()
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetString(vip.GetString(prefix))
|
||||
case bool:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetBool(vip.GetBool(prefix))
|
||||
case int:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetInt(prefix)))
|
||||
case time.Duration:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetDuration(prefix)))
|
||||
case []string:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
// As of October 21st, 2022 we supported delimiting a string
|
||||
// with a comma, but Viper only supports with a space. This
|
||||
// is a small hack around it!
|
||||
rawSlice := reflect.ValueOf(vip.GetStringSlice(prefix)).Interface()
|
||||
stringSlice, ok := rawSlice.([]string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("string slice is of type %T", rawSlice))
|
||||
}
|
||||
value := make([]string, 0, len(stringSlice))
|
||||
for _, entry := range stringSlice {
|
||||
value = append(value, strings.Split(entry, ",")...)
|
||||
}
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(value))
|
||||
case []codersdk.GitAuthConfig:
|
||||
// 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))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setConfig(key, vip, fv.Interface())
|
||||
case reflect.Slice:
|
||||
for j := 0; j < fv.Len(); j++ {
|
||||
key := fmt.Sprintf("%s.%d", key, j)
|
||||
setConfig(key, vip, fv.Index(j).Interface())
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readSliceFromViper reads a typed mapping from the key provided.
|
||||
// This enables environment variables like CODER_GITAUTH_<index>_CLIENT_ID.
|
||||
func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T {
|
||||
elementType := reflect.TypeOf(value).Elem()
|
||||
returnValues := make([]T, 0)
|
||||
for entry := 0; true; entry++ {
|
||||
// Only create an instance when the entry exists in viper...
|
||||
// otherwise we risk
|
||||
var instance *reflect.Value
|
||||
for i := 0; i < elementType.NumField(); i++ {
|
||||
fve := elementType.Field(i)
|
||||
prop := fve.Tag.Get("json")
|
||||
// For fields that are omitted in JSON, we use a YAML tag.
|
||||
if prop == "-" {
|
||||
prop = fve.Tag.Get("yaml")
|
||||
}
|
||||
configKey := fmt.Sprintf("%s.%d.%s", key, entry, prop)
|
||||
|
||||
// Ensure the env entry for this key is registered
|
||||
// before checking value.
|
||||
//
|
||||
// We don't support DeploymentConfigField[].EnvOverride for array flags so
|
||||
// this is fine to just use `formatEnv` here.
|
||||
vip.MustBindEnv(configKey, formatEnv(configKey))
|
||||
|
||||
value := vip.Get(configKey)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if instance == nil {
|
||||
newType := reflect.Indirect(reflect.New(elementType))
|
||||
instance = &newType
|
||||
}
|
||||
switch v := instance.Field(i).Type().String(); v {
|
||||
case "[]string":
|
||||
value = vip.GetStringSlice(configKey)
|
||||
case "bool":
|
||||
value = vip.GetBool(configKey)
|
||||
default:
|
||||
}
|
||||
instance.Field(i).Set(reflect.ValueOf(value))
|
||||
}
|
||||
if instance == nil {
|
||||
break
|
||||
}
|
||||
value, ok := instance.Interface().(T)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
returnValues = append(returnValues, value)
|
||||
}
|
||||
return returnValues
|
||||
}
|
||||
|
||||
func NewViper() *viper.Viper {
|
||||
dc := newConfig()
|
||||
vip := viper.New()
|
||||
vip.SetEnvPrefix("coder")
|
||||
vip.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
|
||||
setViperDefaults("", vip, dc)
|
||||
|
||||
return vip
|
||||
}
|
||||
|
||||
func setViperDefaults(prefix string, vip *viper.Viper, target interface{}) {
|
||||
val := reflect.ValueOf(target).Elem()
|
||||
val = reflect.Indirect(val)
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Default").Interface()
|
||||
vip.SetDefault(prefix, value)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setViperDefaults(key, vip, fv.Interface())
|
||||
case reflect.Slice:
|
||||
// we currently don't support default values on structured slices
|
||||
continue
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func AttachFlags(flagset *pflag.FlagSet, vip *viper.Viper, enterprise bool) {
|
||||
setFlags("", flagset, vip, newConfig(), enterprise)
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target interface{}, enterprise bool) {
|
||||
val := reflect.Indirect(reflect.ValueOf(target))
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
isEnt := val.FieldByName("Enterprise").Bool()
|
||||
if enterprise != isEnt {
|
||||
return
|
||||
}
|
||||
flg := val.FieldByName("Flag").String()
|
||||
if flg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
usage := val.FieldByName("Usage").String()
|
||||
usage = fmt.Sprintf("%s\n%s", usage, cliui.Styles.Placeholder.Render("Consumes $"+env))
|
||||
shorthand := val.FieldByName("Shorthand").String()
|
||||
hidden := val.FieldByName("Hidden").Bool()
|
||||
value := val.FieldByName("Default").Interface()
|
||||
|
||||
// Allow currently set environment variables
|
||||
// to override default values in help output.
|
||||
vip.MustBindEnv(prefix, env)
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
_ = flagset.StringP(flg, shorthand, vip.GetString(prefix), usage)
|
||||
case bool:
|
||||
_ = flagset.BoolP(flg, shorthand, vip.GetBool(prefix), usage)
|
||||
case int:
|
||||
_ = flagset.IntP(flg, shorthand, vip.GetInt(prefix), usage)
|
||||
case time.Duration:
|
||||
_ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage)
|
||||
case []string:
|
||||
_ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage)
|
||||
case []codersdk.GitAuthConfig:
|
||||
// Ignore this one!
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", typ))
|
||||
}
|
||||
|
||||
_ = vip.BindPFlag(prefix, flagset.Lookup(flg))
|
||||
if hidden {
|
||||
_ = flagset.MarkHidden(flg)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setFlags(key, flagset, vip, fv.Interface(), enterprise)
|
||||
case reflect.Slice:
|
||||
for j := 0; j < fv.Len(); j++ {
|
||||
key := fmt.Sprintf("%s.%d", key, j)
|
||||
setFlags(key, flagset, vip, fv.Index(j).Interface(), enterprise)
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatEnv(key string) string {
|
||||
return "CODER_" + strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(key))
|
||||
}
|
||||
|
||||
func DefaultCacheDir() string {
|
||||
defaultCacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
defaultCacheDir = os.TempDir()
|
||||
}
|
||||
if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" {
|
||||
// For compatibility with systemd.
|
||||
defaultCacheDir = dir
|
||||
}
|
||||
|
||||
return filepath.Join(defaultCacheDir, "coder")
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package deployment_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestConfig(t *testing.T) {
|
||||
viper := deployment.NewViper()
|
||||
flagSet := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
flagSet.String(config.FlagName, "", "")
|
||||
deployment.AttachFlags(flagSet, viper, true)
|
||||
|
||||
for _, tc := range []struct {
|
||||
Name string
|
||||
Env map[string]string
|
||||
Valid func(config *codersdk.DeploymentConfig)
|
||||
}{{
|
||||
Name: "Deployment",
|
||||
Env: map[string]string{
|
||||
"CODER_ADDRESS": "0.0.0.0:8443",
|
||||
"CODER_ACCESS_URL": "https://dev.coder.com",
|
||||
"CODER_PG_CONNECTION_URL": "some-url",
|
||||
"CODER_PPROF_ADDRESS": "something",
|
||||
"CODER_PPROF_ENABLE": "true",
|
||||
"CODER_PROMETHEUS_ADDRESS": "hello-world",
|
||||
"CODER_PROMETHEUS_ENABLE": "true",
|
||||
"CODER_PROVISIONER_DAEMONS": "5",
|
||||
"CODER_PROVISIONER_DAEMON_POLL_INTERVAL": "5s",
|
||||
"CODER_PROVISIONER_DAEMON_POLL_JITTER": "1s",
|
||||
"CODER_SECURE_AUTH_COOKIE": "true",
|
||||
"CODER_SSH_KEYGEN_ALGORITHM": "potato",
|
||||
"CODER_TELEMETRY": "false",
|
||||
"CODER_TELEMETRY_TRACE": "false",
|
||||
"CODER_WILDCARD_ACCESS_URL": "something-wildcard.com",
|
||||
"CODER_UPDATE_CHECK": "false",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.Address.Value, "0.0.0.0:8443")
|
||||
require.Equal(t, config.AccessURL.Value, "https://dev.coder.com")
|
||||
require.Equal(t, config.PostgresURL.Value, "some-url")
|
||||
require.Equal(t, config.Pprof.Address.Value, "something")
|
||||
require.Equal(t, config.Pprof.Enable.Value, true)
|
||||
require.Equal(t, config.Prometheus.Address.Value, "hello-world")
|
||||
require.Equal(t, config.Prometheus.Enable.Value, true)
|
||||
require.Equal(t, config.Provisioner.Daemons.Value, 5)
|
||||
require.Equal(t, config.Provisioner.DaemonPollInterval.Value, 5*time.Second)
|
||||
require.Equal(t, config.Provisioner.DaemonPollJitter.Value, 1*time.Second)
|
||||
require.Equal(t, config.SecureAuthCookie.Value, true)
|
||||
require.Equal(t, config.SSHKeygenAlgorithm.Value, "potato")
|
||||
require.Equal(t, config.Telemetry.Enable.Value, false)
|
||||
require.Equal(t, config.Telemetry.Trace.Value, false)
|
||||
require.Equal(t, config.WildcardAccessURL.Value, "something-wildcard.com")
|
||||
require.Equal(t, config.UpdateCheck.Value, false)
|
||||
},
|
||||
}, {
|
||||
Name: "DERP",
|
||||
Env: map[string]string{
|
||||
"CODER_DERP_CONFIG_PATH": "/example/path",
|
||||
"CODER_DERP_CONFIG_URL": "https://google.com",
|
||||
"CODER_DERP_SERVER_ENABLE": "false",
|
||||
"CODER_DERP_SERVER_REGION_CODE": "something",
|
||||
"CODER_DERP_SERVER_REGION_ID": "123",
|
||||
"CODER_DERP_SERVER_REGION_NAME": "Code-Land",
|
||||
"CODER_DERP_SERVER_RELAY_URL": "1.1.1.1",
|
||||
"CODER_DERP_SERVER_STUN_ADDRESSES": "google.org",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.DERP.Config.Path.Value, "/example/path")
|
||||
require.Equal(t, config.DERP.Config.URL.Value, "https://google.com")
|
||||
require.Equal(t, config.DERP.Server.Enable.Value, false)
|
||||
require.Equal(t, config.DERP.Server.RegionCode.Value, "something")
|
||||
require.Equal(t, config.DERP.Server.RegionID.Value, 123)
|
||||
require.Equal(t, config.DERP.Server.RegionName.Value, "Code-Land")
|
||||
require.Equal(t, config.DERP.Server.RelayURL.Value, "1.1.1.1")
|
||||
require.Equal(t, config.DERP.Server.STUNAddresses.Value, []string{"google.org"})
|
||||
},
|
||||
}, {
|
||||
Name: "Enterprise",
|
||||
Env: map[string]string{
|
||||
"CODER_AUDIT_LOGGING": "false",
|
||||
"CODER_BROWSER_ONLY": "true",
|
||||
"CODER_SCIM_API_KEY": "some-key",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.AuditLogging.Value, false)
|
||||
require.Equal(t, config.BrowserOnly.Value, true)
|
||||
require.Equal(t, config.SCIMAPIKey.Value, "some-key")
|
||||
},
|
||||
}, {
|
||||
Name: "TLS",
|
||||
Env: map[string]string{
|
||||
"CODER_TLS_CERT_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
|
||||
"CODER_TLS_KEY_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
|
||||
"CODER_TLS_CLIENT_AUTH": "/some/path",
|
||||
"CODER_TLS_CLIENT_CA_FILE": "/some/path",
|
||||
"CODER_TLS_ENABLE": "true",
|
||||
"CODER_TLS_MIN_VERSION": "tls10",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Len(t, config.TLS.CertFiles.Value, 2)
|
||||
require.Equal(t, config.TLS.CertFiles.Value[0], "/etc/acme-sh/dev.coder.com")
|
||||
require.Equal(t, config.TLS.CertFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
|
||||
|
||||
require.Len(t, config.TLS.KeyFiles.Value, 2)
|
||||
require.Equal(t, config.TLS.KeyFiles.Value[0], "/etc/acme-sh/dev.coder.com")
|
||||
require.Equal(t, config.TLS.KeyFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
|
||||
|
||||
require.Equal(t, config.TLS.ClientAuth.Value, "/some/path")
|
||||
require.Equal(t, config.TLS.ClientCAFile.Value, "/some/path")
|
||||
require.Equal(t, config.TLS.Enable.Value, true)
|
||||
require.Equal(t, config.TLS.MinVersion.Value, "tls10")
|
||||
},
|
||||
}, {
|
||||
Name: "Trace",
|
||||
Env: map[string]string{
|
||||
"CODER_TRACE_ENABLE": "true",
|
||||
"CODER_TRACE_HONEYCOMB_API_KEY": "my-honeycomb-key",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.Trace.Enable.Value, true)
|
||||
require.Equal(t, config.Trace.HoneycombAPIKey.Value, "my-honeycomb-key")
|
||||
},
|
||||
}, {
|
||||
Name: "OIDC_Defaults",
|
||||
Env: map[string]string{},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Empty(t, config.OIDC.IssuerURL.Value)
|
||||
require.Empty(t, config.OIDC.EmailDomain.Value)
|
||||
require.Empty(t, config.OIDC.ClientID.Value)
|
||||
require.Empty(t, config.OIDC.ClientSecret.Value)
|
||||
require.True(t, config.OIDC.AllowSignups.Value)
|
||||
require.ElementsMatch(t, config.OIDC.Scopes.Value, []string{"openid", "email", "profile"})
|
||||
require.False(t, config.OIDC.IgnoreEmailVerified.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "OIDC",
|
||||
Env: map[string]string{
|
||||
"CODER_OIDC_ISSUER_URL": "https://accounts.google.com",
|
||||
"CODER_OIDC_EMAIL_DOMAIN": "coder.com",
|
||||
"CODER_OIDC_CLIENT_ID": "client",
|
||||
"CODER_OIDC_CLIENT_SECRET": "secret",
|
||||
"CODER_OIDC_ALLOW_SIGNUPS": "false",
|
||||
"CODER_OIDC_SCOPES": "something,here",
|
||||
"CODER_OIDC_IGNORE_EMAIL_VERIFIED": "true",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.OIDC.IssuerURL.Value, "https://accounts.google.com")
|
||||
require.Equal(t, config.OIDC.EmailDomain.Value, []string{"coder.com"})
|
||||
require.Equal(t, config.OIDC.ClientID.Value, "client")
|
||||
require.Equal(t, config.OIDC.ClientSecret.Value, "secret")
|
||||
require.False(t, config.OIDC.AllowSignups.Value)
|
||||
require.Equal(t, config.OIDC.Scopes.Value, []string{"something", "here"})
|
||||
require.True(t, config.OIDC.IgnoreEmailVerified.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "GitHub",
|
||||
Env: map[string]string{
|
||||
"CODER_OAUTH2_GITHUB_CLIENT_ID": "client",
|
||||
"CODER_OAUTH2_GITHUB_CLIENT_SECRET": "secret",
|
||||
"CODER_OAUTH2_GITHUB_ALLOWED_ORGS": "coder",
|
||||
"CODER_OAUTH2_GITHUB_ALLOWED_TEAMS": "coder",
|
||||
"CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS": "true",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.OAuth2.Github.ClientID.Value, "client")
|
||||
require.Equal(t, config.OAuth2.Github.ClientSecret.Value, "secret")
|
||||
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedOrgs.Value)
|
||||
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedTeams.Value)
|
||||
require.Equal(t, config.OAuth2.Github.AllowSignups.Value, true)
|
||||
},
|
||||
}, {
|
||||
Name: "GitAuth",
|
||||
Env: map[string]string{
|
||||
"CODER_GITAUTH_0_ID": "hello",
|
||||
"CODER_GITAUTH_0_TYPE": "github",
|
||||
"CODER_GITAUTH_0_CLIENT_ID": "client",
|
||||
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
|
||||
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
|
||||
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
|
||||
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
|
||||
"CODER_GITAUTH_0_REGEX": "github.com",
|
||||
"CODER_GITAUTH_0_SCOPES": "read write",
|
||||
"CODER_GITAUTH_0_NO_REFRESH": "true",
|
||||
|
||||
"CODER_GITAUTH_1_ID": "another",
|
||||
"CODER_GITAUTH_1_TYPE": "gitlab",
|
||||
"CODER_GITAUTH_1_CLIENT_ID": "client-2",
|
||||
"CODER_GITAUTH_1_CLIENT_SECRET": "secret-2",
|
||||
"CODER_GITAUTH_1_AUTH_URL": "https://auth-2.com",
|
||||
"CODER_GITAUTH_1_TOKEN_URL": "https://token-2.com",
|
||||
"CODER_GITAUTH_1_REGEX": "gitlab.com",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Len(t, config.GitAuth.Value, 2)
|
||||
require.Equal(t, []codersdk.GitAuthConfig{{
|
||||
ID: "hello",
|
||||
Type: "github",
|
||||
ClientID: "client",
|
||||
ClientSecret: "secret",
|
||||
AuthURL: "https://auth.com",
|
||||
TokenURL: "https://token.com",
|
||||
ValidateURL: "https://validate.com",
|
||||
Regex: "github.com",
|
||||
Scopes: []string{"read", "write"},
|
||||
NoRefresh: true,
|
||||
}, {
|
||||
ID: "another",
|
||||
Type: "gitlab",
|
||||
ClientID: "client-2",
|
||||
ClientSecret: "secret-2",
|
||||
AuthURL: "https://auth-2.com",
|
||||
TokenURL: "https://token-2.com",
|
||||
Regex: "gitlab.com",
|
||||
}}, config.GitAuth.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "Wrong env must not break default values",
|
||||
Env: map[string]string{
|
||||
"CODER_PROMETHEUS_ENABLE": "true",
|
||||
"CODER_PROMETHEUS": "true", // Wrong env name, must not break prom addr.
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
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) {
|
||||
t.Helper()
|
||||
for key, value := range tc.Env {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
config, err := deployment.Config(flagSet, viper)
|
||||
require.NoError(t, err)
|
||||
tc.Valid(config)
|
||||
})
|
||||
}
|
||||
}
|
||||
+48
-39
@@ -10,30 +10,29 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func dotfiles() *cobra.Command {
|
||||
func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
var symlinkDir string
|
||||
cmd := &cobra.Command{
|
||||
Use: "dotfiles [git_repo_url]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Checkout and install a dotfiles repository from a Git URL",
|
||||
Example: formatExamples(
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "dotfiles <git_repo_url>",
|
||||
Middleware: clibase.RequireNArgs(1),
|
||||
Short: "Personalize your workspace by applying a canonical dotfiles repository",
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Description: "Check out and install a dotfiles repository without prompts",
|
||||
Command: "coder dotfiles --yes git@github.com:example/dotfiles.git",
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var (
|
||||
dotfilesRepoDir = "dotfiles"
|
||||
gitRepo = args[0]
|
||||
cfg = createConfig(cmd)
|
||||
gitRepo = inv.Args[0]
|
||||
cfg = r.createConfig()
|
||||
cfgDir = string(cfg)
|
||||
dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir)
|
||||
// This follows the same pattern outlined by others in the market:
|
||||
@@ -50,7 +49,11 @@ func dotfiles() *cobra.Command {
|
||||
}
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Checking if dotfiles repository already exists...\n")
|
||||
if cfg == "" {
|
||||
return xerrors.Errorf("no config directory")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(inv.Stdout, "Checking if dotfiles repository already exists...\n")
|
||||
dotfilesExists, err := dirExists(dotfilesDir)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err)
|
||||
@@ -65,7 +68,7 @@ func dotfiles() *cobra.Command {
|
||||
// if the git url has changed we create a backup and clone fresh
|
||||
if gitRepo != du {
|
||||
backupDir := fmt.Sprintf("%s_backup_%s", dotfilesDir, time.Now().Format(time.RFC3339))
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("The dotfiles URL has changed from %q to %q.\n Coder will backup the existing repo to %s.\n\n Continue?", du, gitRepo, backupDir),
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -77,7 +80,7 @@ func dotfiles() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("renaming dir %s: %w", dotfilesDir, err)
|
||||
}
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Done backup up dotfiles.\n")
|
||||
_, _ = fmt.Fprint(inv.Stdout, "Done backup up dotfiles.\n")
|
||||
dotfilesExists = false
|
||||
moved = true
|
||||
}
|
||||
@@ -89,20 +92,20 @@ func dotfiles() *cobra.Command {
|
||||
promptText string
|
||||
)
|
||||
if dotfilesExists {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Found dotfiles repository at %s\n", dotfilesDir)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Found dotfiles repository at %s\n", dotfilesDir)
|
||||
gitCmdDir = dotfilesDir
|
||||
subcommands = []string{"pull", "--ff-only"}
|
||||
promptText = fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?", gitRepo, dotfilesDir)
|
||||
} else {
|
||||
if !moved {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Did not find dotfiles repository at %s\n", dotfilesDir)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Did not find dotfiles repository at %s\n", dotfilesDir)
|
||||
}
|
||||
gitCmdDir = cfgDir
|
||||
subcommands = []string{"clone", args[0], dotfilesRepoDir}
|
||||
subcommands = []string{"clone", inv.Args[0], dotfilesRepoDir}
|
||||
promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir)
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: promptText,
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -111,9 +114,9 @@ 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)
|
||||
return xerrors.Errorf("ensuring dir at %q: %w", gitCmdDir, err)
|
||||
}
|
||||
|
||||
// check if git ssh command already exists so we can just wrap it
|
||||
@@ -123,18 +126,18 @@ func dotfiles() *cobra.Command {
|
||||
}
|
||||
|
||||
// clone or pull repo
|
||||
c := exec.CommandContext(cmd.Context(), "git", subcommands...)
|
||||
c := exec.CommandContext(inv.Context(), "git", subcommands...)
|
||||
c.Dir = gitCmdDir
|
||||
c.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
|
||||
c.Stdout = cmd.OutOrStdout()
|
||||
c.Stderr = cmd.ErrOrStderr()
|
||||
c.Env = append(inv.Environ.ToOS(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
|
||||
c.Stdout = inv.Stdout
|
||||
c.Stderr = inv.Stderr
|
||||
err = c.Run()
|
||||
if err != nil {
|
||||
if !dotfilesExists {
|
||||
return err
|
||||
}
|
||||
// if the repo exists we soft fail the update operation and try to continue
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Failed to update repo, continuing..."))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Error.Render("Failed to update repo, continuing..."))
|
||||
}
|
||||
|
||||
// save git repo url so we can detect changes next time
|
||||
@@ -158,7 +161,7 @@ func dotfiles() *cobra.Command {
|
||||
|
||||
script := findScript(installScriptSet, files)
|
||||
if script != "" {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script),
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -166,29 +169,29 @@ func dotfiles() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Running %s...\n", script)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Running %s...\n", script)
|
||||
// it is safe to use a variable command here because it's from
|
||||
// a filtered list of pre-approved install scripts
|
||||
// nolint:gosec
|
||||
scriptCmd := exec.CommandContext(cmd.Context(), filepath.Join(dotfilesDir, script))
|
||||
scriptCmd := exec.CommandContext(inv.Context(), filepath.Join(dotfilesDir, script))
|
||||
scriptCmd.Dir = dotfilesDir
|
||||
scriptCmd.Stdout = cmd.OutOrStdout()
|
||||
scriptCmd.Stderr = cmd.ErrOrStderr()
|
||||
scriptCmd.Stdout = inv.Stdout
|
||||
scriptCmd.Stderr = inv.Stderr
|
||||
err = scriptCmd.Run()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("running %s: %w", script, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(dotfiles) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "No install scripts or dotfiles found, nothing to do.")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "No install scripts found, symlinking dotfiles to home directory.\n\n Continue?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
@@ -206,7 +209,7 @@ func dotfiles() *cobra.Command {
|
||||
for _, df := range dotfiles {
|
||||
from := filepath.Join(dotfilesDir, df)
|
||||
to := filepath.Join(symlinkDir, df)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Symlinking %s to %s...\n", from, to)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Symlinking %s to %s...\n", from, to)
|
||||
|
||||
isRegular, err := isRegular(to)
|
||||
if err != nil {
|
||||
@@ -215,7 +218,7 @@ func dotfiles() *cobra.Command {
|
||||
// move conflicting non-symlink files to file.ext.bak
|
||||
if isRegular {
|
||||
backup := fmt.Sprintf("%s.bak", to)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Moving %s to %s...\n", to, backup)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Moving %s to %s...\n", to, backup)
|
||||
err = os.Rename(to, backup)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("renaming dir %s: %w", to, err)
|
||||
@@ -228,13 +231,19 @@ func dotfiles() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cliflag.StringVarP(cmd.Flags(), &symlinkDir, "symlink-dir", "", "CODER_SYMLINK_DIR", "", "Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.")
|
||||
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "symlink-dir",
|
||||
Env: "CODER_SYMLINK_DIR",
|
||||
Description: "Specifies the directory for the dotfiles symlink destinations. If empty, will use $HOME.",
|
||||
Value: clibase.StringOf(&symlinkDir),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -15,19 +15,21 @@ import (
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestDotfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("MissingArg", func(t *testing.T) {
|
||||
cmd, _ := clitest.New(t, "dotfiles")
|
||||
err := cmd.Execute()
|
||||
t.Parallel()
|
||||
inv, _ := clitest.New(t, "dotfiles")
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("NoInstallScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, root := clitest.New(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")
|
||||
@@ -40,8 +42,8 @@ func TestDotfiles(t *testing.T) {
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
@@ -49,6 +51,7 @@ func TestDotfiles(t *testing.T) {
|
||||
require.Equal(t, string(b), "wow")
|
||||
})
|
||||
t.Run("InstallScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("install scripts on windows require sh and aren't very practical")
|
||||
}
|
||||
@@ -56,7 +59,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")
|
||||
@@ -69,8 +72,8 @@ func TestDotfiles(t *testing.T) {
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
@@ -78,16 +81,17 @@ func TestDotfiles(t *testing.T) {
|
||||
require.Equal(t, string(b), "wow\n")
|
||||
})
|
||||
t.Run("SymlinkBackup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, root := clitest.New(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")
|
||||
@@ -100,8 +104,8 @@ func TestDotfiles(t *testing.T) {
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
@@ -119,7 +123,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")
|
||||
|
||||
+17
-18
@@ -7,9 +7,9 @@ import (
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
@@ -18,63 +18,62 @@ import (
|
||||
|
||||
// gitAskpass is used by the Coder agent to automatically authenticate
|
||||
// with Git providers based on a hostname.
|
||||
func gitAskpass() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
func (r *RootCmd) gitAskpass() *clibase.Cmd {
|
||||
return &clibase.Cmd{
|
||||
Use: "gitaskpass",
|
||||
Hidden: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer stop()
|
||||
|
||||
user, host, err := gitauth.ParseAskpass(args[0])
|
||||
user, host, err := gitauth.ParseAskpass(inv.Args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse host: %w", err)
|
||||
}
|
||||
|
||||
client, err := createAgentClient(cmd)
|
||||
client, err := r.createAgentClient()
|
||||
if err != nil {
|
||||
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 {
|
||||
// This prevents the "Run 'coder --help' for usage"
|
||||
// message from occurring.
|
||||
cmd.Printf("%s\n", apiError.Message)
|
||||
cliui.Errorf(inv.Stderr, "%s\n", apiError.Message)
|
||||
return cliui.Canceled
|
||||
}
|
||||
return xerrors.Errorf("get git token: %w", err)
|
||||
}
|
||||
if token.URL != "" {
|
||||
if err := openURL(cmd, token.URL); err == nil {
|
||||
cmd.Printf("Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
if err := openURL(inv, token.URL); err == nil {
|
||||
cliui.Infof(inv.Stdout, "Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
} else {
|
||||
cmd.Printf("Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
cliui.Infof(inv.Stdout, "Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
cmd.Printf("You've been authenticated with Git!\n")
|
||||
cliui.Infof(inv.Stdout, "You've been authenticated with Git!\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if token.Password != "" {
|
||||
if user == "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, token.Username)
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Password)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, token.Password)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, token.Username)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+25
-20
@@ -14,37 +14,39 @@ 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"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestGitAskpass(t *testing.T) {
|
||||
t.Setenv("GIT_PREFIX", "/")
|
||||
t.Parallel()
|
||||
t.Run("UsernameAndPassword", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':")
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':")
|
||||
inv.Environ.Set("GIT_PREFIX", "/")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
pty.ExpectMatch("something")
|
||||
|
||||
cmd, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':")
|
||||
inv, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':")
|
||||
inv.Environ.Set("GIT_PREFIX", "/")
|
||||
pty = ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
pty.ExpectMatch("bananas")
|
||||
})
|
||||
|
||||
t.Run("NoHost", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Nope!",
|
||||
@@ -52,17 +54,19 @@ func TestGitAskpass(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
inv.Environ.Set("GIT_PREFIX", "/")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err := cmd.Execute()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.ErrorIs(t, err, cliui.Canceled)
|
||||
pty.ExpectMatch("Nope!")
|
||||
})
|
||||
|
||||
t.Run("Poll", func(t *testing.T) {
|
||||
resp := atomic.Pointer[codersdk.WorkspaceAgentGitAuthResponse]{}
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
t.Parallel()
|
||||
resp := atomic.Pointer[agentsdk.GitAuthResponse]{}
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
URL: "https://something.org",
|
||||
})
|
||||
poll := make(chan struct{}, 10)
|
||||
@@ -80,15 +84,16 @@ func TestGitAskpass(t *testing.T) {
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
inv.Environ.Set("GIT_PREFIX", "/")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
inv.Stdout = pty.Output()
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
<-poll
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
|
||||
+18
-17
@@ -12,19 +12,19 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func gitssh() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
func (r *RootCmd) gitssh() *clibase.Cmd {
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "gitssh",
|
||||
Hidden: true,
|
||||
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
env := os.Environ()
|
||||
|
||||
// Catch interrupt signals to ensure the temporary private
|
||||
@@ -33,16 +33,16 @@ func gitssh() *cobra.Command {
|
||||
defer stop()
|
||||
|
||||
// Early check so errors are reported immediately.
|
||||
identityFiles, err := parseIdentityFilesForHost(ctx, args, env)
|
||||
identityFiles, err := parseIdentityFilesForHost(ctx, inv.Args, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createAgentClient(cmd)
|
||||
client, err := r.createAgentClient()
|
||||
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)
|
||||
}
|
||||
@@ -78,24 +78,25 @@ func gitssh() *cobra.Command {
|
||||
identityArgs = append(identityArgs, "-i", id)
|
||||
}
|
||||
|
||||
args := inv.Args
|
||||
args = append(identityArgs, args...)
|
||||
c := exec.CommandContext(ctx, "ssh", args...)
|
||||
c.Env = append(c.Env, env...)
|
||||
c.Stderr = cmd.ErrOrStderr()
|
||||
c.Stdout = cmd.OutOrStdout()
|
||||
c.Stdin = cmd.InOrStdin()
|
||||
c.Stderr = inv.Stderr
|
||||
c.Stdout = inv.Stdout
|
||||
c.Stdin = inv.Stdin
|
||||
err = c.Run()
|
||||
if err != nil {
|
||||
exitErr := &exec.ExitError{}
|
||||
if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(),
|
||||
_, _ = fmt.Fprintln(inv.Stderr,
|
||||
"\n"+cliui.Styles.Wrap.Render("Coder authenticates with "+cliui.Styles.Field.Render("git")+
|
||||
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Add to GitHub and GitLab:")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
return err
|
||||
}
|
||||
return xerrors.Errorf("run ssh command: %w", err)
|
||||
|
||||
+16
-34
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -48,23 +47,9 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
|
||||
// setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -72,15 +57,12 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// start workspace agent
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
agentClient := client
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
t.Cleanup(func() { require.NoError(t, <-errC) })
|
||||
clitest.Start(t, inv)
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
return agentClient, agentToken, pubkey
|
||||
}
|
||||
@@ -156,7 +138,7 @@ func TestGitSSH(t *testing.T) {
|
||||
}, pubkey)
|
||||
|
||||
// set to agent config dir
|
||||
cmd, _ := clitest.New(t,
|
||||
inv, _ := clitest.New(t,
|
||||
"gitssh",
|
||||
"--agent-url", client.URL.String(),
|
||||
"--agent-token", token,
|
||||
@@ -166,7 +148,7 @@ func TestGitSSH(t *testing.T) {
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"127.0.0.1",
|
||||
)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, inc)
|
||||
|
||||
@@ -228,10 +210,10 @@ func TestGitSSH(t *testing.T) {
|
||||
"mytest",
|
||||
}
|
||||
// Test authentication via local private key.
|
||||
cmd, _ := clitest.New(t, cmdArgs...)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
inv, _ := clitest.New(t, cmdArgs...)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case key := <-authkey:
|
||||
@@ -245,10 +227,10 @@ func TestGitSSH(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// With the local file deleted, the coder key should be used.
|
||||
cmd, _ = clitest.New(t, cmdArgs...)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
inv, _ = clitest.New(t, cmdArgs...)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case key := <-authkey:
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
//go:embed help.tpl
|
||||
var helpTemplateRaw string
|
||||
|
||||
type optionGroup struct {
|
||||
Name string
|
||||
Description string
|
||||
Options clibase.OptionSet
|
||||
}
|
||||
|
||||
func ttyWidth() int {
|
||||
width, _, err := terminal.GetSize(0)
|
||||
if err != nil {
|
||||
return 80
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// wrapTTY wraps a string to the width of the terminal, or 80 no terminal
|
||||
// is detected.
|
||||
func wrapTTY(s string) string {
|
||||
return wordwrap.WrapString(s, uint(ttyWidth()))
|
||||
}
|
||||
|
||||
var usageTemplate = template.Must(
|
||||
template.New("usage").Funcs(
|
||||
template.FuncMap{
|
||||
"wrapTTY": func(s string) string {
|
||||
return wrapTTY(s)
|
||||
},
|
||||
"trimNewline": func(s string) string {
|
||||
return strings.TrimSuffix(s, "\n")
|
||||
},
|
||||
"typeHelper": func(opt *clibase.Option) string {
|
||||
switch v := opt.Value.(type) {
|
||||
case *clibase.Enum:
|
||||
return strings.Join(v.Choices, "|")
|
||||
default:
|
||||
return v.Type()
|
||||
}
|
||||
},
|
||||
"joinStrings": func(s []string) string {
|
||||
return strings.Join(s, ", ")
|
||||
},
|
||||
"indent": func(body string, spaces int) string {
|
||||
twidth := ttyWidth()
|
||||
|
||||
spacing := strings.Repeat(" ", spaces)
|
||||
|
||||
body = wordwrap.WrapString(body, uint(twidth-len(spacing)))
|
||||
|
||||
var sb strings.Builder
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
// Remove existing indent, if any.
|
||||
line = strings.TrimSpace(line)
|
||||
// Use spaces so we can easily calculate wrapping.
|
||||
_, _ = sb.WriteString(spacing)
|
||||
_, _ = sb.WriteString(line)
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
},
|
||||
"formatSubcommand": func(cmd *clibase.Cmd) string {
|
||||
// Minimize padding by finding the longest neighboring name.
|
||||
maxNameLength := len(cmd.Name())
|
||||
if parent := cmd.Parent; parent != nil {
|
||||
for _, c := range parent.Children {
|
||||
if len(c.Name()) > maxNameLength {
|
||||
maxNameLength = len(c.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
_, _ = fmt.Fprintf(
|
||||
&sb, "%s%s%s",
|
||||
strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4),
|
||||
)
|
||||
|
||||
// This is the point at which indentation begins if there's a
|
||||
// next line.
|
||||
descStart := sb.Len()
|
||||
|
||||
twidth := ttyWidth()
|
||||
|
||||
for i, line := range strings.Split(
|
||||
wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n",
|
||||
) {
|
||||
if i > 0 {
|
||||
_, _ = sb.WriteString(strings.Repeat(" ", descStart))
|
||||
}
|
||||
_, _ = sb.WriteString(line)
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
},
|
||||
"envName": func(opt clibase.Option) string {
|
||||
if opt.Env == "" {
|
||||
return ""
|
||||
}
|
||||
return opt.Env
|
||||
},
|
||||
"flagName": func(opt clibase.Option) string {
|
||||
return opt.Flag
|
||||
},
|
||||
"prettyHeader": func(s string) string {
|
||||
return cliui.Styles.Bold.Render(s)
|
||||
},
|
||||
"isEnterprise": func(opt clibase.Option) bool {
|
||||
return opt.Annotations.IsSet("enterprise")
|
||||
},
|
||||
"isDeprecated": func(opt clibase.Option) bool {
|
||||
return len(opt.UseInstead) > 0
|
||||
},
|
||||
"formatLong": func(long string) string {
|
||||
// We intentionally don't wrap here because it would misformat
|
||||
// examples, where the new line would start without the prior
|
||||
// line's indentation.
|
||||
return strings.TrimSpace(long)
|
||||
},
|
||||
"formatGroupDescription": func(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = s + "\n"
|
||||
s = wrapTTY(s)
|
||||
return s
|
||||
},
|
||||
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
|
||||
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
|
||||
return !c.Hidden
|
||||
})
|
||||
},
|
||||
"optionGroups": func(cmd *clibase.Cmd) []optionGroup {
|
||||
groups := []optionGroup{{
|
||||
// Default group.
|
||||
Name: "",
|
||||
Description: "",
|
||||
}}
|
||||
|
||||
enterpriseGroup := optionGroup{
|
||||
Name: "Enterprise",
|
||||
Description: `These options are only available in the Enterprise Edition.`,
|
||||
}
|
||||
|
||||
// Sort options lexicographically.
|
||||
sort.Slice(cmd.Options, func(i, j int) bool {
|
||||
return cmd.Options[i].Name < cmd.Options[j].Name
|
||||
})
|
||||
|
||||
optionLoop:
|
||||
for _, opt := range cmd.Options {
|
||||
if opt.Hidden {
|
||||
continue
|
||||
}
|
||||
// Enterprise options are always grouped separately.
|
||||
if opt.Annotations.IsSet("enterprise") {
|
||||
enterpriseGroup.Options = append(enterpriseGroup.Options, opt)
|
||||
continue
|
||||
}
|
||||
if len(opt.Group.Ancestry()) == 0 {
|
||||
// Just add option to default group.
|
||||
groups[0].Options = append(groups[0].Options, opt)
|
||||
continue
|
||||
}
|
||||
|
||||
groupName := opt.Group.FullName()
|
||||
|
||||
for i, foundGroup := range groups {
|
||||
if foundGroup.Name != groupName {
|
||||
continue
|
||||
}
|
||||
groups[i].Options = append(groups[i].Options, opt)
|
||||
continue optionLoop
|
||||
}
|
||||
|
||||
groups = append(groups, optionGroup{
|
||||
Name: groupName,
|
||||
Description: opt.Group.Description,
|
||||
Options: clibase.OptionSet{opt},
|
||||
})
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
// Sort groups lexicographically.
|
||||
return groups[i].Name < groups[j].Name
|
||||
})
|
||||
|
||||
// Always show enterprise group last.
|
||||
groups = append(groups, enterpriseGroup)
|
||||
|
||||
return filterSlice(groups, func(g optionGroup) bool {
|
||||
return len(g.Options) > 0
|
||||
})
|
||||
},
|
||||
},
|
||||
).Parse(helpTemplateRaw),
|
||||
)
|
||||
|
||||
func filterSlice[T any](s []T, f func(T) bool) []T {
|
||||
var r []T
|
||||
for _, v := range s {
|
||||
if f(v) {
|
||||
r = append(r, v)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newLineLimiter makes working with Go templates more bearable. Without this,
|
||||
// modifying the template is a slow toil of counting newlines and constantly
|
||||
// checking that a change to one command's help doesn't clobber break another.
|
||||
type newlineLimiter struct {
|
||||
w io.Writer
|
||||
limit int
|
||||
|
||||
newLineCounter int
|
||||
}
|
||||
|
||||
func (lm *newlineLimiter) Write(p []byte) (int, error) {
|
||||
rd := bytes.NewReader(p)
|
||||
for r, n, _ := rd.ReadRune(); n > 0; r, n, _ = rd.ReadRune() {
|
||||
switch {
|
||||
case r == '\r':
|
||||
// Carriage returns can sneak into `help.tpl` when `git clone`
|
||||
// is configured to automatically convert line endings.
|
||||
continue
|
||||
case r == '\n':
|
||||
lm.newLineCounter++
|
||||
if lm.newLineCounter > lm.limit {
|
||||
continue
|
||||
}
|
||||
case !unicode.IsSpace(r):
|
||||
lm.newLineCounter = 0
|
||||
}
|
||||
_, err := lm.w.Write([]byte(string(r)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
var usageWantsArgRe = regexp.MustCompile(`<.*>`)
|
||||
|
||||
// helpFn returns a function that generates usage (help)
|
||||
// output for a given command.
|
||||
func helpFn() clibase.HandlerFunc {
|
||||
return func(inv *clibase.Invocation) error {
|
||||
// We use stdout for help and not stderr since there's no straightforward
|
||||
// way to distinguish between a user error and a help request.
|
||||
//
|
||||
// We buffer writes to stdout because the newlineLimiter writes one
|
||||
// rune at a time.
|
||||
outBuf := bufio.NewWriter(inv.Stdout)
|
||||
out := newlineLimiter{w: outBuf, limit: 2}
|
||||
tabwriter := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0)
|
||||
err := usageTemplate.Execute(tabwriter, inv.Command)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("execute template: %w", err)
|
||||
}
|
||||
err = tabwriter.Flush()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = outBuf.Flush()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unknown subcommand %q\n", inv.Args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{- /* Heavily inspired by the Go toolchain formatting. */ -}}
|
||||
Usage: {{.FullUsage}}
|
||||
|
||||
|
||||
{{ with .Short }}
|
||||
{{- wrapTTY . }}
|
||||
{{"\n"}}
|
||||
{{- end}}
|
||||
|
||||
{{ with .Aliases }}
|
||||
{{ "\n" }}
|
||||
{{ "Aliases:"}} {{ joinStrings .}}
|
||||
{{ "\n" }}
|
||||
{{- end }}
|
||||
|
||||
{{- with .Long}}
|
||||
{{- formatLong . }}
|
||||
{{ "\n" }}
|
||||
{{- end }}
|
||||
{{ with visibleChildren . }}
|
||||
{{- range $index, $child := . }}
|
||||
{{- if eq $index 0 }}
|
||||
{{ prettyHeader "Subcommands"}}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- formatSubcommand . | trimNewline }}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- end }}
|
||||
{{- range $index, $group := optionGroups . }}
|
||||
{{ with $group.Name }} {{- print $group.Name " Options" | prettyHeader }} {{ else -}} {{ prettyHeader "Options"}}{{- end -}}
|
||||
{{- with $group.Description }}
|
||||
{{ formatGroupDescription . }}
|
||||
{{- else }}
|
||||
{{- end }}
|
||||
{{- range $index, $option := $group.Options }}
|
||||
{{- if not (eq $option.FlagShorthand "") }}{{- print "\n -" $option.FlagShorthand ", " -}}
|
||||
{{- else }}{{- print "\n " -}}
|
||||
{{- end }}
|
||||
{{- with flagName $option }}--{{ . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }}
|
||||
{{- with envName $option }}, ${{ . }}{{ end }}
|
||||
{{- with $option.Default }} (default: {{ . }}){{ end }}
|
||||
{{- with $option.Description }}
|
||||
{{- $desc := $option.Description }}
|
||||
{{ indent $desc 10 }}
|
||||
{{- if isDeprecated $option }} DEPRECATED {{ end }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if .Parent }}
|
||||
Run `coder --help` for a list of global options.
|
||||
{{- else }}
|
||||
Report bugs and request features at https://github.com/coder/coder/issues/new
|
||||
{{- end }}
|
||||
+58
-53
@@ -2,26 +2,32 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"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,37 +53,39 @@ 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 {
|
||||
func (r *RootCmd) list() *clibase.Cmd {
|
||||
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{
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "list",
|
||||
Short: "List workspaces",
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
filter := codersdk.WorkspaceFilter{
|
||||
FilterQuery: searchQuery,
|
||||
}
|
||||
@@ -85,27 +93,19 @@ 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)
|
||||
res, err := client.Workspaces(inv.Context(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(res.Workspaces) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), " "+cliui.Styles.Code.Render("coder create <name>"))
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
_, _ = fmt.Fprintln(inv.Stderr, " "+cliui.Styles.Code.Render("coder create <name>"))
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
|
||||
userRes, err := client.Users(inv.Context(), codersdk.UsersRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,26 +121,31 @@ func list() *cobra.Command {
|
||||
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
||||
}
|
||||
|
||||
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
|
||||
out, err := formatter.Format(inv.Context(), displayWorkspaces)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "all",
|
||||
FlagShorthand: "a",
|
||||
Description: "Specifies whether all workspaces will be listed or not.",
|
||||
|
||||
availColumns, err := cliui.TableHeaders(displayWorkspaces)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
Value: clibase.BoolOf(&all),
|
||||
},
|
||||
{
|
||||
Flag: "search",
|
||||
Description: "Search for a workspace with a query.",
|
||||
Default: defaultQuery,
|
||||
Value: clibase.StringOf(&searchQuery),
|
||||
},
|
||||
}
|
||||
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.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+33
-5
@@ -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"
|
||||
)
|
||||
@@ -23,17 +27,15 @@ func TestList(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)
|
||||
cmd, root := clitest.New(t, "ls")
|
||||
inv, root := clitest.New(t, "ls")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
errC := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, errC)
|
||||
close(done)
|
||||
}()
|
||||
@@ -42,4 +44,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)
|
||||
|
||||
inv, 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)
|
||||
inv.Stdout = out
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
var templates []codersdk.Workspace
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &templates))
|
||||
require.Len(t, templates, 1)
|
||||
})
|
||||
}
|
||||
|
||||
+76
-55
@@ -14,11 +14,11 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ func init() {
|
||||
browser.Stdout = io.Discard
|
||||
}
|
||||
|
||||
func login() *cobra.Command {
|
||||
func (r *RootCmd) login() *clibase.Cmd {
|
||||
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
|
||||
|
||||
var (
|
||||
@@ -46,20 +46,16 @@ func login() *cobra.Command {
|
||||
password string
|
||||
trial bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Middleware: clibase.RequireRangeArgs(0, 1),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
rawURL := ""
|
||||
if len(args) == 0 {
|
||||
var err error
|
||||
rawURL, err = cmd.Flags().GetString(varURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get global url flag")
|
||||
}
|
||||
if len(inv.Args) == 0 {
|
||||
rawURL = r.clientURL.String()
|
||||
} else {
|
||||
rawURL = args[0]
|
||||
rawURL = inv.Args[0]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
@@ -78,7 +74,7 @@ func login() *cobra.Command {
|
||||
serverURL.Scheme = "https"
|
||||
}
|
||||
|
||||
client, err := createUnauthenticatedClient(cmd, serverURL)
|
||||
client, err := r.createUnauthenticatedClient(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -86,25 +82,25 @@ func login() *cobra.Command {
|
||||
// Try to check the version of the server prior to logging in.
|
||||
// It may be useful to warn the user if they are trying to login
|
||||
// on a very old client.
|
||||
err = checkVersions(cmd, client)
|
||||
err = r.checkVersions(inv, client)
|
||||
if err != nil {
|
||||
// Checking versions isn't a fatal error so we print a warning
|
||||
// and proceed.
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Warn.Render(err.Error()))
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Warn.Render(err.Error()))
|
||||
}
|
||||
|
||||
hasInitialUser, err := client.HasFirstUser(cmd.Context())
|
||||
hasInitialUser, err := client.HasFirstUser(inv.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
|
||||
}
|
||||
if !hasInitialUser {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
if username == "" {
|
||||
if !isTTY(cmd) {
|
||||
if !isTTY(inv) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: cliui.ConfirmYes,
|
||||
IsConfirm: true,
|
||||
@@ -119,7 +115,7 @@ func login() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
username, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "What " + cliui.Styles.Field.Render("username") + " would you like?",
|
||||
Default: currentUser.Username,
|
||||
})
|
||||
@@ -132,7 +128,7 @@ func login() *cobra.Command {
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
email, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
email, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "What's your " + cliui.Styles.Field.Render("email") + "?",
|
||||
Validate: func(s string) error {
|
||||
err := validator.New().Var(s, "email")
|
||||
@@ -151,17 +147,20 @@ func login() *cobra.Command {
|
||||
var matching bool
|
||||
|
||||
for !matching {
|
||||
password, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
password, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
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,
|
||||
confirm, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
@@ -169,13 +168,13 @@ func login() *cobra.Command {
|
||||
|
||||
matching = confirm == password
|
||||
if !matching {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Passwords do not match"))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Error.Render("Passwords do not match"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cmd.Flags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
|
||||
v, _ := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
|
||||
v, _ := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Start a 30-day trial of Enterprise?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
@@ -183,7 +182,7 @@ func login() *cobra.Command {
|
||||
trial = v == "yes" || v == "y"
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
_, err = client.CreateFirstUser(inv.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: password,
|
||||
@@ -192,7 +191,7 @@ func login() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create initial user: %w", err)
|
||||
}
|
||||
resp, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
resp, err := client.LoginWithPassword(inv.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
})
|
||||
@@ -201,7 +200,7 @@ func login() *cobra.Command {
|
||||
}
|
||||
|
||||
sessionToken := resp.SessionToken
|
||||
config := createConfig(cmd)
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
@@ -211,32 +210,32 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
_, _ = fmt.Fprintf(inv.Stdout,
|
||||
cliui.Styles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.Styles.Keyword.Render(username)))+"\n")
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
_, _ = fmt.Fprintf(inv.Stdout,
|
||||
cliui.Styles.Paragraph.Render("Get started by creating a template: "+cliui.Styles.Code.Render("coder templates init"))+"\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionToken, _ := cmd.Flags().GetString(varToken)
|
||||
sessionToken, _ := inv.ParsedFlags().GetString(varToken)
|
||||
if sessionToken == "" {
|
||||
authURL := *serverURL
|
||||
// Don't use filepath.Join, we don't want to use the os separator
|
||||
// for a url.
|
||||
authURL.Path = path.Join(serverURL.Path, "/cli-auth")
|
||||
if err := openURL(cmd, authURL.String()); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
|
||||
if err := openURL(inv, authURL.String()); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
}
|
||||
|
||||
sessionToken, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SetSessionToken(token)
|
||||
_, err := client.User(cmd.Context(), codersdk.Me)
|
||||
_, err := client.User(inv.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.New("That's not a valid token!")
|
||||
}
|
||||
@@ -250,12 +249,12 @@ func login() *cobra.Command {
|
||||
|
||||
// Login to get user data - verify it is OK before persisting
|
||||
client.SetSessionToken(sessionToken)
|
||||
resp, err := client.User(cmd.Context(), codersdk.Me)
|
||||
resp, err := client.User(inv.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
config := createConfig(cmd)
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
@@ -265,14 +264,36 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliflag.StringVarP(cmd.Flags(), &email, "first-user-email", "", "CODER_FIRST_USER_EMAIL", "", "Specifies an email address to use if creating the first user for the deployment.")
|
||||
cliflag.StringVarP(cmd.Flags(), &username, "first-user-username", "", "CODER_FIRST_USER_USERNAME", "", "Specifies a username to use if creating the first user for the deployment.")
|
||||
cliflag.StringVarP(cmd.Flags(), &password, "first-user-password", "", "CODER_FIRST_USER_PASSWORD", "", "Specifies a password to use if creating the first user for the deployment.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &trial, "first-user-trial", "", firstUserTrialEnv, false, "Specifies whether a trial license should be provisioned for the Coder deployment or not.")
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "first-user-email",
|
||||
Env: "CODER_FIRST_USER_EMAIL",
|
||||
Description: "Specifies an email address to use if creating the first user for the deployment.",
|
||||
Value: clibase.StringOf(&email),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-username",
|
||||
Env: "CODER_FIRST_USER_USERNAME",
|
||||
Description: "Specifies a username to use if creating the first user for the deployment.",
|
||||
Value: clibase.StringOf(&username),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-password",
|
||||
Env: "CODER_FIRST_USER_PASSWORD",
|
||||
Description: "Specifies a password to use if creating the first user for the deployment.",
|
||||
Value: clibase.StringOf(&password),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-trial",
|
||||
Env: firstUserTrialEnv,
|
||||
Description: "Specifies whether a trial license should be provisioned for the Coder deployment or not.",
|
||||
Value: clibase.BoolOf(&trial),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -289,8 +310,8 @@ func isWSL() (bool, error) {
|
||||
}
|
||||
|
||||
// openURL opens the provided URL via user's default browser
|
||||
func openURL(cmd *cobra.Command, urlToOpen string) error {
|
||||
noOpen, err := cmd.Flags().GetBool(varNoOpen)
|
||||
func openURL(inv *clibase.Invocation, urlToOpen string) error {
|
||||
noOpen, err := inv.ParsedFlags().GetBool(varNoOpen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -310,7 +331,7 @@ func openURL(cmd *cobra.Command, urlToOpen string) error {
|
||||
browserEnv := os.Getenv("BROWSER")
|
||||
if browserEnv != "" {
|
||||
browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen)
|
||||
cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh)
|
||||
cmd := exec.CommandContext(inv.Context(), "sh", "-c", browserSh)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err)
|
||||
|
||||
+26
-43
@@ -20,7 +20,7 @@ func TestLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
root, _ := clitest.New(t, "login", client.URL.String())
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
badLoginURL := "https://fcca2077f06e68aaf9"
|
||||
root, _ := clitest.New(t, "login", badLoginURL)
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
errMsg := fmt.Sprintf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser?", badLoginURL)
|
||||
require.ErrorContains(t, err, errMsg)
|
||||
})
|
||||
@@ -41,12 +41,10 @@ func TestLogin(t *testing.T) {
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -54,8 +52,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 {
|
||||
@@ -74,23 +72,17 @@ func TestLogin(t *testing.T) {
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
inv, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
|
||||
matches := []string{
|
||||
"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 {
|
||||
@@ -100,20 +92,17 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
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).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
@@ -130,12 +119,10 @@ func TestLogin(t *testing.T) {
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
err := root.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -143,8 +130,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 +144,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")
|
||||
@@ -173,12 +160,10 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
@@ -197,12 +182,10 @@ func TestLogin(t *testing.T) {
|
||||
defer cancelFunc()
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
err := root.WithContext(ctx).Run()
|
||||
// An error is expected in this case, since the login wasn't successful:
|
||||
assert.Error(t, err)
|
||||
}()
|
||||
@@ -219,7 +202,7 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
|
||||
+15
-15
@@ -5,27 +5,28 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func logout() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
func (r *RootCmd) logout() *clibase.Cmd {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "logout",
|
||||
Short: "Unauthenticate your local session",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Middleware: clibase.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var errors []error
|
||||
|
||||
config := createConfig(cmd)
|
||||
config := r.createConfig()
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
var err error
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Are you sure you want to log out?",
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmYes,
|
||||
@@ -34,7 +35,7 @@ func logout() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.Logout(cmd.Context())
|
||||
err = client.Logout(inv.Context())
|
||||
if err != nil {
|
||||
errors = append(errors, xerrors.Errorf("logout api: %w", err))
|
||||
}
|
||||
@@ -67,11 +68,10 @@ func logout() *cobra.Command {
|
||||
errorString := strings.TrimRight(errorStringBuilder.String(), "\n")
|
||||
return xerrors.New("Failed to log out.\n" + errorString)
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cmd.Options = append(cmd.Options, cliui.SkipPromptOption())
|
||||
return cmd
|
||||
}
|
||||
|
||||
+33
-37
@@ -1,9 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
@@ -30,12 +28,12 @@ func TestLogout(t *testing.T) {
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
logout.Stdin = pty.Input()
|
||||
logout.Stdout = pty.Output()
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
err := logout.Run()
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, string(config.URL()))
|
||||
assert.NoFileExists(t, string(config.Session()))
|
||||
@@ -58,12 +56,12 @@ func TestLogout(t *testing.T) {
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config), "-y")
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
logout.Stdin = pty.Input()
|
||||
logout.Stdout = pty.Output()
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
err := logout.Run()
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, string(config.URL()))
|
||||
assert.NoFileExists(t, string(config.Session()))
|
||||
@@ -88,13 +86,13 @@ func TestLogout(t *testing.T) {
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
logout.Stdin = pty.Input()
|
||||
logout.Stdout = pty.Output()
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
err := logout.Run()
|
||||
assert.ErrorContains(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
}()
|
||||
|
||||
<-logoutChan
|
||||
@@ -115,13 +113,13 @@ func TestLogout(t *testing.T) {
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
logout.Stdin = pty.Input()
|
||||
logout.Stdout = pty.Output()
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err = logout.Execute()
|
||||
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
err = logout.Run()
|
||||
assert.ErrorContains(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
}()
|
||||
|
||||
<-logoutChan
|
||||
@@ -149,7 +147,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() {
|
||||
@@ -166,29 +164,27 @@ func TestLogout(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
logout.Stdin = pty.Input()
|
||||
logout.Stdout = pty.Output()
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.NotNil(t, err)
|
||||
var errorMessage string
|
||||
if runtime.GOOS == "windows" {
|
||||
errorMessage = "The process cannot access the file because it is being used by another process."
|
||||
} else {
|
||||
errorMessage = "permission denied"
|
||||
}
|
||||
errRegex := regexp.MustCompile(fmt.Sprintf("Failed to log out.\n\tremove URL file: .+: %s\n\tremove session file: .+: %s", errorMessage, errorMessage))
|
||||
assert.Regexp(t, errRegex, err.Error())
|
||||
pty.ExpectMatch("Are you sure you want to log out?")
|
||||
pty.WriteLine("yes")
|
||||
}()
|
||||
err = logout.Run()
|
||||
require.Error(t, err)
|
||||
|
||||
pty.ExpectMatch("Are you sure you want to log out?")
|
||||
pty.WriteLine("yes")
|
||||
<-logoutChan
|
||||
t.Logf("err: %v", err)
|
||||
|
||||
var wantError string
|
||||
if runtime.GOOS == "windows" {
|
||||
wantError = "The process cannot access the file because it is being used by another process."
|
||||
} else {
|
||||
wantError = "permission denied"
|
||||
}
|
||||
require.ErrorContains(t, err, wantError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,11 +196,11 @@ func login(t *testing.T, pty *ptytest.PTY) config.Root {
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
root, cfg := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
root.Stdin = pty.Input()
|
||||
root.Stdout = pty.Output()
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
|
||||
+26
-12
@@ -1,12 +1,14 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
@@ -15,20 +17,32 @@ import (
|
||||
// Throws an error if the file name is empty.
|
||||
func createParameterMapFromFile(parameterFile string) (map[string]string, error) {
|
||||
if parameterFile != "" {
|
||||
parameterMap := make(map[string]string)
|
||||
|
||||
parameterFileContents, err := os.ReadFile(parameterFile)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(parameterFileContents, ¶meterMap)
|
||||
|
||||
mapStringInterface := make(map[string]interface{})
|
||||
err = yaml.Unmarshal(parameterFileContents, &mapStringInterface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parameterMap := map[string]string{}
|
||||
for k, v := range mapStringInterface {
|
||||
switch val := v.(type) {
|
||||
case string, bool, int:
|
||||
parameterMap[k] = fmt.Sprintf("%v", val)
|
||||
case []interface{}:
|
||||
b, err := json.Marshal(&val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parameterMap[k] = string(b)
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid parameter type: %T", v)
|
||||
}
|
||||
}
|
||||
return parameterMap, nil
|
||||
}
|
||||
|
||||
@@ -37,20 +51,20 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error)
|
||||
|
||||
// Returns a parameter value from a given map, if the map does not exist or does not contain the item, it takes input from the user.
|
||||
// Throws an error if there are any errors with the users input.
|
||||
func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
func getParameterValueFromMapOrInput(inv *clibase.Invocation, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
var parameterValue string
|
||||
var err error
|
||||
if parameterMap != nil {
|
||||
var ok bool
|
||||
parameterValue, ok = parameterMap[parameterSchema.Name]
|
||||
if !ok {
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
parameterValue, err = cliui.ParameterSchema(inv, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
parameterValue, err = cliui.ParameterSchema(inv, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -58,20 +72,20 @@ 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) {
|
||||
func getWorkspaceBuildParameterValueFromMapOrInput(inv *clibase.Invocation, 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)
|
||||
parameterValue, err = cliui.RichParameter(inv, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parameterValue, err = cliui.RichParameter(cmd, templateVersionParameter)
|
||||
parameterValue, err = cliui.RichParameter(inv, templateVersionParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestCreateParameterMapFromFile(t *testing.T) {
|
||||
parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name())
|
||||
|
||||
assert.Nil(t, parameterMapFromFile)
|
||||
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string")
|
||||
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}")
|
||||
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
+6
-9
@@ -1,13 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
func parameters() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
func (r *RootCmd) parameters() *clibase.Cmd {
|
||||
cmd := &clibase.Cmd{
|
||||
Short: "List parameters for a given scope",
|
||||
Example: formatExamples(
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Command: "coder parameters list workspace my-workspace",
|
||||
},
|
||||
@@ -20,12 +20,9 @@ func parameters() *cobra.Command {
|
||||
// constructing curl requests.
|
||||
Hidden: true,
|
||||
Aliases: []string{"params"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
Children: []*clibase.Cmd{
|
||||
r.parameterList(),
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(
|
||||
parameterList(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+25
-23
@@ -4,30 +4,32 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameterList() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
func (r *RootCmd) parameterList() *clibase.Cmd {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
scope, name := args[0], args[1]
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(2),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
scope, name := inv.Args[0], inv.Args[1]
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := CurrentOrganization(cmd, client)
|
||||
organization, err := CurrentOrganization(inv, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current organization: %w", err)
|
||||
}
|
||||
@@ -35,13 +37,13 @@ func parameterList() *cobra.Command {
|
||||
var scopeID uuid.UUID
|
||||
switch codersdk.ParameterScope(scope) {
|
||||
case codersdk.ParameterWorkspace:
|
||||
workspace, err := namedWorkspace(cmd, client, name)
|
||||
workspace, err := namedWorkspace(inv.Context(), client, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopeID = workspace.ID
|
||||
case codersdk.ParameterTemplate:
|
||||
template, err := client.TemplateByName(cmd.Context(), organization.ID, name)
|
||||
template, err := client.TemplateByName(inv.Context(), organization.ID, name)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace template: %w", err)
|
||||
}
|
||||
@@ -55,7 +57,7 @@ func parameterList() *cobra.Command {
|
||||
|
||||
// Could be a template_version id or a job id. Check for the
|
||||
// version id.
|
||||
tv, err := client.TemplateVersion(cmd.Context(), scopeID)
|
||||
tv, err := client.TemplateVersion(inv.Context(), scopeID)
|
||||
if err == nil {
|
||||
scopeID = tv.Job.ID
|
||||
}
|
||||
@@ -66,21 +68,21 @@ func parameterList() *cobra.Command {
|
||||
})
|
||||
}
|
||||
|
||||
params, err := client.Parameters(cmd.Context(), codersdk.ParameterScope(scope), scopeID)
|
||||
params, err := client.Parameters(inv.Context(), codersdk.ParameterScope(scope), scopeID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch params: %w", err)
|
||||
}
|
||||
|
||||
out, err := cliui.DisplayTable(params, "name", columns)
|
||||
out, err := formatter.Format(inv.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)
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
|
||||
"Specify a column to filter in the table.")
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (r *RootCmd) ping() *clibase.Cmd {
|
||||
var (
|
||||
pingNum int64
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "ping <workspace>",
|
||||
Short: "Ping a workspace",
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
workspaceName := inv.Args[0]
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(
|
||||
ctx, inv, client,
|
||||
codersdk.Me, workspaceName,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var logger slog.Logger
|
||||
if r.verbose {
|
||||
logger = slog.Make(sloghuman.Sink(inv.Stdout)).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(inv.Stdout, "ping to %q timed out \n", workspaceName)
|
||||
if n == int(pingNum) {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err.Error() == "no matching peer" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q failed %s\n", workspaceName, err.Error())
|
||||
if n == int(pingNum) {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
dur = dur.Round(time.Millisecond)
|
||||
var via string
|
||||
if p2p {
|
||||
if !didP2p {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "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(inv.Stdout, "pong from %s %s in %s\n",
|
||||
cliui.Styles.Keyword.Render(workspaceName),
|
||||
via,
|
||||
cliui.Styles.DateTimeStamp.Render(dur.String()),
|
||||
)
|
||||
|
||||
if n == int(pingNum) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "wait",
|
||||
Description: "Specifies how long to wait between pings.",
|
||||
Default: "1s",
|
||||
Value: clibase.DurationOf(&pingWait),
|
||||
},
|
||||
{
|
||||
Flag: "timeout",
|
||||
FlagShorthand: "t",
|
||||
Default: "5s",
|
||||
Description: "Specifies how long to wait for a ping to complete.",
|
||||
Value: clibase.DurationOf(&pingTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "num",
|
||||
FlagShorthand: "n",
|
||||
Default: "10",
|
||||
Description: "Specifies the number of pings to perform.",
|
||||
Value: clibase.Int64Of(&pingNum),
|
||||
},
|
||||
}
|
||||
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)
|
||||
inv, root := clitest.New(t, "ping", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stderr = pty.Output()
|
||||
inv.Stdout = 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 := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
pty.ExpectMatch("pong from " + workspace.Name)
|
||||
cancel()
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
+39
-27
@@ -12,26 +12,25 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/pion/udp"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func portForward() *cobra.Command {
|
||||
func (r *RootCmd) portForward() *clibase.Cmd {
|
||||
var (
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "port-forward <workspace>",
|
||||
Short: "Forward ports from machine to a workspace",
|
||||
Aliases: []string{"tunnel"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: formatExamples(
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Description: "Port forward a single TCP port from 1234 in the workspace to port 5678 on your local machine",
|
||||
Command: "coder port-forward <workspace> --tcp 5678:1234",
|
||||
@@ -49,8 +48,12 @@ func portForward() *cobra.Command {
|
||||
Command: "coder port-forward <workspace> --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012",
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
specs, err := parsePortForwards(tcpForwards, udpForwards)
|
||||
@@ -58,19 +61,14 @@ func portForward() *cobra.Command {
|
||||
return xerrors.Errorf("parse port-forward specs: %w", err)
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
err = cmd.Help()
|
||||
err = inv.Command.HelpHandler(inv)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate help output: %w", err)
|
||||
}
|
||||
return xerrors.New("no port-forwards requested")
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -78,13 +76,13 @@ func portForward() *cobra.Command {
|
||||
return xerrors.New("workspace must be in start transition to port-forward")
|
||||
}
|
||||
if workspace.LatestBuild.Job.CompletedAt == nil {
|
||||
err = cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID)
|
||||
err = cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
err = cliui.Agent(ctx, inv.Stderr, cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
||||
@@ -116,7 +114,7 @@ func portForward() *cobra.Command {
|
||||
defer closeAllListeners()
|
||||
|
||||
for i, spec := range specs {
|
||||
l, err := listenAndPortForward(ctx, cmd, conn, wg, spec)
|
||||
l, err := listenAndPortForward(ctx, inv, conn, wg, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,7 +135,7 @@ func portForward() *cobra.Command {
|
||||
case <-ctx.Done():
|
||||
closeErr = ctx.Err()
|
||||
case <-sigs:
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "\nReceived signal, closing all listeners and active connections")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nReceived signal, closing all listeners and active connections")
|
||||
}
|
||||
|
||||
cancel()
|
||||
@@ -145,19 +143,33 @@ func portForward() *cobra.Command {
|
||||
}()
|
||||
|
||||
conn.AwaitReachable(ctx)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Ready!")
|
||||
wg.Wait()
|
||||
return closeErr
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &tcpForwards, "tcp", "p", "CODER_PORT_FORWARD_TCP", nil, "Forward TCP port(s) from the workspace to the local machine")
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &udpForwards, "udp", "", "CODER_PORT_FORWARD_UDP", nil, "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "tcp",
|
||||
FlagShorthand: "p",
|
||||
Env: "CODER_PORT_FORWARD_TCP",
|
||||
Description: "Forward TCP port(s) from the workspace to the local machine.",
|
||||
Value: clibase.StringArrayOf(&tcpForwards),
|
||||
},
|
||||
{
|
||||
Flag: "udp",
|
||||
Env: "CODER_PORT_FORWARD_UDP",
|
||||
Description: "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols.",
|
||||
Value: clibase.StringArrayOf(&udpForwards),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.AgentConn, 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)
|
||||
func listenAndPortForward(ctx context.Context, inv *clibase.Invocation, conn *codersdk.WorkspaceAgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
|
||||
|
||||
var (
|
||||
l net.Listener
|
||||
@@ -200,8 +212,8 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd
|
||||
if xerrors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Killing listener")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,7 +221,7 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd
|
||||
defer netConn.Close()
|
||||
remoteConn, err := conn.DialContext(ctx, spec.dialNetwork, spec.dialAddress)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err)
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
+26
-45
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -32,14 +31,12 @@ func TestPortForward(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
cmd, root := clitest.New(t, "port-forward", "blah")
|
||||
inv, root := clitest.New(t, "port-forward", "blah")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stderr = pty.Output()
|
||||
|
||||
err := cmd.Execute()
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no port-forwards")
|
||||
|
||||
@@ -134,17 +131,17 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
|
||||
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
errC <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
@@ -182,17 +179,17 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
|
||||
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
errC <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
@@ -239,17 +236,15 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
|
||||
inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stderr = pty.Output()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
errC <- inv.WithContext(ctx).Run()
|
||||
}()
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
@@ -293,23 +288,9 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
|
||||
// Setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
|
||||
})
|
||||
|
||||
// Create template and workspace
|
||||
@@ -319,12 +300,12 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Start workspace agent in a goroutine
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
errC := make(chan error)
|
||||
agentCtx, agentCancel := context.WithCancel(ctx)
|
||||
t.Cleanup(func() {
|
||||
@@ -333,7 +314,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
|
||||
require.NoError(t, err)
|
||||
})
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(agentCtx)
|
||||
errC <- inv.WithContext(agentCtx).Run()
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
+30
-31
@@ -3,32 +3,26 @@ package cli
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func publickey() *cobra.Command {
|
||||
var (
|
||||
reset bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "publickey",
|
||||
Aliases: []string{"pubkey"},
|
||||
Short: "Output your Coder public key used for Git operations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
func (r *RootCmd) publickey() *clibase.Cmd {
|
||||
var reset bool
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "publickey",
|
||||
Aliases: []string{"pubkey"},
|
||||
Short: "Output your Coder public key used for Git operations",
|
||||
Middleware: r.InitClient(client),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
if reset {
|
||||
// Confirm prompt if using --reset. We don't want to accidentally
|
||||
// reset our public key.
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm regenerate a new sshkey for your workspaces? This will require updating the key " +
|
||||
"on any services it is registered with. This action cannot be reverted.",
|
||||
IsConfirm: true,
|
||||
@@ -38,33 +32,38 @@ func publickey() *cobra.Command {
|
||||
}
|
||||
|
||||
// Reset the public key, let the retrieve re-read it.
|
||||
_, err = client.RegenerateGitSSHKey(cmd.Context(), codersdk.Me)
|
||||
_, err = client.RegenerateGitSSHKey(inv.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := client.GitSSHKey(cmd.Context(), codersdk.Me)
|
||||
key, err := client.GitSSHKey(inv.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
cmd.Println(cliui.Styles.Wrap.Render(
|
||||
"This is your public key for using " + cliui.Styles.Field.Render("git") + " in " +
|
||||
"Coder. All clones with SSH will be authenticated automatically 🪄.",
|
||||
))
|
||||
cmd.Println()
|
||||
cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey)))
|
||||
cmd.Println()
|
||||
cmd.Println("Add to GitHub and GitLab:")
|
||||
cmd.Println(cliui.Styles.Prompt.String() + "https://github.com/settings/ssh/new")
|
||||
cmd.Println(cliui.Styles.Prompt.String() + "https://gitlab.com/-/profile/keys")
|
||||
cliui.Infof(inv.Stdout,
|
||||
"This is your public key for using "+cliui.Styles.Field.Render("git")+" in "+
|
||||
"Coder. All clones with SSH will be authenticated automatically 🪄.\n\n",
|
||||
)
|
||||
cliui.Infof(inv.Stdout, cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n\n")
|
||||
cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:"+"\n")
|
||||
cliui.Infof(inv.Stdout, cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new"+"\n")
|
||||
cliui.Infof(inv.Stdout, cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys"+"\n")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "Regenerate your public key. This will require updating the key on any services it's registered with.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "reset",
|
||||
Description: "Regenerate your public key. This will require updating the key on any services it's registered with.",
|
||||
Value: clibase.BoolOf(&reset),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ func TestPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "publickey")
|
||||
inv, root := clitest.New(t, "publickey")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
err := cmd.Execute()
|
||||
inv.Stdout = buf
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
publicKey := buf.String()
|
||||
require.NotEmpty(t, publicKey)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user