Compare commits
411 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0598aecf90 | |||
| 5a6d23a4a3 | |||
| 9a444b3af2 | |||
| d83f4eb076 | |||
| ceb168be95 | |||
| 097fdaffe2 | |||
| 3c7808c575 | |||
| 7982ad7659 | |||
| 78ff375fed | |||
| 4cad6f75a9 | |||
| 4e20eea9e6 | |||
| 1d925ab043 | |||
| fd60e1c2ba | |||
| 971388762c | |||
| 742413e149 | |||
| fb4219f57d | |||
| 074faec7d7 | |||
| 516ba9e28e | |||
| 7cb20d7b26 | |||
| ecb22461bb | |||
| 1636124ed1 | |||
| cdd40fb292 | |||
| 1d33990e78 | |||
| 95a348ecc7 | |||
| 03940f5fef | |||
| 007f0a35a4 | |||
| c8f68cbc46 | |||
| 91c337a2ff | |||
| 9308331d9a | |||
| e03ef62a13 | |||
| 27f5ff2dd1 | |||
| 900e2cd39c | |||
| 487b37b228 | |||
| 0dd942e197 | |||
| cd890aa3a0 | |||
| ccfffc6911 | |||
| df34858c3c | |||
| 5ad47471b5 | |||
| d9f1aafa94 | |||
| 5bcaa93198 | |||
| b62f3e6af5 | |||
| e5668720b8 | |||
| f258232be9 | |||
| 81e99bec6b | |||
| 69c1d981e3 | |||
| 7efdf811ae | |||
| 095c9797c9 | |||
| bcd68ee249 | |||
| 163631e79c | |||
| fed70bdeb8 | |||
| 0fba291ffe | |||
| 57386ed677 | |||
| ed5da65a54 | |||
| d7baa49d6d | |||
| 20a9e9bdfb | |||
| d89ecebb4e | |||
| cd92220ab8 | |||
| b828412edd | |||
| 84f0cf215f | |||
| f61c59fed9 | |||
| b7f9c1aa2e | |||
| d613390162 | |||
| 6cad5dbd52 | |||
| fa6c859e83 | |||
| eabf618479 | |||
| b67a850659 | |||
| 32d5875fa4 | |||
| 343f8ec9ab | |||
| 5076161078 | |||
| 297089e944 | |||
| c42f487668 | |||
| 76bfa9ba17 | |||
| 212aeff724 | |||
| 23f61c68b4 | |||
| 13f6645ab9 | |||
| c5a4095610 | |||
| 29099d4727 | |||
| 8c8bd3141f | |||
| b9d441ff5f | |||
| 9b8e707517 | |||
| d2c1562a94 | |||
| aaa1223408 | |||
| 413928b57a | |||
| d18e8304d6 | |||
| 7f98fa3abb | |||
| b54950cc6e | |||
| 5f640eb219 | |||
| c81fd1d868 | |||
| bab17a3556 | |||
| 5ebc748e94 | |||
| 46cce333b1 | |||
| 40fb57aa23 | |||
| 02f6203dc7 | |||
| 512cbf1682 | |||
| dfb6bfa4d2 | |||
| f537193682 | |||
| 8e254cbb07 | |||
| ccbb687ca0 | |||
| 774c9ddc64 | |||
| 416d67ba2c | |||
| 21feb42fc4 | |||
| d676ad56fe | |||
| a77c9079af | |||
| 2564f9c823 | |||
| 687b4dd41c | |||
| 29763b1b4c | |||
| 75b5d71216 | |||
| fac77f956e | |||
| 5317c500c8 | |||
| cc0b264d36 | |||
| 06a40185cb | |||
| 89ff48744c | |||
| 42e2a4150c | |||
| b8420ecaaf | |||
| 1523d935b5 | |||
| c6396e3125 | |||
| 17f2584318 | |||
| 3565227d02 | |||
| 7da231bc92 | |||
| 384873a114 | |||
| 531565645f | |||
| 88c6a75d48 | |||
| 57a65c15bf | |||
| 208ed1efd7 | |||
| 9c8ecb82a3 | |||
| 79d24d2101 | |||
| 3eb2b5573b | |||
| 914ce1b43a | |||
| 0fe4650c7a | |||
| 61287dc60c | |||
| dee694ca46 | |||
| bb8c7e7e35 | |||
| 23b1515514 | |||
| 6cc1b975e5 | |||
| bac4fcb73b | |||
| 994e807140 | |||
| 20b3f8fbf3 | |||
| 191449078c | |||
| 0ef5340d20 | |||
| ab6cb1a787 | |||
| 9d02269191 | |||
| 26df33ac88 | |||
| a0787b71a1 | |||
| 71d31713c5 | |||
| 93e3c868eb | |||
| a33c38d46d | |||
| 5afd022443 | |||
| e8d5fdfb1a | |||
| 6bf73a5964 | |||
| 3046f5c959 | |||
| 7c7060f6a1 | |||
| e017d22e1b | |||
| 919fe9c632 | |||
| 7f5db44ef6 | |||
| 4516f5d79c | |||
| b815fcc150 | |||
| 61a41027fc | |||
| 999ae7ba56 | |||
| 9eefd2a636 | |||
| 163f96b71a | |||
| 419eba5fb6 | |||
| 288df75686 | |||
| ea3b13c78e | |||
| 7d281c308f | |||
| 53adbaea70 | |||
| 1c60622eba | |||
| 9acf6acd76 | |||
| 4369f2b4b5 | |||
| 8785a51b09 | |||
| 7d9f5ab81d | |||
| 68ec532ca7 | |||
| 50d9206950 | |||
| 8b6a06dbd5 | |||
| 04af56d54b | |||
| cc7899cc7d | |||
| 496b486a2d | |||
| 20bfd1f874 | |||
| 52f03dbdf2 | |||
| bcdb8a4c9f | |||
| 31abfb2c04 | |||
| 3d87f78ce5 | |||
| b4f26a8c78 | |||
| 3a48ba798d | |||
| a7d44150a8 | |||
| d0a8424819 | |||
| 0589267301 | |||
| 7f66bf56a4 | |||
| 680e28bdce | |||
| 0aa84b18a1 | |||
| 2f043d7ab9 | |||
| 4a4d2ecd60 | |||
| b22bd816c9 | |||
| 3c5a5ae2e0 | |||
| 5507b58fe7 | |||
| f724b03a90 | |||
| e058d6c463 | |||
| 414771e40d | |||
| c4e37228f0 | |||
| ef54a0b8f6 | |||
| 70f428b2f6 | |||
| 06aa139d26 | |||
| 1d3eda5c1f | |||
| 302b7fa048 | |||
| baf8e30458 | |||
| f9ec468c6d | |||
| 5a20121fe7 | |||
| d04eaf8392 | |||
| b786166ddf | |||
| 0623531ab8 | |||
| 3ca78dde86 | |||
| eacdba24bc | |||
| 533d655ac0 | |||
| 21b92ef893 | |||
| f7ddbb744f | |||
| 38d8e3ad6a | |||
| ca80dd657b | |||
| 11f7b1b3f5 | |||
| edb4485afd | |||
| 1de12b0310 | |||
| dc40231b74 | |||
| 3f79022848 | |||
| 35a5475166 | |||
| 2a3a00cf82 | |||
| d6766f706d | |||
| ba90bb0ab3 | |||
| e70ad2b4b3 | |||
| 5246f8d142 | |||
| e6cd3005d3 | |||
| b80550957f | |||
| 894c758f06 | |||
| 33988fedcd | |||
| 2c8b264d78 | |||
| 339eebacae | |||
| 5cc5bbea04 | |||
| 62047e5f68 | |||
| 3b5cabb566 | |||
| a3ffab6ceb | |||
| 3fdeaf7b24 | |||
| 5c977c6be7 | |||
| b23e6a05c8 | |||
| fb28979537 | |||
| 3894bab038 | |||
| e4470e1617 | |||
| 9ea2f6f267 | |||
| 4be5b2ff98 | |||
| 8403dd5c36 | |||
| 4dcf5ef323 | |||
| aef400c2c5 | |||
| 9ef9044d9c | |||
| 2cffb55457 | |||
| 6cd1219289 | |||
| 575925c050 | |||
| bb3850adc2 | |||
| 86a82b5a2a | |||
| 718b30cada | |||
| b371bc89c0 | |||
| 676191643b | |||
| c6e44282b2 | |||
| d8ddd07ee5 | |||
| b7c574f679 | |||
| e086d7813b | |||
| c127d90efc | |||
| 326886d3c2 | |||
| ae522c558d | |||
| b8944074c4 | |||
| a3ebcd7a1e | |||
| b4f54f3eea | |||
| db4945dc27 | |||
| 661d22621a | |||
| 3338f32489 | |||
| 35017822d5 | |||
| 50124fefdc | |||
| 96e9a4f85c | |||
| 86f68b220e | |||
| db7b411094 | |||
| 6d992984a4 | |||
| 7c77a3cc83 | |||
| 07d1478f34 | |||
| 15f19431d7 | |||
| 2d5c068525 | |||
| dbe6b6c224 | |||
| b0c86220a7 | |||
| f2a12a06d1 | |||
| 115c52c5b0 | |||
| 4228c1f308 | |||
| 922f4c545f | |||
| 37885e2e82 | |||
| 20a3801600 | |||
| fccf6f1e0e | |||
| 6de59371ea | |||
| 1e5438eadb | |||
| c145f113fe | |||
| 5be02a293e | |||
| de3945c291 | |||
| bbc7b5085d | |||
| dda6bdc174 | |||
| d96adad56f | |||
| 45160c7679 | |||
| 45420b95f3 | |||
| ce21b2030a | |||
| be516f9686 | |||
| 370f0b9020 | |||
| 14d3e300d3 | |||
| 6ff9a05832 | |||
| ff1eabebe5 | |||
| 71393743dc | |||
| 5aa54be6ca | |||
| 910225698e | |||
| 335eb05223 | |||
| 4afce19fb7 | |||
| 705b9ccda8 | |||
| c330af0e4d | |||
| 5ed065d88d | |||
| 2df9a3e554 | |||
| 7ea8a2253e | |||
| c6bc7414aa | |||
| 9006b21758 | |||
| f5601cd783 | |||
| 7780087526 | |||
| 93b4675748 | |||
| 95fc962871 | |||
| 9dc8e0f4c5 | |||
| bfdc29f466 | |||
| bf87c97ede | |||
| 7ef6780d45 | |||
| 628563d94b | |||
| bacad93dde | |||
| c334d9c91a | |||
| f3b35c504f | |||
| 0664efbe2d | |||
| 168b4ff5ac | |||
| 9ecb9b967b | |||
| c44d013519 | |||
| 26ebd70b12 | |||
| 9f4972901c | |||
| 33e896d404 | |||
| efd532e1d7 | |||
| d6154c4310 | |||
| fb3523b37f | |||
| 6a846cdbb8 | |||
| 7de576b596 | |||
| 3301212972 | |||
| 5bd19f8ba3 | |||
| 1b5f3418d3 | |||
| 4f2202fe34 | |||
| c8580a415a | |||
| 40688e40df | |||
| 90b29df145 | |||
| 85cc695dc6 | |||
| 0787c42d32 | |||
| 914f35a3a3 | |||
| 328e69629c | |||
| 2a9234e9ba | |||
| d1db11ab21 | |||
| cb9d40fb8a | |||
| 9da646704b | |||
| eb646f036e | |||
| 25f1ddbf5e | |||
| 918bea18c1 | |||
| 6b9e1d4771 | |||
| 84d312cfea | |||
| 92b81c4164 | |||
| 0d6056633d | |||
| 8b1c46fbe0 | |||
| 0f342ed12f | |||
| 208a5beb95 | |||
| c2491746ba | |||
| c1bb5abcb7 | |||
| cd7ce8ecfb | |||
| 84922e239f | |||
| c3f0db3671 | |||
| 8f07d3357e | |||
| e6d8f674ad | |||
| bcf9bc3c90 | |||
| bd90740166 | |||
| 7b39f6b0d4 | |||
| 2e6dbd18b3 | |||
| 1958436b1d | |||
| 2ed88d593a | |||
| 5366f2576f | |||
| 8f85464fe6 | |||
| 01a904c133 | |||
| 093d243811 | |||
| 44210631cd | |||
| 242b1ea4ca | |||
| fcb0ce1f1b | |||
| 5cffac29da | |||
| 7c8c02733d | |||
| 48430625a0 | |||
| c74fed11ac | |||
| f23a05075e | |||
| 0eca1fcb8b | |||
| 2f18f4583b | |||
| 1d331dd049 | |||
| aa4a6f89ba | |||
| 903993a14a | |||
| 478121df77 | |||
| 2368f48c1c | |||
| 4c8a560e19 | |||
| 4eac2acede | |||
| 5bd5801286 | |||
| 0785b77d0b | |||
| 66c8060605 | |||
| 741d60a25e | |||
| 3a8424ea23 | |||
| 92253d0f52 | |||
| 7d15aad11a | |||
| e5d4f3557b | |||
| faf245234f | |||
| c9fcab3717 | |||
| ead8fae63d |
@@ -4,12 +4,12 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.22.5"
|
||||
default: "1.22.8"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
|
||||
with:
|
||||
go-version: ${{ inputs.version }}
|
||||
|
||||
|
||||
@@ -11,16 +11,16 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.6
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.0.3
|
||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
# See https://github.com/actions/setup-node#caching-global-packages-data
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
|
||||
|
||||
- name: Install root node_modules
|
||||
shell: bash
|
||||
run: ./scripts/pnpm_install.sh
|
||||
|
||||
@@ -5,6 +5,6 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@v4
|
||||
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
with:
|
||||
sqlc-version: "1.25.0"
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.9.2
|
||||
terraform_version: 1.9.8
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: Upload tests to datadog
|
||||
if: always()
|
||||
description: |
|
||||
Uploads the test results to datadog.
|
||||
inputs:
|
||||
api-key:
|
||||
description: "Datadog API key"
|
||||
|
||||
+181
-81
@@ -42,13 +42,18 @@ jobs:
|
||||
offlinedocs: ${{ steps.filter.outputs.offlinedocs }}
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- name: check changed files
|
||||
uses: dorny/paths-filter@v3
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -85,6 +90,7 @@ jobs:
|
||||
- "coderd/**"
|
||||
- "enterprise/**"
|
||||
- "examples/*"
|
||||
- "helm/**"
|
||||
- "provisioner/**"
|
||||
- "provisionerd/**"
|
||||
- "provisionersdk/**"
|
||||
@@ -117,46 +123,53 @@ jobs:
|
||||
run: |
|
||||
echo "${{ toJSON(steps.filter )}}"
|
||||
|
||||
update-flake:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.gomod == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
# Disabled due to instability. See: https://github.com/coder/coder/issues/14553
|
||||
# Re-enable once the flake hash calculation is stable.
|
||||
# update-flake:
|
||||
# needs: changes
|
||||
# if: needs.changes.outputs.gomod == 'true'
|
||||
# runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
# with:
|
||||
# fetch-depth: 1
|
||||
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
|
||||
# token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
# - name: Setup Go
|
||||
# uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Update Nix Flake SRI Hash
|
||||
run: ./scripts/update-flake.sh
|
||||
# - name: Update Nix Flake SRI Hash
|
||||
# run: ./scripts/update-flake.sh
|
||||
|
||||
# auto update flake for dependabot
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
with:
|
||||
# Allows dependabot to still rebase!
|
||||
commit_message: "[dependabot skip] Update Nix Flake SRI Hash"
|
||||
commit_user_name: "dependabot[bot]"
|
||||
commit_user_email: "49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
commit_author: "dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
# # auto update flake for dependabot
|
||||
# - uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1
|
||||
# if: github.actor == 'dependabot[bot]'
|
||||
# with:
|
||||
# # Allows dependabot to still rebase!
|
||||
# commit_message: "[dependabot skip] Update Nix Flake SRI Hash"
|
||||
# commit_user_name: "dependabot[bot]"
|
||||
# commit_user_email: "49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
# commit_author: "dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
|
||||
# require everyone else to update it themselves
|
||||
- name: Ensure No Changes
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
run: git diff --exit-code
|
||||
# # require everyone else to update it themselves
|
||||
# - name: Ensure No Changes
|
||||
# if: github.actor != 'dependabot[bot]'
|
||||
# run: git diff --exit-code
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -174,7 +187,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -184,7 +197,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.23.6
|
||||
uses: crate-ci/typos@0d9e0c2c1bd7f770f6eb90f87780848ca02fc12c # v1.26.8
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -197,7 +210,7 @@ jobs:
|
||||
|
||||
# Needed for helm chart linting
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v4
|
||||
uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0
|
||||
with:
|
||||
version: v3.9.2
|
||||
|
||||
@@ -211,14 +224,24 @@ jobs:
|
||||
./actionlint -color -shellcheck= -ignore "set-output"
|
||||
shell: bash
|
||||
|
||||
- name: Check for unstaged files
|
||||
run: |
|
||||
rm -f ./actionlint ./typos
|
||||
./scripts/check_unstaged.sh
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 8
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -266,8 +289,13 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -302,8 +330,13 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -356,8 +389,13 @@ jobs:
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -396,8 +434,13 @@ jobs:
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -423,13 +466,18 @@ jobs:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
test-go-race:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -439,9 +487,13 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
# We run race tests with reduced parallelism because they use more CPU and we were finding
|
||||
# instances where tests appear to hang for multiple seconds, resulting in flaky tests when
|
||||
# short timeouts are used.
|
||||
# c.f. discussion on https://github.com/coder/coder/pull/15106
|
||||
- name: Run Tests
|
||||
run: |
|
||||
gotestsum --junitfile="gotests.xml" -- -race ./...
|
||||
gotestsum --junitfile="gotests.xml" -- -race -parallel 4 -p 4 ./...
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
@@ -464,8 +516,13 @@ jobs:
|
||||
if: needs.changes.outputs.tailnet-integration == 'true' || needs.changes.outputs.ci == 'true'
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -485,8 +542,13 @@ jobs:
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -497,7 +559,8 @@ jobs:
|
||||
working-directory: site
|
||||
|
||||
test-e2e:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
|
||||
# test-e2e fails on 2-core 8GB runners, so we use the 4-core 16GB runner
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -505,14 +568,19 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- enterprise: false
|
||||
- premium: false
|
||||
name: test-e2e
|
||||
- enterprise: true
|
||||
name: test-e2e-enterprise
|
||||
- premium: true
|
||||
name: test-e2e-premium
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -532,38 +600,35 @@ jobs:
|
||||
- run: pnpm playwright:install
|
||||
working-directory: site
|
||||
|
||||
# Run tests that don't require an enterprise license without an enterprise license
|
||||
# Run tests that don't require a premium license without a premium license
|
||||
- run: pnpm playwright:test --forbid-only --workers 1
|
||||
if: ${{ !matrix.variant.enterprise }}
|
||||
if: ${{ !matrix.variant.premium }}
|
||||
env:
|
||||
DEBUG: pw:api
|
||||
working-directory: site
|
||||
|
||||
# Run all of the tests with an enterprise license
|
||||
# Run all of the tests with a premium license
|
||||
- run: pnpm playwright:test --forbid-only --workers 1
|
||||
if: ${{ matrix.variant.enterprise }}
|
||||
if: ${{ matrix.variant.premium }}
|
||||
env:
|
||||
DEBUG: pw:api
|
||||
CODER_E2E_ENTERPRISE_LICENSE: ${{ secrets.CODER_E2E_ENTERPRISE_LICENSE }}
|
||||
CODER_E2E_REQUIRE_ENTERPRISE_TESTS: "1"
|
||||
CODER_E2E_LICENSE: ${{ secrets.CODER_E2E_LICENSE }}
|
||||
CODER_E2E_REQUIRE_PREMIUM_TESTS: "1"
|
||||
working-directory: site
|
||||
# Temporarily allow these to fail so that I can gather data about which
|
||||
# tests are failing.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.enterprise && '-enterprise' || '-agpl' }}
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.enterprise && '-enterprise' || '-agpl' }}
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
retention-days: 7
|
||||
|
||||
@@ -573,8 +638,13 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
# Required by Chromatic for build-over-build history, otherwise we
|
||||
# only get 1 commit on shallow checkout.
|
||||
@@ -588,7 +658,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@v10
|
||||
uses: chromaui/action@30b6228aa809059d46219e0f556752e8672a7e26 # v11.11.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -619,7 +689,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@v10
|
||||
uses: chromaui/action@30b6228aa809059d46219e0f556752e8672a7e26 # v11.11.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -645,8 +715,13 @@ jobs:
|
||||
if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.docs == 'true'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
# 0 is required here for version.sh to work.
|
||||
fetch-depth: 0
|
||||
@@ -713,6 +788,11 @@ jobs:
|
||||
# cancelled.
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Ensure required checks
|
||||
run: |
|
||||
echo "Checking required checks"
|
||||
@@ -746,13 +826,18 @@ jobs:
|
||||
outputs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -827,7 +912,7 @@ jobs:
|
||||
|
||||
- name: Prune old images
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: vlaurin/action-ghcr-prune@v0.6.0
|
||||
uses: vlaurin/action-ghcr-prune@0cf7d39f88546edd31965acba78cdcb0be14d641 # v0.6.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
organization: coder
|
||||
@@ -842,7 +927,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
@@ -865,28 +950,33 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
uses: google-github-actions/setup-gcloud@f0990588f1e5b5af6827153b93673613abdc6ec7 # v2.1.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@main
|
||||
uses: fluxcd/flux2/action@5350425cdcd5fa015337e09fa502153c0275bd4b # v2.4.0
|
||||
with:
|
||||
# Keep this up to date with the version of flux installed in dogfood cluster
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.2.1"
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
uses: "google-github-actions/get-gke-credentials@v2"
|
||||
uses: google-github-actions/get-gke-credentials@6051de21ad50fbb1767bc93c11357a49082ad116 # v2.2.1
|
||||
with:
|
||||
cluster_name: dogfood-v2
|
||||
location: us-central1-a
|
||||
@@ -922,13 +1012,18 @@ jobs:
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup flyctl
|
||||
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
uses: superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be # v1.5
|
||||
|
||||
- name: Deploy workspace proxies
|
||||
run: |
|
||||
@@ -952,8 +1047,13 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# We need golang to run the migration main.go
|
||||
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
# For jobs that don't run on draft PRs.
|
||||
- ready_for_review
|
||||
|
||||
# Only run one instance per PR to ensure in-order execution.
|
||||
concurrency: pr-${{ github.ref }}
|
||||
@@ -25,16 +27,26 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: auto-approve dependabot
|
||||
uses: hmarr/auto-approve-action@v4
|
||||
uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- 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.5.1
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.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
|
||||
@@ -52,10 +64,15 @@ jobs:
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip tagging for draft PRs.
|
||||
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
|
||||
if: ${{ github.event_name == 'pull_request_target' && !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: release-labels
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
# This script ensures PR title and labels are in sync:
|
||||
#
|
||||
|
||||
@@ -36,11 +36,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -50,11 +55,11 @@ jobs:
|
||||
run: mkdir base-build-context
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
uses: depot/setup-action@v1
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
# This uses OIDC authentication, so no auth variables are required.
|
||||
- name: Build base Docker image via depot.dev
|
||||
uses: depot/build-push-action@v1
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
|
||||
@@ -26,12 +26,17 @@ jobs:
|
||||
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v8
|
||||
uses: tj-actions/branch-names@6871f53176ad61624f978536bbf089c574dc19a2 # v8.0.1
|
||||
|
||||
- name: "Branch name to Docker tag name"
|
||||
id: docker-tag-name
|
||||
@@ -42,20 +47,20 @@ jobs:
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push Non-Nix image
|
||||
uses: depot/build-push-action@v1
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: b4q6ltmpzh
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
@@ -67,7 +72,7 @@ jobs:
|
||||
tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest"
|
||||
|
||||
- name: Build and push Nix image
|
||||
uses: depot/build-push-action@v1
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: b4q6ltmpzh
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
@@ -83,14 +88,19 @@ jobs:
|
||||
needs: build_image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "github.com/<your_github_handle>"
|
||||
},
|
||||
{
|
||||
"pattern": "imgur.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
|
||||
@@ -16,8 +16,13 @@ jobs:
|
||||
# so 0.016 * 240 = 3.84 USD per run.
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
@@ -43,8 +48,13 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
@@ -13,5 +13,10 @@ jobs:
|
||||
assign-author:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Assign author
|
||||
uses: toshimaru/auto-author-assign@v2.1.1
|
||||
uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
|
||||
|
||||
@@ -15,6 +15,11 @@ jobs:
|
||||
cleanup:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Get PR number
|
||||
id: pr_number
|
||||
run: |
|
||||
@@ -26,7 +31,7 @@ jobs:
|
||||
|
||||
- name: Delete image
|
||||
continue-on-error: true
|
||||
uses: bots-house/ghcr-delete-image-action@v1.1.0
|
||||
uses: bots-house/ghcr-delete-image-action@3827559c68cb4dcdf54d813ea9853be6d468d3a4 # v1.1.0
|
||||
with:
|
||||
owner: coder
|
||||
name: coder-preview
|
||||
|
||||
@@ -39,8 +39,13 @@ jobs:
|
||||
outputs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check if PR is open
|
||||
id: check_pr
|
||||
@@ -69,8 +74,13 @@ jobs:
|
||||
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -119,7 +129,7 @@ jobs:
|
||||
echo "NEW=$NEW" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check changed files
|
||||
uses: dorny/paths-filter@v3
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
base: ${{ github.ref }}
|
||||
@@ -162,8 +172,13 @@ jobs:
|
||||
if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true'
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -173,7 +188,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR
|
||||
id: comment_id
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -199,7 +214,7 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -213,7 +228,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -294,7 +309,7 @@ jobs:
|
||||
kubectl create namespace "pr${{ env.PR_NUMBER }}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check and Create Certificate
|
||||
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
|
||||
@@ -410,7 +425,7 @@ jobs:
|
||||
--first-user-username coder \
|
||||
--first-user-email pr${{ env.PR_NUMBER }}@coder.com \
|
||||
--first-user-password $password \
|
||||
--first-user-trial \
|
||||
--first-user-trial=false \
|
||||
--use-token-as-session \
|
||||
https://${{ env.PR_HOSTNAME }}
|
||||
|
||||
@@ -441,7 +456,7 @@ jobs:
|
||||
echo "Slack notification sent"
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
@@ -450,7 +465,7 @@ jobs:
|
||||
direction: last
|
||||
|
||||
- name: Comment on PR
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
env:
|
||||
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
|
||||
with:
|
||||
|
||||
@@ -10,8 +10,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Run Schmoder CI
|
||||
uses: benc-uk/workflow-dispatch@v1.2.4
|
||||
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
|
||||
with:
|
||||
workflow: ci.yaml
|
||||
repo: coder/schmoder
|
||||
|
||||
@@ -46,8 +46,13 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -116,7 +121,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -130,7 +135,7 @@ jobs:
|
||||
|
||||
# Necessary for signing Windows binaries.
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11.0"
|
||||
@@ -185,14 +190,14 @@ jobs:
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: "google-github-actions/setup-gcloud@v2"
|
||||
uses: google-github-actions/setup-gcloud@f0990588f1e5b5af6827153b93673613abdc6ec7 # v2.1.1
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
@@ -245,12 +250,12 @@ jobs:
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
uses: depot/setup-action@v1
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
# 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
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
@@ -358,13 +363,13 @@ jobs:
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: "google-github-actions/setup-gcloud@v2"
|
||||
uses: google-github-actions/setup-gcloud@f0990588f1e5b5af6827153b93673613abdc6ec7 # 2.1.1
|
||||
|
||||
- name: Publish Helm Chart
|
||||
if: ${{ !inputs.dry_run }}
|
||||
@@ -383,7 +388,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -398,7 +403,7 @@ jobs:
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
@@ -414,6 +419,11 @@ jobs:
|
||||
steps:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Update homebrew
|
||||
env:
|
||||
# Variables used by the `gh` command
|
||||
@@ -485,13 +495,18 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Sync fork
|
||||
run: gh repo sync cdrci/winget-pkgs -b master
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -570,8 +585,13 @@ jobs:
|
||||
needs: release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: OpenSSF Scorecard
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: "27 7 * * 3" # A random time to run weekly
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_results: true
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -3,7 +3,6 @@ name: "security"
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -23,16 +22,23 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
permissions:
|
||||
security-events: write
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -56,10 +62,17 @@ jobs:
|
||||
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
|
||||
|
||||
trivy:
|
||||
permissions:
|
||||
security-events: write
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -85,13 +98,20 @@ jobs:
|
||||
# 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
|
||||
set -euxo pipefail
|
||||
cd dogfood/contents
|
||||
mkdir -p /usr/local/bin
|
||||
mkdir -p /usr/local/include
|
||||
|
||||
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
|
||||
# Copy the generated files to the include directory.
|
||||
docker run --rm -v /usr/local/include:/target protoc cp -r /tmp/include/google /target/
|
||||
ls -la /usr/local/include/google/protobuf/
|
||||
stat /usr/local/include/google/protobuf/timestamp.proto
|
||||
|
||||
- name: Build Coder linux amd64 Docker image
|
||||
id: build
|
||||
@@ -114,7 +134,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8
|
||||
uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
@@ -122,28 +142,18 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
retention-days: 7
|
||||
|
||||
# Prisma cloud scan runs last because it fails the entire job if it
|
||||
# detects vulnerabilities. :|
|
||||
- name: Run Prisma Cloud image scan
|
||||
uses: PaloAltoNetworks/prisma-cloud-scan@v1
|
||||
with:
|
||||
pcc_console_url: ${{ secrets.PRISMA_CLOUD_URL }}
|
||||
pcc_user: ${{ secrets.PRISMA_CLOUD_ACCESS_KEY }}
|
||||
pcc_pass: ${{ secrets.PRISMA_CLOUD_SECRET_KEY }}
|
||||
image_name: ${{ steps.build.outputs.image }}
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
|
||||
@@ -12,12 +12,20 @@ jobs:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: stale
|
||||
uses: actions/stale@v9.0.0
|
||||
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
days-before-stale: 180
|
||||
# days-before-stale: 180
|
||||
# essentially disabled for now while we work through polish issues
|
||||
days-before-stale: 3650
|
||||
|
||||
# Pull Requests become stale more quickly due to merge conflicts.
|
||||
# Also, we promote minimizing WIP.
|
||||
days-before-pr-stale: 7
|
||||
@@ -31,7 +39,7 @@ jobs:
|
||||
# Start with the oldest issues, always.
|
||||
ascending: true
|
||||
- name: "Close old issues labeled likely-no"
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -57,7 +65,7 @@ jobs:
|
||||
});
|
||||
|
||||
const labelEvent = timeline.data.find(event => event.event === 'labeled' && event.label.name === 'likely-no');
|
||||
|
||||
|
||||
if (labelEvent) {
|
||||
console.log(`Issue #${issue.number} was labeled with 'likely-no' at ${labelEvent.created_at}`);
|
||||
|
||||
@@ -79,10 +87,15 @@ jobs:
|
||||
branches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
- name: Run delete-old-branches-action
|
||||
uses: beatlabs/delete-old-branches-action@v0.0.10
|
||||
uses: beatlabs/delete-old-branches-action@6e94df089372a619c01ae2c2f666bf474f890911 # v0.0.10
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
date: "6 months ago"
|
||||
@@ -93,8 +106,13 @@ jobs:
|
||||
del_runs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Delete PR Cleanup workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@v2
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -103,7 +121,7 @@ jobs:
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
uses: Mattraks/delete-workflow-runs@v2
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
@@ -22,6 +22,7 @@ pn = "pn"
|
||||
EDE = "EDE"
|
||||
# HELO is an SMTP command
|
||||
HELO = "HELO"
|
||||
LKE = "LKE"
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
@@ -40,4 +41,6 @@ extend-exclude = [
|
||||
"tailnet/testdata/**",
|
||||
"site/src/pages/SetupPage/countries.tsx",
|
||||
"provisioner/terraform/testdata/**",
|
||||
# notifications' golden files confuse the detector because of quoted-printable encoding
|
||||
"coderd/notifications/testdata/**"
|
||||
]
|
||||
|
||||
@@ -10,15 +10,23 @@ on:
|
||||
paths:
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@d53a906aa6b22b8979d33bc86170567e619495ec # v1.0.15
|
||||
id: markdown-link-check
|
||||
# checks all markdown files from /docs including all subfolders
|
||||
with:
|
||||
|
||||
Vendored
+25
-2
@@ -6,14 +6,17 @@
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"autoupdate",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
"buildname",
|
||||
"Caddyfile",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"codecov",
|
||||
"codercom",
|
||||
"coderd",
|
||||
"coderdenttest",
|
||||
"coderdtest",
|
||||
@@ -21,15 +24,19 @@
|
||||
"contravariance",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbcrypt",
|
||||
"dbgen",
|
||||
"dbmem",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
"derpmap",
|
||||
"devcontainers",
|
||||
"devel",
|
||||
"devtunnel",
|
||||
"dflags",
|
||||
"dogfood",
|
||||
"dotfiles",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
@@ -38,18 +45,22 @@
|
||||
"embeddedpostgres",
|
||||
"enablements",
|
||||
"enterprisemeta",
|
||||
"Entra",
|
||||
"errgroup",
|
||||
"eventsourcemock",
|
||||
"externalauth",
|
||||
"Failf",
|
||||
"fatih",
|
||||
"filebrowser",
|
||||
"Formik",
|
||||
"gitauth",
|
||||
"Gitea",
|
||||
"gitsshkey",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gonet",
|
||||
"googleclouddns",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"GTTY",
|
||||
@@ -63,9 +74,11 @@
|
||||
"initialisms",
|
||||
"ipnstate",
|
||||
"isatty",
|
||||
"jetbrains",
|
||||
"Jobf",
|
||||
"Keygen",
|
||||
"kirsle",
|
||||
"knowledgebase",
|
||||
"Kubernetes",
|
||||
"ldflags",
|
||||
"magicsock",
|
||||
@@ -77,6 +90,7 @@
|
||||
"namesgenerator",
|
||||
"namespacing",
|
||||
"netaddr",
|
||||
"netcheck",
|
||||
"netip",
|
||||
"netmap",
|
||||
"netns",
|
||||
@@ -93,6 +107,7 @@
|
||||
"opty",
|
||||
"paralleltest",
|
||||
"parameterscopeid",
|
||||
"portsharing",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promhttp",
|
||||
@@ -100,6 +115,8 @@
|
||||
"provisionerd",
|
||||
"provisionerdserver",
|
||||
"provisionersdk",
|
||||
"psql",
|
||||
"ptrace",
|
||||
"ptty",
|
||||
"ptys",
|
||||
"ptytest",
|
||||
@@ -114,12 +131,14 @@
|
||||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"speedtest",
|
||||
"spinbutton",
|
||||
"Srcs",
|
||||
"stdbuf",
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"subpage",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
@@ -153,13 +172,16 @@
|
||||
"turnconn",
|
||||
"typegen",
|
||||
"typesafe",
|
||||
"unauthenticate",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"Userspace",
|
||||
"untar",
|
||||
"userauth",
|
||||
"userspace",
|
||||
"VMID",
|
||||
"walkthrough",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"websockets",
|
||||
"wgcfg",
|
||||
"wgconfig",
|
||||
"wgengine",
|
||||
@@ -171,6 +193,7 @@
|
||||
"workspaceapps",
|
||||
"workspacebuilds",
|
||||
"workspacename",
|
||||
"workspaceproxies",
|
||||
"wsjson",
|
||||
"xerrors",
|
||||
"xlarge",
|
||||
|
||||
@@ -395,6 +395,7 @@ fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/prettier
|
||||
.PHONY: fmt
|
||||
|
||||
fmt/go:
|
||||
go mod tidy
|
||||
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/go$(RESET)"
|
||||
# VS Code users should check out
|
||||
# https://github.com/mvdan/gofumpt#visual-studio-code
|
||||
@@ -451,6 +452,7 @@ lint/ts:
|
||||
|
||||
lint/go:
|
||||
./scripts/check_enterprise_imports.sh
|
||||
./scripts/check_codersdk_imports.sh
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
|
||||
.PHONY: lint/go
|
||||
@@ -486,15 +488,16 @@ gen: \
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
coderd/rbac/object_gen.go \
|
||||
codersdk/rbacresources_gen.go \
|
||||
site/src/api/rbacresourcesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/reference/cli/README.md \
|
||||
docs/admin/audit-logs.md \
|
||||
docs/admin/integrations/prometheus.md \
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
@@ -504,7 +507,8 @@ gen: \
|
||||
examples/examples.gen.json \
|
||||
tailnet/tailnettest/coordinatormock.go \
|
||||
tailnet/tailnettest/coordinateemock.go \
|
||||
tailnet/tailnettest/multiagentmock.go
|
||||
tailnet/tailnettest/multiagentmock.go \
|
||||
coderd/database/pubsub/psmock/psmock.go
|
||||
.PHONY: gen
|
||||
|
||||
# Mark all generated files as fresh so make thinks they're up-to-date. This is
|
||||
@@ -515,15 +519,16 @@ gen/mark-fresh:
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
coderd/rbac/object_gen.go \
|
||||
codersdk/rbacresources_gen.go \
|
||||
site/src/api/rbacresourcesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/reference/cli/README.md \
|
||||
docs/admin/audit-logs.md \
|
||||
docs/admin/integrations/prometheus.md \
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
@@ -533,7 +538,9 @@ gen/mark-fresh:
|
||||
tailnet/tailnettest/coordinatormock.go \
|
||||
tailnet/tailnettest/coordinateemock.go \
|
||||
tailnet/tailnettest/multiagentmock.go \
|
||||
"
|
||||
coderd/database/pubsub/psmock/psmock.go \
|
||||
"
|
||||
|
||||
for file in $$files; do
|
||||
echo "$$file"
|
||||
if [ ! -f "$$file" ]; then
|
||||
@@ -598,6 +605,12 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
vpn/vpn.pb.go: vpn/vpn.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run ./scripts/apitypings/ > $@
|
||||
./scripts/pnpm_install.sh
|
||||
@@ -619,26 +632,28 @@ coderd/rbac/object_gen.go: scripts/rbacgen/rbacobject.gotmpl scripts/rbacgen/mai
|
||||
go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go
|
||||
|
||||
codersdk/rbacresources_gen.go: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go
|
||||
# Do no overwrite codersdk/rbacresources_gen.go directly, as it would make the file empty, breaking
|
||||
# the `codersdk` package and any parallel build targets.
|
||||
go run scripts/rbacgen/main.go codersdk > /tmp/rbacresources_gen.go
|
||||
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
|
||||
|
||||
site/src/api/rbacresourcesGenerated.ts: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/rbacgen/main.go typescript > "$@"
|
||||
|
||||
|
||||
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
docs/admin/integrations/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/admin/prometheus.md
|
||||
pnpm exec prettier --write ./docs/admin/integrations/prometheus.md
|
||||
|
||||
docs/reference/cli/README.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
docs/reference/cli/index.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
CI=true BASE_PATH="." go run ./scripts/clidocgen
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/reference/cli/README.md ./docs/reference/cli/*.md ./docs/manifest.json
|
||||
pnpm exec prettier --write ./docs/reference/cli/index.md ./docs/reference/cli/*.md ./docs/manifest.json
|
||||
|
||||
docs/admin/audit-logs.md: coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
docs/admin/security/audit-logs.md: coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
go run scripts/auditdocgen/main.go
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/admin/audit-logs.md
|
||||
pnpm exec prettier --write ./docs/admin/security/audit-logs.md
|
||||
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) $(wildcard enterprise/wsproxy/wsproxysdk/*.go) $(DB_GEN_FILES) .swaggo docs/manifest.json coderd/rbac/object_gen.go
|
||||
./scripts/apidocgen/generate.sh
|
||||
@@ -654,6 +669,7 @@ update-golden-files: \
|
||||
enterprise/tailnet/testdata/.gen-golden \
|
||||
tailnet/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
provisioner/terraform/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
@@ -685,6 +701,10 @@ coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wil
|
||||
go test ./coderd -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/notifications/*_test.go)
|
||||
go test ./coderd/notifications -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
|
||||
go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
@@ -797,7 +817,7 @@ test-postgres-docker:
|
||||
|
||||
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
|
||||
test-race:
|
||||
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 ./...
|
||||
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 -parallel 4 -p 4 ./...
|
||||
.PHONY: test-race
|
||||
|
||||
test-tailnet-integration:
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
[](https://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder/v2)
|
||||
[](https://www.bestpractices.dev/projects/9511)
|
||||
[](https://api.securityscorecards.dev/projects/github.com/coder/coder)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
@@ -111,6 +113,7 @@ We are always working on new integrations. Please feel free to open an issue and
|
||||
- [**Module Registry**](https://registry.coder.com): Extend development environments with common use-cases
|
||||
- [**Kubernetes Log Stream**](https://github.com/coder/coder-logstream-kube): Stream Kubernetes Pod events to the Coder startup logs
|
||||
- [**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).
|
||||
- [**Setup Coder**](https://github.com/marketplace/actions/setup-coder): An action to setup coder CLI in GitHub workflows.
|
||||
|
||||
### Community
|
||||
|
||||
|
||||
+44
-27
@@ -82,7 +82,6 @@ type Options struct {
|
||||
SSHMaxTimeout time.Duration
|
||||
TailnetListenPort uint16
|
||||
Subsystems []codersdk.AgentSubsystem
|
||||
Addresses []netip.Prefix
|
||||
PrometheusRegistry *prometheus.Registry
|
||||
ReportMetadataInterval time.Duration
|
||||
ServiceBannerRefreshInterval time.Duration
|
||||
@@ -180,7 +179,6 @@ func New(options Options) Agent {
|
||||
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
subsystems: options.Subsystems,
|
||||
addresses: options.Addresses,
|
||||
syscaller: options.Syscaller,
|
||||
modifiedProcs: options.ModifiedProcesses,
|
||||
processManagementTick: options.ProcessManagementTick,
|
||||
@@ -250,7 +248,6 @@ type agent struct {
|
||||
lifecycleLastReportedIndex int // Keeps track of the last lifecycle state we successfully reported.
|
||||
|
||||
network *tailnet.Conn
|
||||
addresses []netip.Prefix
|
||||
statsReporter *statsReporter
|
||||
logSender *agentsdk.LogSender
|
||||
|
||||
@@ -941,7 +938,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
err = a.scriptRunner.Init(manifest.Scripts)
|
||||
err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("init script runner: %w", err)
|
||||
}
|
||||
@@ -949,9 +946,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
start := time.Now()
|
||||
// here we use the graceful context because the script runner is not directly tied
|
||||
// to the agent API.
|
||||
err := a.scriptRunner.Execute(a.gracefulCtx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStart
|
||||
})
|
||||
err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts)
|
||||
// Measure the time immediately after the script has finished
|
||||
dur := time.Since(start).Seconds()
|
||||
if err != nil {
|
||||
@@ -1114,18 +1109,14 @@ func (a *agent) updateCommandEnv(current []string) (updated []string, err error)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (a *agent) wireguardAddresses(agentID uuid.UUID) []netip.Prefix {
|
||||
if len(a.addresses) == 0 {
|
||||
return []netip.Prefix{
|
||||
// This is the IP that should be used primarily.
|
||||
netip.PrefixFrom(tailnet.IPFromUUID(agentID), 128),
|
||||
// We also listen on the legacy codersdk.WorkspaceAgentIP. This
|
||||
// allows for a transition away from wsconncache.
|
||||
netip.PrefixFrom(workspacesdk.AgentIP, 128),
|
||||
}
|
||||
func (*agent) wireguardAddresses(agentID uuid.UUID) []netip.Prefix {
|
||||
return []netip.Prefix{
|
||||
// This is the IP that should be used primarily.
|
||||
tailnet.TailscaleServicePrefix.PrefixFromUUID(agentID),
|
||||
// We'll need this address for CoderVPN, but aren't using it from clients until that feature
|
||||
// is ready
|
||||
tailnet.CoderServicePrefix.PrefixFromUUID(agentID),
|
||||
}
|
||||
|
||||
return a.addresses
|
||||
}
|
||||
|
||||
func (a *agent) trackGoroutine(fn func()) error {
|
||||
@@ -1143,11 +1134,19 @@ func (a *agent) trackGoroutine(fn func()) error {
|
||||
}
|
||||
|
||||
func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, derpForceWebSockets, disableDirectConnections bool) (_ *tailnet.Conn, err error) {
|
||||
// Inject `CODER_AGENT_HEADER` into the DERP header.
|
||||
var header http.Header
|
||||
if client, ok := a.client.(*agentsdk.Client); ok {
|
||||
if headerTransport, ok := client.SDK.HTTPClient.Transport.(*codersdk.HeaderTransport); ok {
|
||||
header = headerTransport.Header
|
||||
}
|
||||
}
|
||||
network, err := tailnet.NewConn(&tailnet.Options{
|
||||
ID: agentID,
|
||||
Addresses: a.wireguardAddresses(agentID),
|
||||
DERPMap: derpMap,
|
||||
DERPForceWebSockets: derpForceWebSockets,
|
||||
DERPHeader: &header,
|
||||
Logger: a.logger.Named("net.tailnet"),
|
||||
ListenPort: a.tailnetListenPort,
|
||||
BlockEndpoints: disableDirectConnections,
|
||||
@@ -1360,7 +1359,7 @@ func (a *agent) runCoordinator(ctx context.Context, conn drpc.Conn, network *tai
|
||||
defer close(errCh)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := coordination.Close()
|
||||
err := coordination.Close(a.hardCtx)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "failed to close remote coordination", slog.Error(err))
|
||||
}
|
||||
@@ -1510,6 +1509,8 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
var mu sync.Mutex
|
||||
status := a.network.Status()
|
||||
durations := []float64{}
|
||||
p2pConns := 0
|
||||
derpConns := 0
|
||||
pingCtx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
for nodeID, peer := range status.Peer {
|
||||
@@ -1526,13 +1527,18 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
duration, _, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
|
||||
duration, p2p, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
durations = append(durations, float64(duration.Microseconds()))
|
||||
if p2p {
|
||||
p2pConns++
|
||||
} else {
|
||||
derpConns++
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -1552,6 +1558,9 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
// Agent metrics are changing all the time, so there is no need to perform
|
||||
// reflect.DeepEqual to see if stats should be transferred.
|
||||
|
||||
// currentConnections behaves like a hypothetical `GaugeFuncVec` and is only set at collection time.
|
||||
a.metrics.currentConnections.WithLabelValues("p2p").Set(float64(p2pConns))
|
||||
a.metrics.currentConnections.WithLabelValues("derp").Set(float64(derpConns))
|
||||
metricsCtx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
a.logger.Debug(ctx, "collecting agent metrics for stats")
|
||||
@@ -1669,13 +1678,12 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
}
|
||||
|
||||
score, niceErr := proc.Niceness(a.syscaller)
|
||||
if niceErr != nil && !xerrors.Is(niceErr, os.ErrPermission) {
|
||||
if niceErr != nil && !isBenignProcessErr(niceErr) {
|
||||
debouncer.Warn(ctx, "unable to get proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
slog.Error(niceErr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// We only want processes that don't have a nice value set
|
||||
@@ -1689,7 +1697,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
|
||||
if niceErr == nil {
|
||||
err := proc.SetNiceness(a.syscaller, niceness)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if err != nil && !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -1703,7 +1711,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
if oomScore != unsetOOMScore && oomScore != proc.OOMScoreAdj && !isCustomOOMScore(agentScore, proc) {
|
||||
oomScoreStr := strconv.Itoa(oomScore)
|
||||
err := afero.WriteFile(a.filesystem, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), []byte(oomScoreStr), 0o644)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if err != nil && !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set oom_score_adj",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -1838,9 +1846,7 @@ func (a *agent) Close() error {
|
||||
a.gracefulCancel()
|
||||
|
||||
lifecycleState := codersdk.WorkspaceAgentLifecycleOff
|
||||
err = a.scriptRunner.Execute(a.hardCtx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStop
|
||||
})
|
||||
err = a.scriptRunner.Execute(a.hardCtx, agentscripts.ExecuteStopScripts)
|
||||
if err != nil {
|
||||
a.logger.Warn(a.hardCtx, "shutdown script(s) failed", slog.Error(err))
|
||||
if errors.Is(err, agentscripts.ErrTimeout) {
|
||||
@@ -2139,3 +2145,14 @@ func (l *logDebouncer) log(ctx context.Context, level slog.Level, msg string, fi
|
||||
}
|
||||
l.messages[msg] = time.Now()
|
||||
}
|
||||
|
||||
func isBenignProcessErr(err error) bool {
|
||||
return err != nil &&
|
||||
(xerrors.Is(err, os.ErrNotExist) ||
|
||||
xerrors.Is(err, os.ErrPermission) ||
|
||||
isNoSuchProcessErr(err))
|
||||
}
|
||||
|
||||
func isNoSuchProcessErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no such process")
|
||||
}
|
||||
|
||||
+94
-52
@@ -19,6 +19,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -1517,10 +1518,12 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
ID: uuid.New(),
|
||||
LogPath: "coder-startup-script.log",
|
||||
Script: "echo 1",
|
||||
RunOnStart: true,
|
||||
}, {
|
||||
ID: uuid.New(),
|
||||
LogPath: "coder-shutdown-script.log",
|
||||
Script: "echo " + expected,
|
||||
RunOnStop: true,
|
||||
@@ -1812,20 +1815,45 @@ func TestAgent_Dial(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
c, err := l.Accept()
|
||||
if assert.NoError(t, err, "accept connection") {
|
||||
defer c.Close()
|
||||
testAccept(ctx, t, c)
|
||||
for range 2 {
|
||||
c, err := l.Accept()
|
||||
if assert.NoError(t, err, "accept connection") {
|
||||
testAccept(ctx, t, c)
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
agentID := uuid.UUID{0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8}
|
||||
//nolint:dogsled
|
||||
agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
AgentID: agentID,
|
||||
}, 0)
|
||||
require.True(t, agentConn.AwaitReachable(ctx))
|
||||
conn, err := agentConn.DialContext(ctx, l.Addr().Network(), l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
testDial(ctx, t, conn)
|
||||
err = conn.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// also connect via the CoderServicePrefix, to test that we can reach the agent on this
|
||||
// IP. This will be required for CoderVPN.
|
||||
_, rawPort, _ := net.SplitHostPort(l.Addr().String())
|
||||
port, _ := strconv.ParseUint(rawPort, 10, 16)
|
||||
ipp := netip.AddrPortFrom(tailnet.CoderServicePrefix.AddrFromUUID(agentID), uint16(port))
|
||||
|
||||
switch l.Addr().Network() {
|
||||
case "tcp":
|
||||
conn, err = agentConn.Conn.DialContextTCP(ctx, ipp)
|
||||
case "udp":
|
||||
conn, err = agentConn.Conn.DialContextUDP(ctx, ipp)
|
||||
default:
|
||||
t.Fatalf("unknown network: %s", l.Addr().Network())
|
||||
}
|
||||
require.NoError(t, err)
|
||||
testDial(ctx, t, conn)
|
||||
err = conn.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1878,7 +1906,7 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
// Setup a client connection.
|
||||
newClientConn := func(derpMap *tailcfg.DERPMap, name string) *workspacesdk.AgentConn {
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()},
|
||||
DERPMap: derpMap,
|
||||
Logger: logger.Named(name),
|
||||
})
|
||||
@@ -1896,7 +1924,9 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
coordinator, conn)
|
||||
t.Cleanup(func() {
|
||||
t.Logf("closing coordination %s", name)
|
||||
err := coordination.Close()
|
||||
cctx, ccancel := context.WithTimeout(testCtx, testutil.WaitShort)
|
||||
defer ccancel()
|
||||
err := coordination.Close(cctx)
|
||||
if err != nil {
|
||||
t.Logf("error closing in-memory coordination: %s", err.Error())
|
||||
}
|
||||
@@ -2368,7 +2398,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
|
||||
_ = agnt.Close()
|
||||
})
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.TailscaleServicePrefix.RandomAddr(), 128)},
|
||||
DERPMap: metadata.DERPMap,
|
||||
Logger: logger.Named("client"),
|
||||
})
|
||||
@@ -2384,7 +2414,9 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
|
||||
clientID, metadata.AgentID,
|
||||
coordinator, conn)
|
||||
t.Cleanup(func() {
|
||||
err := coordination.Close()
|
||||
cctx, ccancel := context.WithTimeout(testCtx, testutil.WaitShort)
|
||||
defer ccancel()
|
||||
err := coordination.Close(cctx)
|
||||
if err != nil {
|
||||
t.Logf("error closing in-mem coordination: %s", err.Error())
|
||||
}
|
||||
@@ -2531,17 +2563,17 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []agentsdk.AgentMetric{
|
||||
expected := []*proto.Stats_Metric{
|
||||
{
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_sessions_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 1,
|
||||
Labels: []agentsdk.AgentMetricLabel{
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "magic_type",
|
||||
Value: "ssh",
|
||||
@@ -2554,30 +2586,46 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: agentsdk.AgentMetricTypeGauge,
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 0,
|
||||
Labels: []agentsdk.AgentMetricLabel{
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "success",
|
||||
Value: "true",
|
||||
Name: "connection_type",
|
||||
Value: "derp",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
Value: "p2p",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
|
||||
var actual []*promgo.MetricFamily
|
||||
@@ -2586,17 +2634,33 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(expected) != len(actual) {
|
||||
return false
|
||||
count := 0
|
||||
for _, m := range actual {
|
||||
count += len(m.GetMetric())
|
||||
}
|
||||
|
||||
return verifyCollectedMetrics(t, expected, actual)
|
||||
return count == len(expected)
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
require.Len(t, actual, len(expected))
|
||||
collected := verifyCollectedMetrics(t, expected, actual)
|
||||
require.True(t, collected, "expected metrics were not collected")
|
||||
i := 0
|
||||
for _, mf := range actual {
|
||||
for _, m := range mf.GetMetric() {
|
||||
assert.Equal(t, expected[i].Name, mf.GetName())
|
||||
assert.Equal(t, expected[i].Type.String(), mf.GetType().String())
|
||||
// Value is max expected
|
||||
if expected[i].Type == proto.Stats_Metric_GAUGE {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue())
|
||||
} else if expected[i].Type == proto.Stats_Metric_COUNTER {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue())
|
||||
}
|
||||
for j, lbl := range expected[i].Labels {
|
||||
assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{
|
||||
Name: &lbl.Name,
|
||||
Value: &lbl.Value,
|
||||
})
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
@@ -2828,28 +2892,6 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actual []*promgo.MetricFamily) bool {
|
||||
t.Helper()
|
||||
|
||||
for i, e := range expected {
|
||||
assert.Equal(t, e.Name, actual[i].GetName())
|
||||
assert.Equal(t, string(e.Type), strings.ToLower(actual[i].GetType().String()))
|
||||
|
||||
for _, m := range actual[i].GetMetric() {
|
||||
assert.Equal(t, e.Value, m.Counter.GetValue())
|
||||
|
||||
if len(m.GetLabel()) > 0 {
|
||||
for j, lbl := range m.GetLabel() {
|
||||
assert.Equal(t, e.Labels[j].Name, lbl.GetName())
|
||||
assert.Equal(t, e.Labels[j].Value, lbl.GetValue())
|
||||
}
|
||||
}
|
||||
m.GetLabel()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type syncWriter struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
|
||||
@@ -45,8 +45,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline"))
|
||||
if err != nil {
|
||||
var errNo syscall.Errno
|
||||
if xerrors.As(err, &errNo) && errNo == syscall.EPERM {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
return nil, xerrors.Errorf("read cmdline: %w", err)
|
||||
@@ -54,7 +53,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
oomScore, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "oom_score_adj"))
|
||||
if err != nil {
|
||||
if xerrors.Is(err, os.ErrPermission) {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,3 +123,12 @@ func (p *Process) Cmd() string {
|
||||
func (p *Process) cmdLine() []string {
|
||||
return strings.Split(p.CmdLine, "\x00")
|
||||
}
|
||||
|
||||
func isBenignError(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if !xerrors.As(err, &errno) {
|
||||
return false
|
||||
}
|
||||
|
||||
return errno == syscall.ESRCH || errno == syscall.EPERM || xerrors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,13 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
@@ -75,18 +78,21 @@ func New(opts Options) *Runner {
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)
|
||||
|
||||
type Runner struct {
|
||||
Options
|
||||
|
||||
cronCtx context.Context
|
||||
cronCtxCancel context.CancelFunc
|
||||
cmdCloseWait sync.WaitGroup
|
||||
closed chan struct{}
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
dataDir string
|
||||
cronCtx context.Context
|
||||
cronCtxCancel context.CancelFunc
|
||||
cmdCloseWait sync.WaitGroup
|
||||
closed chan struct{}
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
dataDir string
|
||||
scriptCompleted ScriptCompletedFunc
|
||||
|
||||
// scriptsExecuted includes all scripts executed by the workspace agent. Agents
|
||||
// execute startup scripts, and scripts on a cron schedule. Both will increment
|
||||
@@ -116,12 +122,13 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
|
||||
// Init initializes the runner with the provided scripts.
|
||||
// It also schedules any scripts that have a schedule.
|
||||
// This function must be called before Execute.
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc) error {
|
||||
if r.initialized.Load() {
|
||||
return xerrors.New("init: already initialized")
|
||||
}
|
||||
r.initialized.Store(true)
|
||||
r.scripts = scripts
|
||||
r.scriptCompleted = scriptCompleted
|
||||
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
|
||||
|
||||
err := r.Filesystem.MkdirAll(r.ScriptBinDir(), 0o700)
|
||||
@@ -135,7 +142,7 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
|
||||
}
|
||||
script := script
|
||||
_, err := r.cron.AddFunc(script.Cron, func() {
|
||||
err := r.trackRun(r.cronCtx, script)
|
||||
err := r.trackRun(r.cronCtx, script, ExecuteCronScripts)
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
|
||||
}
|
||||
@@ -172,22 +179,33 @@ func (r *Runner) StartCron() {
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteOption describes what scripts we want to execute.
|
||||
type ExecuteOption int
|
||||
|
||||
// ExecuteOption enums.
|
||||
const (
|
||||
ExecuteAllScripts ExecuteOption = iota
|
||||
ExecuteStartScripts
|
||||
ExecuteStopScripts
|
||||
ExecuteCronScripts
|
||||
)
|
||||
|
||||
// Execute runs a set of scripts according to a filter.
|
||||
func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.WorkspaceAgentScript) bool) error {
|
||||
if filter == nil {
|
||||
// Execute em' all!
|
||||
filter = func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
var eg errgroup.Group
|
||||
for _, script := range r.scripts {
|
||||
if !filter(script) {
|
||||
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
|
||||
(option == ExecuteStopScripts && script.RunOnStop) ||
|
||||
(option == ExecuteCronScripts && script.Cron != "") ||
|
||||
option == ExecuteAllScripts
|
||||
|
||||
if !runScript {
|
||||
continue
|
||||
}
|
||||
|
||||
script := script
|
||||
eg.Go(func() error {
|
||||
err := r.trackRun(ctx, script)
|
||||
err := r.trackRun(ctx, script, option)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
|
||||
}
|
||||
@@ -198,8 +216,8 @@ func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.Worksp
|
||||
}
|
||||
|
||||
// trackRun wraps "run" with metrics.
|
||||
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
|
||||
err := r.run(ctx, script)
|
||||
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
|
||||
err := r.run(ctx, script, option)
|
||||
if err != nil {
|
||||
r.scriptsExecuted.WithLabelValues("false").Add(1)
|
||||
} else {
|
||||
@@ -212,7 +230,7 @@ func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScr
|
||||
// If the timeout is exceeded, the process is sent an interrupt signal.
|
||||
// If the process does not exit after a few seconds, it is forcefully killed.
|
||||
// This function immediately returns after a timeout, and does not wait for the process to exit.
|
||||
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
|
||||
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
|
||||
logPath := script.LogPath
|
||||
if logPath == "" {
|
||||
logPath = fmt.Sprintf("coder-script-%s.log", script.LogSourceID)
|
||||
@@ -299,9 +317,9 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
|
||||
cmd.Stdout = io.MultiWriter(fileWriter, infoW)
|
||||
cmd.Stderr = io.MultiWriter(fileWriter, errW)
|
||||
|
||||
start := time.Now()
|
||||
start := dbtime.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
end := dbtime.Now()
|
||||
execTime := end.Sub(start)
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
@@ -314,6 +332,60 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
|
||||
} else {
|
||||
logger.Info(ctx, fmt.Sprintf("%s script completed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode))
|
||||
}
|
||||
|
||||
if r.scriptCompleted == nil {
|
||||
logger.Debug(ctx, "r.scriptCompleted unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
// We want to check this outside of the goroutine to avoid a race condition
|
||||
timedOut := errors.Is(err, ErrTimeout)
|
||||
pipesLeftOpen := errors.Is(err, ErrOutputPipesOpen)
|
||||
|
||||
err = r.trackCommandGoroutine(func() {
|
||||
var stage proto.Timing_Stage
|
||||
switch option {
|
||||
case ExecuteStartScripts:
|
||||
stage = proto.Timing_START
|
||||
case ExecuteStopScripts:
|
||||
stage = proto.Timing_STOP
|
||||
case ExecuteCronScripts:
|
||||
stage = proto.Timing_CRON
|
||||
}
|
||||
|
||||
var status proto.Timing_Status
|
||||
switch {
|
||||
case timedOut:
|
||||
status = proto.Timing_TIMED_OUT
|
||||
case pipesLeftOpen:
|
||||
status = proto.Timing_PIPES_LEFT_OPEN
|
||||
case exitCode != 0:
|
||||
status = proto.Timing_EXIT_FAILURE
|
||||
default:
|
||||
status = proto.Timing_OK
|
||||
}
|
||||
|
||||
reportTimeout := 30 * time.Second
|
||||
reportCtx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := r.scriptCompleted(reportCtx, &proto.WorkspaceAgentScriptCompletedRequest{
|
||||
Timing: &proto.Timing{
|
||||
ScriptId: script.ID[:],
|
||||
Start: timestamppb.New(start),
|
||||
End: timestamppb.New(end),
|
||||
ExitCode: int32(exitCode),
|
||||
Stage: stage,
|
||||
Status: status,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: %s", err.Error()))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: track command goroutine: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
err = cmd.Start()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -34,14 +35,13 @@ func TestExecuteBasic(t *testing.T) {
|
||||
return fLogger
|
||||
})
|
||||
defer runner.Close()
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo hello",
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(context.Background(), func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}))
|
||||
require.NoError(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts))
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
}
|
||||
@@ -61,18 +61,17 @@ func TestEnv(t *testing.T) {
|
||||
cmd.exe /c echo %CODER_SCRIPT_BIN_DIR%
|
||||
`
|
||||
}
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: id,
|
||||
Script: script,
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
done := testutil.Go(t, func() {
|
||||
err := runner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
})
|
||||
err := runner.Execute(ctx, agentscripts.ExecuteAllScripts)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
defer func() {
|
||||
@@ -103,13 +102,44 @@ func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := setup(t, nil)
|
||||
defer runner.Close()
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "sleep infinity",
|
||||
Timeout: time.Millisecond,
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), nil), agentscripts.ErrTimeout)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts), agentscripts.ErrTimeout)
|
||||
}
|
||||
|
||||
func TestScriptReportsTiming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
fLogger := newFakeScriptLogger()
|
||||
runner := setup(t, func(uuid2 uuid.UUID) agentscripts.ScriptLogger {
|
||||
return fLogger
|
||||
})
|
||||
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
DisplayName: "say-hello",
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo hello",
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteAllScripts))
|
||||
runner.Close()
|
||||
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
|
||||
timings := aAPI.GetTimings()
|
||||
require.Equal(t, 1, len(timings))
|
||||
|
||||
timing := timings[0]
|
||||
require.Equal(t, int32(0), timing.ExitCode)
|
||||
require.GreaterOrEqual(t, timing.End.AsTime(), timing.Start.AsTime())
|
||||
}
|
||||
|
||||
// TestCronClose exists because cron.Run() can happen after cron.Close().
|
||||
|
||||
@@ -79,9 +79,9 @@ type Config struct {
|
||||
// where users will land when they connect via SSH. Default is the home
|
||||
// directory of the user.
|
||||
WorkingDirectory func() string
|
||||
// X11SocketDir is the directory where X11 sockets are created. Default is
|
||||
// /tmp/.X11-unix.
|
||||
X11SocketDir string
|
||||
// X11DisplayOffset is the offset to add to the X11 display number.
|
||||
// Default is 10.
|
||||
X11DisplayOffset *int
|
||||
// BlockFileTransfer restricts use of file transfer applications.
|
||||
BlockFileTransfer bool
|
||||
}
|
||||
@@ -124,8 +124,9 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
if config.X11SocketDir == "" {
|
||||
config.X11SocketDir = filepath.Join(os.TempDir(), ".X11-unix")
|
||||
if config.X11DisplayOffset == nil {
|
||||
offset := X11DefaultDisplayOffset
|
||||
config.X11DisplayOffset = &offset
|
||||
}
|
||||
if config.UpdateEnv == nil {
|
||||
config.UpdateEnv = func(current []string) ([]string, error) { return current, nil }
|
||||
@@ -273,13 +274,13 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
extraEnv := make([]string, 0)
|
||||
x11, hasX11 := session.X11()
|
||||
if hasX11 {
|
||||
handled := s.x11Handler(session.Context(), x11)
|
||||
display, handled := s.x11Handler(session.Context(), x11)
|
||||
if !handled {
|
||||
_ = session.Exit(1)
|
||||
logger.Error(ctx, "x11 handler failed")
|
||||
return
|
||||
}
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
|
||||
}
|
||||
|
||||
if s.fileTransferBlocked(session) {
|
||||
|
||||
+91
-56
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,61 +23,69 @@ import (
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// X11StartPort is the starting port for X11 forwarding, this is the
|
||||
// port used for "DISPLAY=localhost:0".
|
||||
X11StartPort = 6000
|
||||
// X11DefaultDisplayOffset is the default offset for X11 forwarding.
|
||||
X11DefaultDisplayOffset = 10
|
||||
)
|
||||
|
||||
// x11Callback is called when the client requests X11 forwarding.
|
||||
// It adds an Xauthority entry to the Xauthority file.
|
||||
func (s *Server) x11Callback(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
|
||||
return false
|
||||
}
|
||||
|
||||
err = s.fs.MkdirAll(s.config.X11SocketDir, 0o700)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to make the x11 socket dir", slog.F("dir", s.config.X11SocketDir), slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("socker_dir").Add(1)
|
||||
return false
|
||||
}
|
||||
|
||||
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(int(x11.ScreenNumber)), x11.AuthProtocol, x11.AuthCookie)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
|
||||
return false
|
||||
}
|
||||
func (*Server) x11Callback(_ ssh.Context, _ ssh.X11) bool {
|
||||
// Always allow.
|
||||
return true
|
||||
}
|
||||
|
||||
// x11Handler is called when a session has requested X11 forwarding.
|
||||
// It listens for X11 connections and forwards them to the client.
|
||||
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, handled bool) {
|
||||
serverConn, valid := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||
if !valid {
|
||||
s.logger.Warn(ctx, "failed to get server connection")
|
||||
return false
|
||||
return -1, false
|
||||
}
|
||||
// We want to overwrite the socket so that subsequent connections will succeed.
|
||||
socketPath := filepath.Join(s.config.X11SocketDir, fmt.Sprintf("X%d", x11.ScreenNumber))
|
||||
err := os.Remove(socketPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Warn(ctx, "failed to remove existing X11 socket", slog.Error(err))
|
||||
return false
|
||||
}
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to listen for X11", slog.Error(err))
|
||||
return false
|
||||
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
|
||||
ln, display, err := createX11Listener(ctx, *s.config.X11DisplayOffset)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to create X11 listener", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("listen").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
s.trackListener(ln, true)
|
||||
defer func() {
|
||||
if !handled {
|
||||
s.trackListener(ln, false)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(display), x11.AuthProtocol, x11.AuthCookie)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
s.trackListener(listener, true)
|
||||
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
defer s.trackListener(listener, false)
|
||||
handledFirstConnection := false
|
||||
// Don't leave the listener open after the session is gone.
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
defer s.trackListener(ln, false)
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
@@ -84,40 +93,66 @@ func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
s.logger.Warn(ctx, "failed to accept X11 connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
if x11.SingleConnection && handledFirstConnection {
|
||||
s.logger.Warn(ctx, "X11 connection rejected because single connection is enabled")
|
||||
if x11.SingleConnection {
|
||||
s.logger.Debug(ctx, "single connection requested, closing X11 listener")
|
||||
_ = ln.Close()
|
||||
}
|
||||
|
||||
tcpConn, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to TCPConn. got: %T", conn))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
handledFirstConnection = true
|
||||
|
||||
unixConn, ok := conn.(*net.UnixConn)
|
||||
tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to UnixConn. got: %T", conn))
|
||||
return
|
||||
}
|
||||
unixAddr, ok := unixConn.LocalAddr().(*net.UnixAddr)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to UnixAddr. got: %T", unixConn.LocalAddr()))
|
||||
return
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to TCPAddr. got: %T", tcpConn.LocalAddr()))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
channel, reqs, err := serverConn.OpenChannel("x11", gossh.Marshal(struct {
|
||||
OriginatorAddress string
|
||||
OriginatorPort uint32
|
||||
}{
|
||||
OriginatorAddress: unixAddr.Name,
|
||||
OriginatorPort: 0,
|
||||
OriginatorAddress: tcpAddr.IP.String(),
|
||||
OriginatorPort: uint32(tcpAddr.Port),
|
||||
}))
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err))
|
||||
return
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
go Bicopy(ctx, conn, channel)
|
||||
|
||||
if !s.trackConn(ln, conn, true) {
|
||||
s.logger.Warn(ctx, "failed to track X11 connection")
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer s.trackConn(ln, conn, false)
|
||||
Bicopy(ctx, conn, channel)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return true
|
||||
|
||||
return display, true
|
||||
}
|
||||
|
||||
// createX11Listener creates a listener for X11 forwarding, it will use
|
||||
// the next available port starting from X11StartPort and displayOffset.
|
||||
func createX11Listener(ctx context.Context, displayOffset int) (ln net.Listener, display int, err error) {
|
||||
var lc net.ListenConfig
|
||||
// Look for an open port to listen on.
|
||||
for port := X11StartPort + displayOffset; port < math.MaxUint16; port++ {
|
||||
ln, err = lc.Listen(ctx, "tcp", fmt.Sprintf("localhost:%d", port))
|
||||
if err == nil {
|
||||
display = port - X11StartPort
|
||||
return ln, display, nil
|
||||
}
|
||||
}
|
||||
return nil, -1, xerrors.Errorf("failed to find open port for X11 listener: %w", err)
|
||||
}
|
||||
|
||||
// addXauthEntry adds an Xauthority entry to the Xauthority file.
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package agentssh_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
@@ -31,10 +36,7 @@ func TestServer_X11(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
fs := afero.NewOsFs()
|
||||
dir := t.TempDir()
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, &agentssh.Config{
|
||||
X11SocketDir: dir,
|
||||
})
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, &agentssh.Config{})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
@@ -53,21 +55,45 @@ func TestServer_X11(t *testing.T) {
|
||||
sess, err := c.NewSession()
|
||||
require.NoError(t, err)
|
||||
|
||||
wantScreenNumber := 1
|
||||
reply, err := sess.SendRequest("x11-req", true, gossh.Marshal(ssh.X11{
|
||||
AuthProtocol: "MIT-MAGIC-COOKIE-1",
|
||||
AuthCookie: hex.EncodeToString([]byte("cookie")),
|
||||
ScreenNumber: 0,
|
||||
ScreenNumber: uint32(wantScreenNumber),
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reply)
|
||||
|
||||
err = sess.Shell()
|
||||
// Want: ~DISPLAY=localhost:10.1
|
||||
out, err := sess.Output("echo DISPLAY=$DISPLAY")
|
||||
require.NoError(t, err)
|
||||
|
||||
sc := bufio.NewScanner(bytes.NewReader(out))
|
||||
displayNumber := -1
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
t.Log(line)
|
||||
if strings.HasPrefix(line, "DISPLAY=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
display := parts[1]
|
||||
parts = strings.SplitN(display, ":", 2)
|
||||
parts = strings.SplitN(parts[1], ".", 2)
|
||||
displayNumber, err = strconv.Atoi(parts[0])
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, displayNumber, 10, "display number should be >= 10")
|
||||
gotScreenNumber, err := strconv.Atoi(parts[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantScreenNumber, gotScreenNumber, "screen number should match")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NoError(t, sc.Err())
|
||||
require.NotEqual(t, -1, displayNumber)
|
||||
|
||||
x11Chans := c.HandleChannelOpen("x11")
|
||||
payload := "hello world"
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.Dial("unix", filepath.Join(dir, "X0"))
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+displayNumber))
|
||||
if err == nil {
|
||||
_, err = conn.Write([]byte(payload))
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -170,6 +170,7 @@ type FakeAgentAPI struct {
|
||||
logsCh chan<- *agentproto.BatchCreateLogsRequest
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
metadata map[string]agentsdk.Metadata
|
||||
timings []*agentproto.Timing
|
||||
|
||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||
}
|
||||
@@ -182,6 +183,12 @@ func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBan
|
||||
return &agentproto.ServiceBanner{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetTimings() []*agentproto.Timing {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return slices.Clone(f.timings)
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) SetAnnouncementBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
@@ -301,6 +308,14 @@ func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.Batc
|
||||
return &agentproto.BatchCreateLogsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
||||
f.Lock()
|
||||
f.timings = append(f.timings, req.Timing)
|
||||
f.Unlock()
|
||||
|
||||
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
||||
}
|
||||
|
||||
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
||||
return &FakeAgentAPI{
|
||||
t: t,
|
||||
|
||||
@@ -19,6 +19,7 @@ type agentMetrics struct {
|
||||
// startupScriptSeconds is the time in seconds that the start script(s)
|
||||
// took to run. This is reported once per agent.
|
||||
startupScriptSeconds *prometheus.GaugeVec
|
||||
currentConnections *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
|
||||
@@ -45,10 +46,19 @@ func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
|
||||
}, []string{"success"})
|
||||
registerer.MustRegister(startupScriptSeconds)
|
||||
|
||||
currentConnections := prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentstats",
|
||||
Name: "currently_reachable_peers",
|
||||
Help: "The number of peers (e.g. clients) that are currently reachable over the encrypted network.",
|
||||
}, []string{"connection_type"})
|
||||
registerer.MustRegister(currentConnections)
|
||||
|
||||
return &agentMetrics{
|
||||
connectionsTotal: connectionsTotal,
|
||||
reconnectingPTYErrors: reconnectingPTYErrors,
|
||||
startupScriptSeconds: startupScriptSeconds,
|
||||
currentConnections: currentConnections,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+881
-491
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ message WorkspaceApp {
|
||||
UNHEALTHY = 4;
|
||||
}
|
||||
Health health = 12;
|
||||
bool hidden = 13;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScript {
|
||||
@@ -52,6 +53,8 @@ message WorkspaceAgentScript {
|
||||
bool run_on_stop = 6;
|
||||
bool start_blocks_login = 7;
|
||||
google.protobuf.Duration timeout = 8;
|
||||
string display_name = 9;
|
||||
bytes id = 10;
|
||||
}
|
||||
|
||||
message WorkspaceAgentMetadata {
|
||||
@@ -263,6 +266,35 @@ message BannerConfig {
|
||||
string background_color = 3;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScriptCompletedRequest {
|
||||
Timing timing = 1;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScriptCompletedResponse {
|
||||
}
|
||||
|
||||
message Timing {
|
||||
bytes script_id = 1;
|
||||
google.protobuf.Timestamp start = 2;
|
||||
google.protobuf.Timestamp end = 3;
|
||||
int32 exit_code = 4;
|
||||
|
||||
enum Stage {
|
||||
START = 0;
|
||||
STOP = 1;
|
||||
CRON = 2;
|
||||
}
|
||||
Stage stage = 5;
|
||||
|
||||
enum Status {
|
||||
OK = 0;
|
||||
EXIT_FAILURE = 1;
|
||||
TIMED_OUT = 2;
|
||||
PIPES_LEFT_OPEN = 3;
|
||||
}
|
||||
Status status = 6;
|
||||
}
|
||||
|
||||
service Agent {
|
||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||
@@ -273,4 +305,5 @@ service Agent {
|
||||
rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse);
|
||||
rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse);
|
||||
rpc GetAnnouncementBanners(GetAnnouncementBannersRequest) returns (GetAnnouncementBannersResponse);
|
||||
rpc ScriptCompleted(WorkspaceAgentScriptCompletedRequest) returns (WorkspaceAgentScriptCompletedResponse);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type DRPCAgentClient interface {
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentClient struct {
|
||||
@@ -140,6 +141,15 @@ func (c *drpcAgentClient) GetAnnouncementBanners(ctx context.Context, in *GetAnn
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error) {
|
||||
out := new(WorkspaceAgentScriptCompletedResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/ScriptCompleted", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentServer interface {
|
||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
@@ -150,6 +160,7 @@ type DRPCAgentServer interface {
|
||||
BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(context.Context, *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentUnimplementedServer struct{}
|
||||
@@ -190,9 +201,13 @@ func (s *DRPCAgentUnimplementedServer) GetAnnouncementBanners(context.Context, *
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentDescription struct{}
|
||||
|
||||
func (DRPCAgentDescription) NumMethods() int { return 9 }
|
||||
func (DRPCAgentDescription) NumMethods() int { return 10 }
|
||||
|
||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
@@ -277,6 +292,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
|
||||
in1.(*GetAnnouncementBannersRequest),
|
||||
)
|
||||
}, DRPCAgentServer.GetAnnouncementBanners, true
|
||||
case 9:
|
||||
return "/coder.agent.v2.Agent/ScriptCompleted", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
ScriptCompleted(
|
||||
ctx,
|
||||
in1.(*WorkspaceAgentScriptCompletedRequest),
|
||||
)
|
||||
}, DRPCAgentServer.ScriptCompleted, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
@@ -429,3 +453,19 @@ func (x *drpcAgent_GetAnnouncementBannersStream) SendAndClose(m *GetAnnouncement
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_ScriptCompletedStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*WorkspaceAgentScriptCompletedResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_ScriptCompletedStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_ScriptCompletedStream) SendAndClose(m *WorkspaceAgentScriptCompletedResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package coderd
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
@@ -10,21 +10,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CreateTarFromZip(zipReader *zip.Reader) ([]byte, error) {
|
||||
// CreateTarFromZip converts the given zipReader to a tar archive.
|
||||
func CreateTarFromZip(zipReader *zip.Reader, maxSize int64) ([]byte, error) {
|
||||
var tarBuffer bytes.Buffer
|
||||
err := writeTarArchive(&tarBuffer, zipReader)
|
||||
err := writeTarArchive(&tarBuffer, zipReader, maxSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tarBuffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeTarArchive(w io.Writer, zipReader *zip.Reader) error {
|
||||
func writeTarArchive(w io.Writer, zipReader *zip.Reader, maxSize int64) error {
|
||||
tarWriter := tar.NewWriter(w)
|
||||
defer tarWriter.Close()
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
err := processFileInZipArchive(file, tarWriter)
|
||||
err := processFileInZipArchive(file, tarWriter, maxSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -32,7 +33,7 @@ func writeTarArchive(w io.Writer, zipReader *zip.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error {
|
||||
func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer, maxSize int64) error {
|
||||
fileReader, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -52,7 +53,7 @@ func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := io.CopyN(tarWriter, fileReader, httpFileMaxBytes)
|
||||
n, err := io.CopyN(tarWriter, fileReader, maxSize)
|
||||
log.Println(file.Name, n, err)
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = nil
|
||||
@@ -60,16 +61,18 @@ func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func CreateZipFromTar(tarReader *tar.Reader) ([]byte, error) {
|
||||
// CreateZipFromTar converts the given tarReader to a zip archive.
|
||||
func CreateZipFromTar(tarReader *tar.Reader, maxSize int64) ([]byte, error) {
|
||||
var zipBuffer bytes.Buffer
|
||||
err := WriteZipArchive(&zipBuffer, tarReader)
|
||||
err := WriteZip(&zipBuffer, tarReader, maxSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zipBuffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func WriteZipArchive(w io.Writer, tarReader *tar.Reader) error {
|
||||
// WriteZip writes the given tarReader to w.
|
||||
func WriteZip(w io.Writer, tarReader *tar.Reader, maxSize int64) error {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
defer zipWriter.Close()
|
||||
|
||||
@@ -100,7 +103,7 @@ func WriteZipArchive(w io.Writer, tarReader *tar.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.CopyN(zipEntry, tarReader, httpFileMaxBytes)
|
||||
_, err = io.CopyN(zipEntry, tarReader, maxSize)
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = nil
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package coderd_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -12,13 +11,12 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/archive"
|
||||
"github.com/coder/coder/v2/archive/archivetest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
@@ -30,18 +28,17 @@ func TestCreateTarFromZip(t *testing.T) {
|
||||
|
||||
// Read a zip file we prepared earlier
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
zipBytes, err := os.ReadFile(filepath.Join("testdata", "test.zip"))
|
||||
require.NoError(t, err, "failed to read sample zip file")
|
||||
zipBytes := archivetest.TestZipFileBytes()
|
||||
// Assert invariant
|
||||
assertSampleZipFile(t, zipBytes)
|
||||
archivetest.AssertSampleZipFile(t, zipBytes)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||
require.NoError(t, err, "failed to parse sample zip file")
|
||||
|
||||
tarBytes, err := coderd.CreateTarFromZip(zr)
|
||||
tarBytes, err := archive.CreateTarFromZip(zr, int64(len(zipBytes)))
|
||||
require.NoError(t, err, "failed to convert zip to tar")
|
||||
|
||||
assertSampleTarFile(t, tarBytes)
|
||||
archivetest.AssertSampleTarFile(t, tarBytes)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
tempFilePath := filepath.Join(tempDir, "test.tar")
|
||||
@@ -60,14 +57,13 @@ func TestCreateZipFromTar(t *testing.T) {
|
||||
}
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tarBytes, err := os.ReadFile(filepath.Join(".", "testdata", "test.tar"))
|
||||
require.NoError(t, err, "failed to read sample tar file")
|
||||
tarBytes := archivetest.TestTarFileBytes()
|
||||
|
||||
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
||||
zipBytes, err := coderd.CreateZipFromTar(tr)
|
||||
zipBytes, err := archive.CreateZipFromTar(tr, int64(len(tarBytes)))
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSampleZipFile(t, zipBytes)
|
||||
archivetest.AssertSampleZipFile(t, zipBytes)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
tempFilePath := filepath.Join(tempDir, "test.zip")
|
||||
@@ -99,7 +95,7 @@ func TestCreateZipFromTar(t *testing.T) {
|
||||
|
||||
// When: we convert this to a zip
|
||||
tr := tar.NewReader(&tarBytes)
|
||||
zipBytes, err := coderd.CreateZipFromTar(tr)
|
||||
zipBytes, err := archive.CreateZipFromTar(tr, int64(tarBytes.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the resulting zip should contain a corresponding directory
|
||||
@@ -133,7 +129,7 @@ func assertExtractedFiles(t *testing.T, dir string, checkModePerm bool) {
|
||||
if checkModePerm {
|
||||
assert.Equal(t, fs.ModePerm&0o755, stat.Mode().Perm(), "expected mode 0755 on directory")
|
||||
}
|
||||
assert.Equal(t, archiveRefTime(t).UTC(), stat.ModTime().UTC(), "unexpected modtime of %q", path)
|
||||
assert.Equal(t, archivetest.ArchiveRefTime(t).UTC(), stat.ModTime().UTC(), "unexpected modtime of %q", path)
|
||||
case "/test/hello.txt":
|
||||
stat, err := os.Stat(path)
|
||||
assert.NoError(t, err, "failed to stat path %q", path)
|
||||
@@ -168,84 +164,3 @@ func assertExtractedFiles(t *testing.T, dir string, checkModePerm bool) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func assertSampleTarFile(t *testing.T, tarBytes []byte) {
|
||||
t.Helper()
|
||||
|
||||
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Note: ignoring timezones here.
|
||||
require.Equal(t, archiveRefTime(t).UTC(), hdr.ModTime.UTC())
|
||||
|
||||
switch hdr.Name {
|
||||
case "test/":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeDir))
|
||||
case "test/hello.txt":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeReg))
|
||||
bs, err := io.ReadAll(tr)
|
||||
if err != nil && !xerrors.Is(err, io.EOF) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, "hello", string(bs))
|
||||
case "test/dir/":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeDir))
|
||||
case "test/dir/world.txt":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeReg))
|
||||
bs, err := io.ReadAll(tr)
|
||||
if err != nil && !xerrors.Is(err, io.EOF) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, "world", string(bs))
|
||||
default:
|
||||
require.Failf(t, "unexpected file in tar", hdr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSampleZipFile(t *testing.T, zipBytes []byte) {
|
||||
t.Helper()
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// Note: ignoring timezones here.
|
||||
require.Equal(t, archiveRefTime(t).UTC(), f.Modified.UTC())
|
||||
switch f.Name {
|
||||
case "test/", "test/dir/":
|
||||
// directory
|
||||
case "test/hello.txt":
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
bs, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", string(bs))
|
||||
case "test/dir/world.txt":
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
bs, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "world", string(bs))
|
||||
default:
|
||||
require.Failf(t, "unexpected file in zip", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// archiveRefTime is the Go reference time. The contents of the sample tar and zip files
|
||||
// in testdata/ all have their modtimes set to the below in some timezone.
|
||||
func archiveRefTime(t *testing.T) time.Time {
|
||||
locMST, err := time.LoadLocation("MST")
|
||||
require.NoError(t, err, "failed to load MST timezone")
|
||||
return time.Date(2006, 1, 2, 3, 4, 5, 0, locMST)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package archivetest
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:embed testdata/test.tar
|
||||
var testTarFileBytes []byte
|
||||
|
||||
//go:embed testdata/test.zip
|
||||
var testZipFileBytes []byte
|
||||
|
||||
// TestTarFileBytes returns the content of testdata/test.tar
|
||||
func TestTarFileBytes() []byte {
|
||||
return append([]byte{}, testTarFileBytes...)
|
||||
}
|
||||
|
||||
// TestZipFileBytes returns the content of testdata/test.zip
|
||||
func TestZipFileBytes() []byte {
|
||||
return append([]byte{}, testZipFileBytes...)
|
||||
}
|
||||
|
||||
// AssertSampleTarfile compares the content of tarBytes against testdata/test.tar.
|
||||
func AssertSampleTarFile(t *testing.T, tarBytes []byte) {
|
||||
t.Helper()
|
||||
|
||||
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Note: ignoring timezones here.
|
||||
require.Equal(t, ArchiveRefTime(t).UTC(), hdr.ModTime.UTC())
|
||||
|
||||
switch hdr.Name {
|
||||
case "test/":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeDir))
|
||||
case "test/hello.txt":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeReg))
|
||||
bs, err := io.ReadAll(tr)
|
||||
if err != nil && !xerrors.Is(err, io.EOF) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, "hello", string(bs))
|
||||
case "test/dir/":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeDir))
|
||||
case "test/dir/world.txt":
|
||||
require.Equal(t, hdr.Typeflag, byte(tar.TypeReg))
|
||||
bs, err := io.ReadAll(tr)
|
||||
if err != nil && !xerrors.Is(err, io.EOF) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, "world", string(bs))
|
||||
default:
|
||||
require.Failf(t, "unexpected file in tar", hdr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertSampleZipFile compares the content of zipBytes against testdata/test.zip.
|
||||
func AssertSampleZipFile(t *testing.T, zipBytes []byte) {
|
||||
t.Helper()
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// Note: ignoring timezones here.
|
||||
require.Equal(t, ArchiveRefTime(t).UTC(), f.Modified.UTC())
|
||||
switch f.Name {
|
||||
case "test/", "test/dir/":
|
||||
// directory
|
||||
case "test/hello.txt":
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
bs, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", string(bs))
|
||||
case "test/dir/world.txt":
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
bs, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "world", string(bs))
|
||||
default:
|
||||
require.Failf(t, "unexpected file in zip", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// archiveRefTime is the Go reference time. The contents of the sample tar and zip files
|
||||
// in testdata/ all have their modtimes set to the below in some timezone.
|
||||
func ArchiveRefTime(t *testing.T) time.Time {
|
||||
locMST, err := time.LoadLocation("MST")
|
||||
require.NoError(t, err, "failed to load MST timezone")
|
||||
return time.Date(2006, 1, 2, 3, 4, 5, 0, locMST)
|
||||
}
|
||||
@@ -24,6 +24,9 @@ var (
|
||||
// Updated by buildinfo_slim.go on start.
|
||||
slim bool
|
||||
|
||||
// Updated by buildinfo_site.go on start.
|
||||
site bool
|
||||
|
||||
// Injected with ldflags at build, see scripts/build_go.sh
|
||||
tag string
|
||||
agpl string // either "true" or "false", ldflags does not support bools
|
||||
@@ -95,6 +98,11 @@ func IsSlim() bool {
|
||||
return slim
|
||||
}
|
||||
|
||||
// HasSite returns true if the frontend is embedded in the build.
|
||||
func HasSite() bool {
|
||||
return site
|
||||
}
|
||||
|
||||
// IsAGPL returns true if this is an AGPL build.
|
||||
func IsAGPL() bool {
|
||||
return strings.Contains(agpl, "t")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build embed
|
||||
|
||||
package buildinfo
|
||||
|
||||
func init() {
|
||||
site = true
|
||||
}
|
||||
+76
-26
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
@@ -35,7 +35,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).
|
||||
@@ -71,7 +71,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
AzureCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
@@ -110,7 +110,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
AWSCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
@@ -151,7 +151,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
@@ -205,7 +205,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
@@ -232,42 +232,92 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystems[0])
|
||||
require.Equal(t, codersdk.AgentSubsystemExectrace, resources[0].Agents[0].Subsystems[1])
|
||||
})
|
||||
t.Run("Header", func(t *testing.T) {
|
||||
t.Run("Headers&DERPHeaders", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var url string
|
||||
var called int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "wow", r.Header.Get("X-Testing"))
|
||||
assert.Equal(t, "Ethan was Here!", r.Header.Get("Cool-Header"))
|
||||
assert.Equal(t, "very-wow-"+url, r.Header.Get("X-Process-Testing"))
|
||||
assert.Equal(t, "more-wow", r.Header.Get("X-Process-Testing2"))
|
||||
atomic.AddInt64(&called, 1)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
// Create a coderd API instance the hard way since we need to change the
|
||||
// handler to inject our custom /derp handler.
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.DERP.Config.BlockDirect = true
|
||||
setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
})
|
||||
|
||||
// We set the handler after server creation for the access URL.
|
||||
coderAPI := coderd.New(newOptions)
|
||||
setHandler(coderAPI.RootHandler)
|
||||
provisionerCloser := coderdtest.NewProvisionerDaemon(t, coderAPI)
|
||||
t.Cleanup(func() {
|
||||
_ = provisionerCloser.Close()
|
||||
})
|
||||
client := codersdk.New(serverURL)
|
||||
t.Cleanup(func() {
|
||||
cancelFunc()
|
||||
_ = provisionerCloser.Close()
|
||||
_ = coderAPI.Close()
|
||||
client.HTTPClient.CloseIdleConnections()
|
||||
})
|
||||
|
||||
var (
|
||||
admin = coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
called int64
|
||||
derpCalled int64
|
||||
)
|
||||
|
||||
setHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Ignore client requests
|
||||
if r.Header.Get("X-Testing") == "agent" {
|
||||
assert.Equal(t, "Ethan was Here!", r.Header.Get("Cool-Header"))
|
||||
assert.Equal(t, "very-wow-"+client.URL.String(), r.Header.Get("X-Process-Testing"))
|
||||
assert.Equal(t, "more-wow", r.Header.Get("X-Process-Testing2"))
|
||||
if strings.HasPrefix(r.URL.Path, "/derp") {
|
||||
atomic.AddInt64(&derpCalled, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&called, 1)
|
||||
}
|
||||
}
|
||||
coderAPI.RootHandler.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
url = srv.URL
|
||||
r := dbfake.WorkspaceBuild(t, coderAPI.Database, database.WorkspaceTable{
|
||||
OrganizationID: memberUser.OrganizationIDs[0],
|
||||
OwnerID: memberUser.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
coderURLEnv := "$CODER_URL"
|
||||
if runtime.GOOS == "windows" {
|
||||
coderURLEnv = "%CODER_URL%"
|
||||
}
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
agentInv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", "fake-token",
|
||||
"--agent-url", srv.URL,
|
||||
"--agent-token", r.AgentToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
"--agent-header", "X-Testing=wow",
|
||||
"--agent-header", "X-Testing=agent",
|
||||
"--agent-header", "Cool-Header=Ethan was Here!",
|
||||
"--agent-header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow",
|
||||
)
|
||||
clitest.Start(t, agentInv)
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).
|
||||
MatchResources(matchAgentWithVersion).Wait()
|
||||
|
||||
clitest.Start(t, inv)
|
||||
require.Eventually(t, func() bool {
|
||||
return atomic.LoadInt64(&called) > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clientInv, root := clitest.New(t,
|
||||
"-v",
|
||||
"--no-feature-warning",
|
||||
"--no-version-warning",
|
||||
"ping", r.Workspace.Name,
|
||||
"-n", "1",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err := clientInv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Greater(t, atomic.LoadInt64(&called), int64(0), "expected coderd to be reached with custom headers")
|
||||
require.Greater(t, atomic.LoadInt64(&derpCalled), int64(0), "expected /derp to be called with custom headers")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
const (
|
||||
procMounts = "/proc/mounts"
|
||||
procOneCgroup = "/proc/1/cgroup"
|
||||
sysCgroupType = "/sys/fs/cgroup/cgroup.type"
|
||||
kubernetesDefaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec
|
||||
)
|
||||
|
||||
@@ -65,6 +66,17 @@ func IsContainerized(fs afero.Fs) (ok bool, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from https://github.com/systemd/systemd/blob/88bbf187a9b2ebe0732caa1e886616ae5f8186da/src/basic/virt.c#L603-L605
|
||||
// The file `/sys/fs/cgroup/cgroup.type` does not exist on the root cgroup.
|
||||
// If this file exists we can be sure we're in a container.
|
||||
cgTypeExists, err := afero.Exists(fs, sysCgroupType)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("check file exists %s: %w", sysCgroupType, err)
|
||||
}
|
||||
if cgTypeExists {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If we get here, we are _probably_ not running in a container.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -309,6 +309,12 @@ func TestIsContainerized(t *testing.T) {
|
||||
Expected: true,
|
||||
Error: "",
|
||||
},
|
||||
{
|
||||
Name: "Docker (Cgroupns=private)",
|
||||
FS: fsContainerCgroupV2PrivateCgroupns,
|
||||
Expected: true,
|
||||
Error: "",
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
@@ -374,6 +380,12 @@ proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`,
|
||||
cgroupV2MemoryUsageBytes: "536870912",
|
||||
cgroupV2MemoryStat: "inactive_file 268435456",
|
||||
}
|
||||
fsContainerCgroupV2PrivateCgroupns = map[string]string{
|
||||
procOneCgroup: "0::/",
|
||||
procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0
|
||||
proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`,
|
||||
sysCgroupType: "domain",
|
||||
}
|
||||
fsContainerCgroupV1 = map[string]string{
|
||||
procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f",
|
||||
procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0
|
||||
|
||||
+42
-29
@@ -25,6 +25,7 @@ type AgentOptions struct {
|
||||
Fetch func(ctx context.Context, agentID uuid.UUID) (codersdk.WorkspaceAgent, error)
|
||||
FetchLogs func(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []codersdk.WorkspaceAgentLog, io.Closer, error)
|
||||
Wait bool // If true, wait for the agent to be ready (startup script).
|
||||
DocsURL string
|
||||
}
|
||||
|
||||
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
||||
@@ -119,7 +120,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
if agent.Status == codersdk.WorkspaceAgentTimeout {
|
||||
now := time.Now()
|
||||
sw.Log(now, codersdk.LogLevelInfo, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.")
|
||||
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, "https://coder.com/docs/templates#agent-connection-issues"))
|
||||
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL)))
|
||||
for agent.Status == codersdk.WorkspaceAgentTimeout {
|
||||
if agent, err = fetch(); err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
@@ -224,13 +225,13 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#startup-script-exited-with-an-error"))
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#startup-script-exited-with-an-error", opts.DocsURL)))
|
||||
default:
|
||||
switch {
|
||||
case agent.LifecycleState.Starting():
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#your-workspace-may-be-incomplete"))
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#your-workspace-may-be-incomplete", opts.DocsURL)))
|
||||
// Note: We don't complete or fail the stage here, it's
|
||||
// intentionally left open to indicate this stage didn't
|
||||
// complete.
|
||||
@@ -252,7 +253,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
stage := "The workspace agent lost connection"
|
||||
sw.Start(stage)
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.")
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#agent-connection-issues"))
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL)))
|
||||
|
||||
disconnectedAt := agent.DisconnectedAt
|
||||
for agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
@@ -309,7 +310,7 @@ func PeerDiagnostics(w io.Writer, d tailnet.PeerDiagnostics) {
|
||||
_, _ = fmt.Fprint(w, "✘ not connected to DERP\n")
|
||||
}
|
||||
if d.SentNode {
|
||||
_, _ = fmt.Fprint(w, "✔ sent local data to Coder networking coodinator\n")
|
||||
_, _ = fmt.Fprint(w, "✔ sent local data to Coder networking coordinator\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, "✘ have not sent local data to Coder networking coordinator\n")
|
||||
}
|
||||
@@ -351,16 +352,16 @@ func PeerDiagnostics(w io.Writer, d tailnet.PeerDiagnostics) {
|
||||
}
|
||||
|
||||
type ConnDiags struct {
|
||||
ConnInfo workspacesdk.AgentConnectionInfo
|
||||
PingP2P bool
|
||||
DisableDirect bool
|
||||
LocalNetInfo *tailcfg.NetInfo
|
||||
LocalInterfaces *healthsdk.InterfacesReport
|
||||
AgentNetcheck *healthsdk.AgentNetcheckReport
|
||||
ClientIPIsAWS bool
|
||||
AgentIPIsAWS bool
|
||||
Verbose bool
|
||||
// TODO: More diagnostics
|
||||
ConnInfo workspacesdk.AgentConnectionInfo
|
||||
PingP2P bool
|
||||
DisableDirect bool
|
||||
LocalNetInfo *tailcfg.NetInfo
|
||||
LocalInterfaces *healthsdk.InterfacesReport
|
||||
AgentNetcheck *healthsdk.AgentNetcheckReport
|
||||
ClientIPIsAWS bool
|
||||
AgentIPIsAWS bool
|
||||
Verbose bool
|
||||
TroubleshootingURL string
|
||||
}
|
||||
|
||||
func (d ConnDiags) Write(w io.Writer) {
|
||||
@@ -369,6 +370,9 @@ func (d ConnDiags) Write(w io.Writer) {
|
||||
for _, msg := range general {
|
||||
_, _ = fmt.Fprintln(w, msg)
|
||||
}
|
||||
if len(general) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "")
|
||||
}
|
||||
if len(client) > 0 {
|
||||
_, _ = fmt.Fprint(w, "Possible client-side issues with direct connection:\n\n")
|
||||
for _, msg := range client {
|
||||
@@ -384,22 +388,22 @@ func (d ConnDiags) Write(w io.Writer) {
|
||||
}
|
||||
|
||||
func (d ConnDiags) splitDiagnostics() (general, client, agent []string) {
|
||||
if d.PingP2P {
|
||||
general = append(general, "✔ You are connected directly (p2p)")
|
||||
} else {
|
||||
general = append(general, "❗ You are connected via a DERP relay, not directly (p2p)")
|
||||
}
|
||||
|
||||
if d.AgentNetcheck != nil {
|
||||
for _, msg := range d.AgentNetcheck.Interfaces.Warnings {
|
||||
agent = append(agent, msg.Message)
|
||||
}
|
||||
if len(d.AgentNetcheck.Interfaces.Warnings) > 0 {
|
||||
agent[len(agent)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL)
|
||||
}
|
||||
}
|
||||
|
||||
if d.LocalInterfaces != nil {
|
||||
for _, msg := range d.LocalInterfaces.Warnings {
|
||||
client = append(client, msg.Message)
|
||||
}
|
||||
if len(d.LocalInterfaces.Warnings) > 0 {
|
||||
client[len(client)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL)
|
||||
}
|
||||
}
|
||||
|
||||
if d.PingP2P && !d.Verbose {
|
||||
@@ -414,37 +418,46 @@ func (d ConnDiags) splitDiagnostics() (general, client, agent []string) {
|
||||
}
|
||||
|
||||
if d.ConnInfo.DisableDirectConnections {
|
||||
general = append(general, "❗ Your Coder administrator has blocked direct connections")
|
||||
general = append(general,
|
||||
fmt.Sprintf("❗ Your Coder administrator has blocked direct connections\n %s#disabled-deployment-wide", d.TroubleshootingURL))
|
||||
if !d.Verbose {
|
||||
return general, client, agent
|
||||
}
|
||||
}
|
||||
|
||||
if !d.ConnInfo.DERPMap.HasSTUN() {
|
||||
general = append(general, "The DERP map is not configured to use STUN")
|
||||
general = append(general,
|
||||
fmt.Sprintf("❗ The DERP map is not configured to use STUN\n %s#no-stun-servers", d.TroubleshootingURL))
|
||||
} else if d.LocalNetInfo != nil && !d.LocalNetInfo.UDP {
|
||||
client = append(client, "Client could not connect to STUN over UDP")
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.LocalNetInfo != nil && d.LocalNetInfo.MappingVariesByDestIP.EqualBool(true) {
|
||||
client = append(client, "Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers")
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.AgentNetcheck != nil && d.AgentNetcheck.NetInfo != nil {
|
||||
if d.AgentNetcheck.NetInfo.MappingVariesByDestIP.EqualBool(true) {
|
||||
agent = append(agent, "Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers")
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
if !d.AgentNetcheck.NetInfo.UDP {
|
||||
agent = append(agent, "Agent could not connect to STUN over UDP")
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL))
|
||||
}
|
||||
}
|
||||
|
||||
if d.ClientIPIsAWS {
|
||||
client = append(client, "Client IP address is within an AWS range (AWS uses hard NAT)")
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.AgentIPIsAWS {
|
||||
agent = append(agent, "Agent IP address is within an AWS range (AWS uses hard NAT)")
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
return general, client, agent
|
||||
}
|
||||
|
||||
+1
-27
@@ -533,7 +533,7 @@ func TestPeerDiagnostics(t *testing.T) {
|
||||
LastWireguardHandshake: time.Time{},
|
||||
},
|
||||
want: []*regexp.Regexp{
|
||||
regexp.MustCompile(`^✔ sent local data to Coder networking coodinator$`),
|
||||
regexp.MustCompile(`^✔ sent local data to Coder networking coordinator$`),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -683,19 +683,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
diags cliui.ConnDiags
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "Direct",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
PingP2P: true,
|
||||
LocalNetInfo: &tailcfg.NetInfo{},
|
||||
},
|
||||
want: []string{
|
||||
`✔ You are connected directly (p2p)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DirectBlocked",
|
||||
diags: cliui.ConnDiags{
|
||||
@@ -705,7 +692,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`❗ Your Coder administrator has blocked direct connections`,
|
||||
},
|
||||
},
|
||||
@@ -718,7 +704,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
LocalNetInfo: &tailcfg.NetInfo{},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`The DERP map is not configured to use STUN`,
|
||||
},
|
||||
},
|
||||
@@ -743,7 +728,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Client could not connect to STUN over UDP`,
|
||||
},
|
||||
},
|
||||
@@ -770,7 +754,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Agent could not connect to STUN over UDP`,
|
||||
},
|
||||
},
|
||||
@@ -785,7 +768,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers`,
|
||||
},
|
||||
},
|
||||
@@ -795,14 +777,12 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
PingP2P: false,
|
||||
LocalNetInfo: &tailcfg.NetInfo{},
|
||||
AgentNetcheck: &healthsdk.AgentNetcheckReport{
|
||||
NetInfo: &tailcfg.NetInfo{MappingVariesByDestIP: "true"},
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers`,
|
||||
},
|
||||
},
|
||||
@@ -812,7 +792,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
PingP2P: true,
|
||||
AgentNetcheck: &healthsdk.AgentNetcheckReport{
|
||||
Interfaces: healthsdk.InterfacesReport{
|
||||
BaseReport: healthsdk.BaseReport{
|
||||
@@ -824,7 +803,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`✔ You are connected directly (p2p)`,
|
||||
`Network interface eth0 has MTU 1280, (less than 1378), which may degrade the quality of direct connections`,
|
||||
},
|
||||
},
|
||||
@@ -834,7 +812,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
PingP2P: true,
|
||||
LocalInterfaces: &healthsdk.InterfacesReport{
|
||||
BaseReport: healthsdk.BaseReport{
|
||||
Warnings: []health.Message{
|
||||
@@ -844,7 +821,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`✔ You are connected directly (p2p)`,
|
||||
`Network interface eth1 has MTU 1310, (less than 1378), which may degrade the quality of direct connections`,
|
||||
},
|
||||
},
|
||||
@@ -858,7 +834,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
AgentIPIsAWS: false,
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Client IP address is within an AWS range (AWS uses hard NAT)`,
|
||||
},
|
||||
},
|
||||
@@ -872,7 +847,6 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
AgentIPIsAWS: true,
|
||||
},
|
||||
want: []string{
|
||||
`❗ You are connected via a DERP relay, not directly (p2p)`,
|
||||
`Agent IP address is within an AWS range (AWS uses hard NAT)`,
|
||||
},
|
||||
},
|
||||
|
||||
+34
-22
@@ -22,6 +22,7 @@ type Styles struct {
|
||||
DateTimeStamp,
|
||||
Error,
|
||||
Field,
|
||||
Hyperlink,
|
||||
Keyword,
|
||||
Placeholder,
|
||||
Prompt,
|
||||
@@ -37,17 +38,21 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
Green = Color("#04B575")
|
||||
Red = Color("#ED567A")
|
||||
Fuchsia = Color("#EE6FF8")
|
||||
Yellow = Color("#ECFD65")
|
||||
Blue = Color("#5000ff")
|
||||
// ANSI color codes
|
||||
red = Color("1")
|
||||
green = Color("2")
|
||||
yellow = Color("3")
|
||||
magenta = Color("5")
|
||||
white = Color("7")
|
||||
brightBlue = Color("12")
|
||||
brightMagenta = Color("13")
|
||||
)
|
||||
|
||||
// Color returns a color for the given string.
|
||||
func Color(s string) termenv.Color {
|
||||
colorOnce.Do(func() {
|
||||
color = termenv.NewOutput(os.Stdout).ColorProfile()
|
||||
color = termenv.NewOutput(os.Stdout).EnvColorProfile()
|
||||
|
||||
if flag.Lookup("test.v") != nil {
|
||||
// Use a consistent colorless profile in tests so that results
|
||||
// are deterministic.
|
||||
@@ -123,42 +128,49 @@ func init() {
|
||||
DefaultStyles = Styles{
|
||||
Code: pretty.Style{
|
||||
ifTerm(pretty.XPad(1, 1)),
|
||||
pretty.FgColor(Red),
|
||||
pretty.BgColor(color.Color("#2c2c2c")),
|
||||
pretty.FgColor(Color("#ED567A")),
|
||||
pretty.BgColor(Color("#2C2C2C")),
|
||||
},
|
||||
DateTimeStamp: pretty.Style{
|
||||
pretty.FgColor(color.Color("#7571F9")),
|
||||
pretty.FgColor(brightBlue),
|
||||
},
|
||||
Error: pretty.Style{
|
||||
pretty.FgColor(Red),
|
||||
pretty.FgColor(red),
|
||||
},
|
||||
Field: pretty.Style{
|
||||
pretty.XPad(1, 1),
|
||||
pretty.FgColor(color.Color("#FFFFFF")),
|
||||
pretty.BgColor(color.Color("#2b2a2a")),
|
||||
pretty.FgColor(Color("#FFFFFF")),
|
||||
pretty.BgColor(Color("#2B2A2A")),
|
||||
},
|
||||
Fuchsia: pretty.Style{
|
||||
pretty.FgColor(brightMagenta),
|
||||
},
|
||||
FocusedPrompt: pretty.Style{
|
||||
pretty.FgColor(white),
|
||||
pretty.Wrap("> ", ""),
|
||||
pretty.FgColor(brightBlue),
|
||||
},
|
||||
Hyperlink: pretty.Style{
|
||||
pretty.FgColor(magenta),
|
||||
pretty.Underline(),
|
||||
},
|
||||
Keyword: pretty.Style{
|
||||
pretty.FgColor(Green),
|
||||
pretty.FgColor(green),
|
||||
},
|
||||
Placeholder: pretty.Style{
|
||||
pretty.FgColor(color.Color("#4d46b3")),
|
||||
pretty.FgColor(magenta),
|
||||
},
|
||||
Prompt: pretty.Style{
|
||||
pretty.FgColor(color.Color("#5C5C5C")),
|
||||
pretty.Wrap("> ", ""),
|
||||
pretty.FgColor(white),
|
||||
pretty.Wrap(" ", ""),
|
||||
},
|
||||
Warn: pretty.Style{
|
||||
pretty.FgColor(Yellow),
|
||||
pretty.FgColor(yellow),
|
||||
},
|
||||
Wrap: pretty.Style{
|
||||
pretty.LineWrap(80),
|
||||
},
|
||||
}
|
||||
|
||||
DefaultStyles.FocusedPrompt = append(
|
||||
DefaultStyles.Prompt,
|
||||
pretty.FgColor(Blue),
|
||||
)
|
||||
}
|
||||
|
||||
// ValidateNotEmpty is a helper function to disallow empty inputs!
|
||||
|
||||
+426
-49
@@ -1,19 +1,54 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const defaultSelectModelHeight = 7
|
||||
|
||||
type terminateMsg struct{}
|
||||
|
||||
func installSignalHandler(p *tea.Program) func() {
|
||||
ch := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
defer func() {
|
||||
signal.Stop(sig)
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
return
|
||||
|
||||
case <-sig:
|
||||
p.Send(terminateMsg{})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
ch <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
// Default will be highlighted first if it's a valid option.
|
||||
@@ -75,31 +110,193 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
|
||||
return opts.Options[0], nil
|
||||
}
|
||||
|
||||
var defaultOption interface{}
|
||||
if opts.Default != "" {
|
||||
defaultOption = opts.Default
|
||||
initialModel := selectModel{
|
||||
search: textinput.New(),
|
||||
hideSearch: opts.HideSearch,
|
||||
options: opts.Options,
|
||||
height: opts.Size,
|
||||
message: opts.Message,
|
||||
}
|
||||
|
||||
var value string
|
||||
err := survey.AskOne(&survey.Select{
|
||||
Options: opts.Options,
|
||||
Default: defaultOption,
|
||||
PageSize: opts.Size,
|
||||
Message: opts.Message,
|
||||
}, &value, survey.WithIcons(func(is *survey.IconSet) {
|
||||
is.Help.Text = "Type to search"
|
||||
if opts.HideSearch {
|
||||
is.Help.Text = ""
|
||||
}
|
||||
}), survey.WithStdio(fileReadWriter{
|
||||
Reader: inv.Stdin,
|
||||
}, fileReadWriter{
|
||||
Writer: inv.Stdout,
|
||||
}, inv.Stdout))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
if initialModel.height == 0 {
|
||||
initialModel.height = defaultSelectModelHeight
|
||||
}
|
||||
return value, err
|
||||
|
||||
initialModel.search.Prompt = ""
|
||||
initialModel.search.Focus()
|
||||
|
||||
p := tea.NewProgram(
|
||||
initialModel,
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(inv.Context()),
|
||||
tea.WithInput(inv.Stdin),
|
||||
tea.WithOutput(inv.Stdout),
|
||||
)
|
||||
|
||||
closeSignalHandler := installSignalHandler(p)
|
||||
defer closeSignalHandler()
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
model, ok := m.(selectModel)
|
||||
if !ok {
|
||||
return "", xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
||||
}
|
||||
|
||||
if model.canceled {
|
||||
return "", Canceled
|
||||
}
|
||||
|
||||
return model.selected, nil
|
||||
}
|
||||
|
||||
type selectModel struct {
|
||||
search textinput.Model
|
||||
options []string
|
||||
cursor int
|
||||
height int
|
||||
message string
|
||||
selected string
|
||||
canceled bool
|
||||
hideSearch bool
|
||||
}
|
||||
|
||||
func (selectModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // The linter complains about modifying 'm' but this is typical practice for bubbletea
|
||||
func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case terminateMsg:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
options := m.filteredOptions()
|
||||
if len(options) != 0 {
|
||||
m.selected = options[m.cursor]
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case tea.KeyUp:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
} else {
|
||||
m.cursor = len(options) - 1
|
||||
}
|
||||
|
||||
case tea.KeyDown:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor < len(options)-1 {
|
||||
m.cursor++
|
||||
} else {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !m.hideSearch {
|
||||
oldSearch := m.search.Value()
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
|
||||
// If the search query has changed then we need to ensure
|
||||
// the cursor is still pointing at a valid option.
|
||||
if m.search.Value() != oldSearch {
|
||||
options := m.filteredOptions()
|
||||
|
||||
if m.cursor > len(options)-1 {
|
||||
m.cursor = max(0, len(options)-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m selectModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
||||
|
||||
if m.selected != "" {
|
||||
selected := pretty.Sprint(DefaultStyles.Keyword, m.selected)
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
if m.hideSearch {
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s [Use arrows to move]\n", msg))
|
||||
} else {
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s %s[Use arrows to move, type to filter]\n",
|
||||
msg,
|
||||
m.search.View(),
|
||||
))
|
||||
}
|
||||
|
||||
options, start := m.viewableOptions()
|
||||
|
||||
for i, option := range options {
|
||||
// Is this the currently selected option?
|
||||
style := pretty.Wrap(" ", "")
|
||||
if m.cursor == start+i {
|
||||
style = pretty.Style{
|
||||
pretty.Wrap("> ", ""),
|
||||
DefaultStyles.Keyword,
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(pretty.Sprint(style, option))
|
||||
_, _ = s.WriteString("\n")
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m selectModel) viewableOptions() ([]string, int) {
|
||||
options := m.filteredOptions()
|
||||
halfHeight := m.height / 2
|
||||
bottom := 0
|
||||
top := len(options)
|
||||
|
||||
switch {
|
||||
case m.cursor <= halfHeight:
|
||||
top = min(top, m.height)
|
||||
case m.cursor < top-halfHeight:
|
||||
bottom = max(0, m.cursor-halfHeight)
|
||||
top = min(top, m.cursor+halfHeight+1)
|
||||
default:
|
||||
bottom = max(0, top-m.height)
|
||||
}
|
||||
|
||||
return options[bottom:top], bottom
|
||||
}
|
||||
|
||||
func (m selectModel) filteredOptions() []string {
|
||||
options := []string{}
|
||||
for _, o := range m.options {
|
||||
filter := strings.ToLower(m.search.Value())
|
||||
option := strings.ToLower(o)
|
||||
|
||||
if strings.Contains(option, filter) {
|
||||
options = append(options, o)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type MultiSelectOptions struct {
|
||||
@@ -114,35 +311,215 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er
|
||||
return opts.Defaults, nil
|
||||
}
|
||||
|
||||
prompt := &survey.MultiSelect{
|
||||
Options: opts.Options,
|
||||
Default: opts.Defaults,
|
||||
Message: opts.Message,
|
||||
options := make([]*multiSelectOption, len(opts.Options))
|
||||
for i, option := range opts.Options {
|
||||
chosen := false
|
||||
for _, d := range opts.Defaults {
|
||||
if option == d {
|
||||
chosen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
options[i] = &multiSelectOption{
|
||||
option: option,
|
||||
chosen: chosen,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
initialModel := multiSelectModel{
|
||||
search: textinput.New(),
|
||||
options: options,
|
||||
message: opts.Message,
|
||||
}
|
||||
|
||||
initialModel.search.Prompt = ""
|
||||
initialModel.search.Focus()
|
||||
|
||||
p := tea.NewProgram(
|
||||
initialModel,
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(inv.Context()),
|
||||
tea.WithInput(inv.Stdin),
|
||||
tea.WithOutput(inv.Stdout),
|
||||
)
|
||||
|
||||
closeSignalHandler := installSignalHandler(p)
|
||||
defer closeSignalHandler()
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model, ok := m.(multiSelectModel)
|
||||
if !ok {
|
||||
return nil, xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
||||
}
|
||||
|
||||
if model.canceled {
|
||||
return nil, Canceled
|
||||
}
|
||||
return values, err
|
||||
|
||||
return model.selectedOptions(), nil
|
||||
}
|
||||
|
||||
type fileReadWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
type multiSelectOption struct {
|
||||
option string
|
||||
chosen bool
|
||||
}
|
||||
|
||||
func (f fileReadWriter) Fd() uintptr {
|
||||
if file, ok := f.Reader.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
if file, ok := f.Writer.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
return 0
|
||||
type multiSelectModel struct {
|
||||
search textinput.Model
|
||||
options []*multiSelectOption
|
||||
cursor int
|
||||
message string
|
||||
canceled bool
|
||||
selected bool
|
||||
}
|
||||
|
||||
func (multiSelectModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // For same reason as previous Update definition
|
||||
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case terminateMsg:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
if len(m.options) != 0 {
|
||||
m.selected = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case tea.KeySpace:
|
||||
options := m.filteredOptions()
|
||||
if len(options) != 0 {
|
||||
options[m.cursor].chosen = !options[m.cursor].chosen
|
||||
}
|
||||
// We back out early here otherwise a space will be inserted
|
||||
// into the search field.
|
||||
return m, nil
|
||||
|
||||
case tea.KeyUp:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
} else {
|
||||
m.cursor = len(options) - 1
|
||||
}
|
||||
|
||||
case tea.KeyDown:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor < len(options)-1 {
|
||||
m.cursor++
|
||||
} else {
|
||||
m.cursor = 0
|
||||
}
|
||||
|
||||
case tea.KeyRight:
|
||||
options := m.filteredOptions()
|
||||
for _, option := range options {
|
||||
option.chosen = true
|
||||
}
|
||||
|
||||
case tea.KeyLeft:
|
||||
options := m.filteredOptions()
|
||||
for _, option := range options {
|
||||
option.chosen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldSearch := m.search.Value()
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
|
||||
// If the search query has changed then we need to ensure
|
||||
// the cursor is still pointing at a valid option.
|
||||
if m.search.Value() != oldSearch {
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > len(options)-1 {
|
||||
m.cursor = max(0, len(options)-1)
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m multiSelectModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
||||
|
||||
if m.selected {
|
||||
selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", "))
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n",
|
||||
msg,
|
||||
m.search.View(),
|
||||
))
|
||||
|
||||
for i, option := range m.filteredOptions() {
|
||||
cursor := " "
|
||||
chosen := "[ ]"
|
||||
o := option.option
|
||||
|
||||
if m.cursor == i {
|
||||
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ")
|
||||
chosen = pretty.Sprint(DefaultStyles.Keyword, "[ ]")
|
||||
o = pretty.Sprint(DefaultStyles.Keyword, o)
|
||||
}
|
||||
|
||||
if option.chosen {
|
||||
chosen = pretty.Sprint(DefaultStyles.Keyword, "[x]")
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s%s %s\n",
|
||||
cursor,
|
||||
chosen,
|
||||
o,
|
||||
))
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m multiSelectModel) filteredOptions() []*multiSelectOption {
|
||||
options := []*multiSelectOption{}
|
||||
for _, o := range m.options {
|
||||
filter := strings.ToLower(m.search.Value())
|
||||
option := strings.ToLower(o.option)
|
||||
|
||||
if strings.Contains(option, filter) {
|
||||
options = append(options, o)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (m multiSelectModel) selectedOptions() []string {
|
||||
selected := []string{}
|
||||
for _, o := range m.options {
|
||||
if o.chosen {
|
||||
selected = append(selected, o.option)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
@@ -199,6 +199,10 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
|
||||
if val != nil {
|
||||
v = *val
|
||||
}
|
||||
case *time.Duration:
|
||||
if val != nil {
|
||||
v = val.String()
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if val != nil {
|
||||
v = val.String()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -63,6 +64,10 @@ func sshConfigFileRead(t *testing.T, name string) string {
|
||||
func TestConfigSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("See coder/internal#117")
|
||||
}
|
||||
|
||||
const hostname = "test-coder."
|
||||
const expectedKey = "ConnectionAttempts"
|
||||
const removeKey = "ConnectTimeout"
|
||||
@@ -78,7 +83,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).WithAgent().Do()
|
||||
@@ -642,7 +647,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
if tt.hasAgent {
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
@@ -762,7 +767,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).Resource(resources...).Do()
|
||||
|
||||
+34
-12
@@ -22,10 +22,11 @@ import (
|
||||
|
||||
func (r *RootCmd) create() *serpent.Command {
|
||||
var (
|
||||
templateName string
|
||||
startAt string
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
templateName string
|
||||
templateVersion string
|
||||
startAt string
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
@@ -60,9 +61,13 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Specify a name for your workspace:",
|
||||
Validate: func(workspaceName string) error {
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
err = codersdk.NameValid(workspaceName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
|
||||
}
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
return xerrors.Errorf("a workspace already exists named %q", workspaceName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -71,10 +76,13 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = codersdk.NameValid(workspaceName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
|
||||
}
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
return xerrors.Errorf("a workspace already exists named %q", workspaceName)
|
||||
}
|
||||
|
||||
var sourceWorkspace codersdk.Workspace
|
||||
@@ -195,6 +203,14 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
templateVersionID = template.ActiveVersionID
|
||||
}
|
||||
|
||||
if len(templateVersion) > 0 {
|
||||
version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version by name: %w", err)
|
||||
}
|
||||
templateVersionID = version.ID
|
||||
}
|
||||
|
||||
// If the user specified an organization via a flag or env var, the template **must**
|
||||
// be in that organization. Otherwise, we should throw an error.
|
||||
orgValue, orgValueSource := orgContext.ValueSource(inv)
|
||||
@@ -307,6 +323,12 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
Description: "Specify a template name.",
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "template-version",
|
||||
Env: "CODER_TEMPLATE_VERSION",
|
||||
Description: "Specify a template version name.",
|
||||
Value: serpent.StringOf(&templateVersion),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "start-at",
|
||||
Env: "CODER_WORKSPACE_START_AT",
|
||||
@@ -348,8 +370,8 @@ type prepWorkspaceBuildArgs struct {
|
||||
LastBuildParameters []codersdk.WorkspaceBuildParameter
|
||||
SourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
|
||||
|
||||
PromptBuildOptions bool
|
||||
BuildOptions []codersdk.WorkspaceBuildParameter
|
||||
PromptEphemeralParameters bool
|
||||
EphemeralParameters []codersdk.WorkspaceBuildParameter
|
||||
|
||||
PromptRichParameters bool
|
||||
RichParameters []codersdk.WorkspaceBuildParameter
|
||||
@@ -383,8 +405,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
resolver := new(ParameterResolver).
|
||||
WithLastBuildParameters(args.LastBuildParameters).
|
||||
WithSourceWorkspaceParameters(args.SourceWorkspaceParameters).
|
||||
WithPromptBuildOptions(args.PromptBuildOptions).
|
||||
WithBuildOptions(args.BuildOptions).
|
||||
WithPromptEphemeralParameters(args.PromptEphemeralParameters).
|
||||
WithEphemeralParameters(args.EphemeralParameters).
|
||||
WithPromptRichParameters(args.PromptRichParameters).
|
||||
WithRichParameters(args.RichParameters).
|
||||
WithRichParametersFile(parameterFile).
|
||||
|
||||
@@ -133,6 +133,70 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateWithSpecificTemplateVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
// Create a new version
|
||||
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent(), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = template.ID
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
|
||||
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--template-version", version2.Name,
|
||||
"--start-at", "9:30AM Mon-Fri US/Central",
|
||||
"--stop-after", "8h",
|
||||
"--automatic-updates", "always",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
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)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
// Check if the workspace is using the new template version
|
||||
assert.Equal(t, ws.LatestBuild.TemplateVersionID, version2.ID, "expected workspace to use the specified template version")
|
||||
if assert.NotNil(t, ws.AutostartSchedule) {
|
||||
assert.Equal(t, *ws.AutostartSchedule, "CRON_TZ=US/Central 30 9 * * Mon-Fri")
|
||||
}
|
||||
if assert.NotNil(t, ws.TTLMillis) {
|
||||
assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds())
|
||||
}
|
||||
assert.Equal(t, codersdk.AutomaticUpdatesAlways, ws.AutomaticUpdates)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
|
||||
+11
-3
@@ -11,7 +11,10 @@ import (
|
||||
|
||||
// nolint
|
||||
func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
var orphan bool
|
||||
var (
|
||||
orphan bool
|
||||
prov buildFlags
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -40,11 +43,15 @@ func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
}
|
||||
|
||||
var state []byte
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
req := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: state,
|
||||
Orphan: orphan,
|
||||
})
|
||||
}
|
||||
if prov.provisionerLogDebug {
|
||||
req.LogLevel = codersdk.ProvisionerLogLevelDebug
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,5 +78,6 @@ func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
cmd.Options = append(cmd.Options, prov.cliOptions()...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ func (r *RootCmd) dotfiles() *serpent.Command {
|
||||
}
|
||||
|
||||
if fi.Mode()&0o111 == 0 {
|
||||
return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/dotfiles for information on how to resolve the issue.", script)
|
||||
return xerrors.Errorf("script %q does not have execute permissions", script)
|
||||
}
|
||||
|
||||
// it is safe to use a variable command here because it's from
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestFavoriteUnfavorite(t *testing.T) {
|
||||
client, db = coderdtest.NewWithDatabase(t, nil)
|
||||
owner = coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
ws = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do()
|
||||
ws = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do()
|
||||
)
|
||||
|
||||
inv, root := clitest.New(t, "favorite", ws.Workspace.Name)
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, str
|
||||
require.NoError(t, err)
|
||||
|
||||
// setup template
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ func TestList(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
// setup template
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).WithAgent().Do()
|
||||
@@ -54,7 +54,7 @@ func TestList(t *testing.T) {
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
+128
-6
@@ -212,7 +212,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
if username == "" {
|
||||
if !isTTY(inv) {
|
||||
if !isTTYIn(inv) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
|
||||
@@ -267,12 +267,59 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
trial = v == "yes" || v == "y"
|
||||
}
|
||||
|
||||
var trialInfo codersdk.CreateFirstUserTrialInfo
|
||||
if trial {
|
||||
if trialInfo.FirstName == "" {
|
||||
trialInfo.FirstName, err = promptTrialInfo(inv, "firstName")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.LastName == "" {
|
||||
trialInfo.LastName, err = promptTrialInfo(inv, "lastName")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.PhoneNumber == "" {
|
||||
trialInfo.PhoneNumber, err = promptTrialInfo(inv, "phoneNumber")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.JobTitle == "" {
|
||||
trialInfo.JobTitle, err = promptTrialInfo(inv, "jobTitle")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.CompanyName == "" {
|
||||
trialInfo.CompanyName, err = promptTrialInfo(inv, "companyName")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.Country == "" {
|
||||
trialInfo.Country, err = promptCountry(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if trialInfo.Developers == "" {
|
||||
trialInfo.Developers, err = promptDevelopers(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
Trial: trial,
|
||||
Email: email,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
Trial: trial,
|
||||
TrialInfo: trialInfo,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create initial user: %w", err)
|
||||
@@ -416,6 +463,9 @@ func isWSL() (bool, error) {
|
||||
|
||||
// openURL opens the provided URL via user's default browser
|
||||
func openURL(inv *serpent.Invocation, urlToOpen string) error {
|
||||
if !isTTYOut(inv) {
|
||||
return xerrors.New("skipping browser open in non-interactive mode")
|
||||
}
|
||||
noOpen, err := inv.ParsedFlags().GetBool(varNoOpen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -446,3 +496,75 @@ func openURL(inv *serpent.Invocation, urlToOpen string) error {
|
||||
|
||||
return browser.OpenURL(urlToOpen)
|
||||
}
|
||||
|
||||
func promptTrialInfo(inv *serpent.Invocation, fieldName string) (string, error) {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Please enter %s:", pretty.Sprint(cliui.DefaultStyles.Field, fieldName)),
|
||||
Validate: func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return xerrors.Errorf("%s is required", fieldName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func promptDevelopers(inv *serpent.Invocation) (string, error) {
|
||||
options := []string{"1-100", "101-500", "501-1000", "1001-2500", "2500+"}
|
||||
selection, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: options,
|
||||
HideSearch: false,
|
||||
Message: "Select the number of developers:",
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("select developers: %w", err)
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func promptCountry(inv *serpent.Invocation) (string, error) {
|
||||
countries := []string{
|
||||
"Afghanistan", "Åland Islands", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda",
|
||||
"Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados",
|
||||
"Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia, Plurinational State of", "Bonaire, Sint Eustatius and Saba", "Bosnia and Herzegovina", "Botswana",
|
||||
"Bouvet Island", "Brazil", "British Indian Ocean Territory", "Brunei Darussalam", "Bulgaria", "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Canada",
|
||||
"Cape Verde", "Cayman Islands", "Central African Republic", "Chad", "Chile", "China", "Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros",
|
||||
"Congo", "Congo, the Democratic Republic of the", "Cook Islands", "Costa Rica", "Côte d'Ivoire", "Croatia", "Cuba", "Curaçao", "Cyprus", "Czech Republic",
|
||||
"Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia",
|
||||
"Ethiopia", "Falkland Islands (Malvinas)", "Faroe Islands", "Fiji", "Finland", "France", "French Guiana", "French Polynesia", "French Southern Territories", "Gabon",
|
||||
"Gambia", "Georgia", "Germany", "Ghana", "Gibraltar", "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam",
|
||||
"Guatemala", "Guernsey", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Heard Island and McDonald Islands", "Holy See (Vatican City State)", "Honduras", "Hong Kong",
|
||||
"Hungary", "Iceland", "India", "Indonesia", "Iran, Islamic Republic of", "Iraq", "Ireland", "Isle of Man", "Israel", "Italy",
|
||||
"Jamaica", "Japan", "Jersey", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Korea, Democratic People's Republic of", "Korea, Republic of", "Kuwait",
|
||||
"Kyrgyzstan", "Lao People's Democratic Republic", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg",
|
||||
"Macao", "Macedonia, the Former Yugoslav Republic of", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Martinique",
|
||||
"Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia, Federated States of", "Moldova, Republic of", "Monaco", "Mongolia", "Montenegro", "Montserrat",
|
||||
"Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Caledonia", "New Zealand", "Nicaragua",
|
||||
"Niger", "Nigeria", "Niue", "Norfolk Island", "Northern Mariana Islands", "Norway", "Oman", "Pakistan", "Palau", "Palestine, State of",
|
||||
"Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn", "Poland", "Portugal", "Puerto Rico", "Qatar",
|
||||
"Réunion", "Romania", "Russian Federation", "Rwanda", "Saint Barthélemy", "Saint Helena, Ascension and Tristan da Cunha", "Saint Kitts and Nevis", "Saint Lucia", "Saint Martin (French part)", "Saint Pierre and Miquelon",
|
||||
"Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore",
|
||||
"Sint Maarten (Dutch part)", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "South Sudan", "Spain", "Sri Lanka",
|
||||
"Sudan", "Suriname", "Svalbard and Jan Mayen", "Swaziland", "Sweden", "Switzerland", "Syrian Arab Republic", "Taiwan, Province of China", "Tajikistan", "Tanzania, United Republic of",
|
||||
"Thailand", "Timor-Leste", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands",
|
||||
"Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan", "Vanuatu",
|
||||
"Venezuela, Bolivarian Republic of", "Vietnam", "Virgin Islands, British", "Virgin Islands, U.S.", "Wallis and Futuna", "Western Sahara", "Yemen", "Zambia", "Zimbabwe",
|
||||
}
|
||||
|
||||
selection, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: countries,
|
||||
Message: "Select the country:",
|
||||
HideSearch: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("select country: %w", err)
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
+96
-1
@@ -96,6 +96,58 @@ func TestLogin(t *testing.T) {
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
"firstName", coderdtest.TrialUserParams.FirstName,
|
||||
"lastName", coderdtest.TrialUserParams.LastName,
|
||||
"phoneNumber", coderdtest.TrialUserParams.PhoneNumber,
|
||||
"jobTitle", coderdtest.TrialUserParams.JobTitle,
|
||||
"companyName", coderdtest.TrialUserParams.CompanyName,
|
||||
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYWithNoTrial", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "no",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -142,6 +194,12 @@ func TestLogin(t *testing.T) {
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
"firstName", coderdtest.TrialUserParams.FirstName,
|
||||
"lastName", coderdtest.TrialUserParams.LastName,
|
||||
"phoneNumber", coderdtest.TrialUserParams.PhoneNumber,
|
||||
"jobTitle", coderdtest.TrialUserParams.JobTitle,
|
||||
"companyName", coderdtest.TrialUserParams.CompanyName,
|
||||
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -185,6 +243,12 @@ func TestLogin(t *testing.T) {
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
"firstName", coderdtest.TrialUserParams.FirstName,
|
||||
"lastName", coderdtest.TrialUserParams.LastName,
|
||||
"phoneNumber", coderdtest.TrialUserParams.PhoneNumber,
|
||||
"jobTitle", coderdtest.TrialUserParams.JobTitle,
|
||||
"companyName", coderdtest.TrialUserParams.CompanyName,
|
||||
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -220,6 +284,17 @@ func TestLogin(t *testing.T) {
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("firstName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
|
||||
pty.ExpectMatch("lastName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.LastName)
|
||||
pty.ExpectMatch("phoneNumber")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
|
||||
pty.ExpectMatch("jobTitle")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
|
||||
pty.ExpectMatch("companyName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
|
||||
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -248,6 +323,17 @@ func TestLogin(t *testing.T) {
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("firstName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
|
||||
pty.ExpectMatch("lastName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.LastName)
|
||||
pty.ExpectMatch("phoneNumber")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
|
||||
pty.ExpectMatch("jobTitle")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
|
||||
pty.ExpectMatch("companyName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
|
||||
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -299,12 +385,21 @@ func TestLogin(t *testing.T) {
|
||||
// Validate that we reprompt for matching passwords.
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
|
||||
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("trial")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("firstName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
|
||||
pty.ExpectMatch("lastName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.LastName)
|
||||
pty.ExpectMatch("phoneNumber")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
|
||||
pty.ExpectMatch("jobTitle")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
|
||||
pty.ExpectMatch("companyName")
|
||||
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
@@ -20,7 +20,6 @@ func createOpts(t *testing.T) *coderdtest.Options {
|
||||
t.Helper()
|
||||
|
||||
dt := coderdtest.DeploymentValues(t)
|
||||
dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
|
||||
return &coderdtest.Options{
|
||||
DeploymentValues: dt,
|
||||
}
|
||||
|
||||
+5
-2
@@ -35,8 +35,9 @@ const vscodeDesktopName = "VS Code Desktop"
|
||||
|
||||
func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
var (
|
||||
generateToken bool
|
||||
testOpenError bool
|
||||
generateToken bool
|
||||
testOpenError bool
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
@@ -47,6 +48,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(1, 2),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
@@ -79,6 +81,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
Fetch: client.WorkspaceAgent,
|
||||
FetchLogs: nil,
|
||||
Wait: false,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
|
||||
+1
-1
@@ -18,7 +18,6 @@ func (r *RootCmd) organizations() *serpent.Command {
|
||||
Use: "organizations [subcommand]",
|
||||
Short: "Organization related commands",
|
||||
Aliases: []string{"organization", "org", "orgs"},
|
||||
Hidden: true, // Hidden until these commands are complete.
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
@@ -27,6 +26,7 @@ func (r *RootCmd) organizations() *serpent.Command {
|
||||
r.createOrganization(),
|
||||
r.organizationMembers(orgContext),
|
||||
r.organizationRoles(orgContext),
|
||||
r.organizationSettings(orgContext),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "create <organization name>",
|
||||
Short: "Create a new organization.",
|
||||
// This action is currently irreversible, so it's hidden until we have a way to delete organizations.
|
||||
Hidden: true,
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
@@ -30,6 +28,11 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
orgName := inv.Args[0]
|
||||
|
||||
err := codersdk.NameValid(orgName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("organization name %q is invalid: %w", orgName, err)
|
||||
}
|
||||
|
||||
// This check is not perfect since not all users can read all organizations.
|
||||
// So ignore the error and if the org already exists, prevent the user
|
||||
// from creating it.
|
||||
@@ -38,7 +41,7 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
return xerrors.Errorf("organization %q already exists", orgName)
|
||||
}
|
||||
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Are you sure you want to create an organization with the name %s?\n%s",
|
||||
pretty.Sprint(cliui.DefaultStyles.Code, orgName),
|
||||
pretty.Sprint(cliui.BoldFmt(), "This action is irreversible."),
|
||||
|
||||
@@ -24,7 +24,6 @@ func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Co
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Hidden: true,
|
||||
Children: []*serpent.Command{
|
||||
r.showOrganizationRoles(orgContext),
|
||||
r.editOrganizationRole(orgContext),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent.Command {
|
||||
settings := []organizationSetting{
|
||||
{
|
||||
Name: "group-sync",
|
||||
Aliases: []string{"groupsync"},
|
||||
Short: "Group sync settings to sync groups from an IdP.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.GroupSyncSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling group sync settings: %w", err)
|
||||
}
|
||||
return cli.PatchGroupIDPSyncSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.GroupIDPSyncSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "role-sync",
|
||||
Aliases: []string{"rolesync"},
|
||||
Short: "Role sync settings to sync organization roles from an IdP.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.RoleSyncSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling role sync settings: %w", err)
|
||||
}
|
||||
return cli.PatchRoleIDPSyncSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.RoleIDPSyncSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "settings",
|
||||
Short: "Manage organization settings.",
|
||||
Aliases: []string{"setting"},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.printOrganizationSetting(orgContext, settings),
|
||||
r.setOrganizationSettings(orgContext, settings),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
type organizationSetting struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Short string
|
||||
Patch func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error)
|
||||
Fetch func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error)
|
||||
}
|
||||
|
||||
func (r *RootCmd) setOrganizationSettings(orgContext *OrganizationContext, settings []organizationSetting) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "set",
|
||||
Short: "Update specified organization setting.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Update group sync settings.",
|
||||
Command: "coder organization settings set groupsync < input.json",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
for _, set := range settings {
|
||||
set := set
|
||||
patch := set.Patch
|
||||
cmd.Children = append(cmd.Children, &serpent.Command{
|
||||
Use: set.Name,
|
||||
Aliases: set.Aliases,
|
||||
Short: set.Short,
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read in the json
|
||||
inputData, err := io.ReadAll(inv.Stdin)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading stdin: %w", err)
|
||||
}
|
||||
|
||||
output, err := patch(ctx, client, org.ID, inputData)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patching %q: %w", set.Name, err)
|
||||
}
|
||||
|
||||
settingJSON, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
var dst bytes.Buffer
|
||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) printOrganizationSetting(orgContext *OrganizationContext, settings []organizationSetting) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "show",
|
||||
Short: "Outputs specified organization setting.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Output group sync settings.",
|
||||
Command: "coder organization settings show groupsync",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
for _, set := range settings {
|
||||
set := set
|
||||
fetch := set.Fetch
|
||||
cmd.Children = append(cmd.Children, &serpent.Command{
|
||||
Use: set.Name,
|
||||
Aliases: set.Aliases,
|
||||
Short: set.Short,
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := fetch(ctx, client, org.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patching %q: %w", set.Name, err)
|
||||
}
|
||||
|
||||
settingJSON, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
var dst bytes.Buffer
|
||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
+42
-7
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// workspaceParameterFlags are used by commands processing rich parameters and/or build options.
|
||||
type workspaceParameterFlags struct {
|
||||
promptBuildOptions bool
|
||||
buildOptions []string
|
||||
promptEphemeralParameters bool
|
||||
|
||||
ephemeralParameters []string
|
||||
|
||||
richParameterFile string
|
||||
richParameters []string
|
||||
@@ -26,23 +27,39 @@ type workspaceParameterFlags struct {
|
||||
}
|
||||
|
||||
func (wpf *workspaceParameterFlags) allOptions() []serpent.Option {
|
||||
options := append(wpf.cliBuildOptions(), wpf.cliParameters()...)
|
||||
options := append(wpf.cliEphemeralParameters(), wpf.cliParameters()...)
|
||||
options = append(options, wpf.cliParameterDefaults()...)
|
||||
return append(options, wpf.alwaysPrompt())
|
||||
}
|
||||
|
||||
func (wpf *workspaceParameterFlags) cliBuildOptions() []serpent.Option {
|
||||
func (wpf *workspaceParameterFlags) cliEphemeralParameters() []serpent.Option {
|
||||
return serpent.OptionSet{
|
||||
// Deprecated - replaced with ephemeral-parameter
|
||||
{
|
||||
Flag: "build-option",
|
||||
Env: "CODER_BUILD_OPTION",
|
||||
Description: `Build option value in the format "name=value".`,
|
||||
Value: serpent.StringArrayOf(&wpf.buildOptions),
|
||||
UseInstead: []serpent.Option{{Flag: "ephemeral-parameter"}},
|
||||
Value: serpent.StringArrayOf(&wpf.ephemeralParameters),
|
||||
},
|
||||
// Deprecated - replaced with prompt-ephemeral-parameters
|
||||
{
|
||||
Flag: "build-options",
|
||||
Description: "Prompt for one-time build options defined with ephemeral parameters.",
|
||||
Value: serpent.BoolOf(&wpf.promptBuildOptions),
|
||||
UseInstead: []serpent.Option{{Flag: "prompt-ephemeral-parameters"}},
|
||||
Value: serpent.BoolOf(&wpf.promptEphemeralParameters),
|
||||
},
|
||||
{
|
||||
Flag: "ephemeral-parameter",
|
||||
Env: "CODER_EPHEMERAL_PARAMETER",
|
||||
Description: `Set the value of ephemeral parameters defined in the template. The format is "name=value".`,
|
||||
Value: serpent.StringArrayOf(&wpf.ephemeralParameters),
|
||||
},
|
||||
{
|
||||
Flag: "prompt-ephemeral-parameters",
|
||||
Env: "CODER_PROMPT_EPHEMERAL_PARAMETERS",
|
||||
Description: "Prompt to set values of ephemeral parameters defined in the template. If a value has been set via --ephemeral-parameter, it will not be prompted for.",
|
||||
Value: serpent.BoolOf(&wpf.promptEphemeralParameters),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -58,7 +75,7 @@ func (wpf *workspaceParameterFlags) cliParameters() []serpent.Option {
|
||||
serpent.Option{
|
||||
Flag: "rich-parameter-file",
|
||||
Env: "CODER_RICH_PARAMETER_FILE",
|
||||
Description: "Specify a file path with values for rich parameters defined in the template.",
|
||||
Description: "Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value pairs for the parameters.",
|
||||
Value: serpent.StringOf(&wpf.richParameterFile),
|
||||
},
|
||||
}
|
||||
@@ -127,3 +144,21 @@ func parseParameterMapFile(parameterFile string) (map[string]string, error) {
|
||||
}
|
||||
return parameterMap, nil
|
||||
}
|
||||
|
||||
// buildFlags contains options relating to troubleshooting provisioner jobs.
|
||||
type buildFlags struct {
|
||||
provisionerLogDebug bool
|
||||
}
|
||||
|
||||
func (bf *buildFlags) cliOptions() []serpent.Option {
|
||||
return []serpent.Option{
|
||||
{
|
||||
Flag: "provisioner-log-debug",
|
||||
Description: `Sets the provisioner log level to debug.
|
||||
This will print additional information about the build process.
|
||||
This is useful for troubleshooting build issues.`,
|
||||
Value: serpent.BoolOf(&bf.provisionerLogDebug),
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -29,10 +29,10 @@ type ParameterResolver struct {
|
||||
richParameters []codersdk.WorkspaceBuildParameter
|
||||
richParametersDefaults map[string]string
|
||||
richParametersFile map[string]string
|
||||
buildOptions []codersdk.WorkspaceBuildParameter
|
||||
ephemeralParameters []codersdk.WorkspaceBuildParameter
|
||||
|
||||
promptRichParameters bool
|
||||
promptBuildOptions bool
|
||||
promptRichParameters bool
|
||||
promptEphemeralParameters bool
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
||||
@@ -50,8 +50,8 @@ func (pr *ParameterResolver) WithRichParameters(params []codersdk.WorkspaceBuild
|
||||
return pr
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) WithBuildOptions(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
||||
pr.buildOptions = params
|
||||
func (pr *ParameterResolver) WithEphemeralParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
||||
pr.ephemeralParameters = params
|
||||
return pr
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func (pr *ParameterResolver) WithPromptRichParameters(promptRichParameters bool)
|
||||
return pr
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) WithPromptBuildOptions(promptBuildOptions bool) *ParameterResolver {
|
||||
pr.promptBuildOptions = promptBuildOptions
|
||||
func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParameters bool) *ParameterResolver {
|
||||
pr.promptEphemeralParameters = promptEphemeralParameters
|
||||
return pr
|
||||
}
|
||||
|
||||
@@ -128,16 +128,16 @@ nextRichParameter:
|
||||
resolved = append(resolved, richParameter)
|
||||
}
|
||||
|
||||
nextBuildOption:
|
||||
for _, buildOption := range pr.buildOptions {
|
||||
nextEphemeralParameter:
|
||||
for _, ephemeralParameter := range pr.ephemeralParameters {
|
||||
for i, r := range resolved {
|
||||
if r.Name == buildOption.Name {
|
||||
resolved[i].Value = buildOption.Value
|
||||
continue nextBuildOption
|
||||
if r.Name == ephemeralParameter.Name {
|
||||
resolved[i].Value = ephemeralParameter.Value
|
||||
continue nextEphemeralParameter
|
||||
}
|
||||
}
|
||||
|
||||
resolved = append(resolved, buildOption)
|
||||
resolved = append(resolved, ephemeralParameter)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -209,8 +209,8 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil
|
||||
return templateVersionParametersNotFound(r.Name, templateVersionParameters)
|
||||
}
|
||||
|
||||
if tvp.Ephemeral && !pr.promptBuildOptions && findWorkspaceBuildParameter(tvp.Name, pr.buildOptions) == nil {
|
||||
return xerrors.Errorf("ephemeral parameter %q can be used only with --build-options or --build-option flag", r.Name)
|
||||
if tvp.Ephemeral && !pr.promptEphemeralParameters && findWorkspaceBuildParameter(tvp.Name, pr.ephemeralParameters) == nil {
|
||||
return xerrors.Errorf("ephemeral parameter %q can be used only with --prompt-ephemeral-parameters or --ephemeral-parameter flag", r.Name)
|
||||
}
|
||||
|
||||
if !tvp.Mutable && action != WorkspaceCreate {
|
||||
@@ -231,7 +231,7 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
|
||||
firstTimeUse := pr.isFirstTimeUse(tvp.Name)
|
||||
promptParameterOption := pr.isLastBuildParameterInvalidOption(tvp)
|
||||
|
||||
if (tvp.Ephemeral && pr.promptBuildOptions) ||
|
||||
if (tvp.Ephemeral && pr.promptEphemeralParameters) ||
|
||||
(action == WorkspaceCreate && tvp.Required) ||
|
||||
(action == WorkspaceCreate && !tvp.Ephemeral) ||
|
||||
(action == WorkspaceUpdate && promptParameterOption) ||
|
||||
|
||||
+144
-56
@@ -4,31 +4,89 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/cliutil"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
type pingSummary struct {
|
||||
Workspace string `table:"workspace,nosort"`
|
||||
Total int `table:"total"`
|
||||
Successful int `table:"successful"`
|
||||
Min *time.Duration `table:"min"`
|
||||
Avg *time.Duration `table:"avg"`
|
||||
Max *time.Duration `table:"max"`
|
||||
Variance *time.Duration `table:"variance"`
|
||||
latencySum float64
|
||||
runningAvg float64
|
||||
m2 float64
|
||||
}
|
||||
|
||||
func (s *pingSummary) addResult(r *ipnstate.PingResult) {
|
||||
s.Total++
|
||||
if r == nil || r.Err != "" {
|
||||
return
|
||||
}
|
||||
s.Successful++
|
||||
if s.Min == nil || r.LatencySeconds < s.Min.Seconds() {
|
||||
s.Min = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
|
||||
}
|
||||
if s.Max == nil || r.LatencySeconds > s.Min.Seconds() {
|
||||
s.Max = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
|
||||
}
|
||||
s.latencySum += r.LatencySeconds
|
||||
|
||||
d := r.LatencySeconds - s.runningAvg
|
||||
s.runningAvg += d / float64(s.Successful)
|
||||
d2 := r.LatencySeconds - s.runningAvg
|
||||
s.m2 += d * d2
|
||||
}
|
||||
|
||||
// Write finalizes the summary and writes it
|
||||
func (s *pingSummary) Write(w io.Writer) {
|
||||
if s.Successful > 0 {
|
||||
s.Avg = ptr.Ref(time.Duration(s.latencySum / float64(s.Successful) * float64(time.Second)))
|
||||
}
|
||||
if s.Successful > 1 {
|
||||
s.Variance = ptr.Ref(time.Duration((s.m2 / float64(s.Successful-1)) * float64(time.Second)))
|
||||
}
|
||||
out, err := cliui.DisplayTable([]*pingSummary{s}, "", nil)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, "Failed to display ping summary: %v\n", err)
|
||||
return
|
||||
}
|
||||
width := len(strings.Split(out, "\n")[0])
|
||||
_, _ = fmt.Println(strings.Repeat("-", width))
|
||||
_, _ = fmt.Fprint(w, out)
|
||||
}
|
||||
|
||||
func (r *RootCmd) ping() *serpent.Command {
|
||||
var (
|
||||
pingNum int64
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
pingNum int64
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
@@ -39,11 +97,20 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = inv.Stderr
|
||||
spin.Suffix = pretty.Sprint(cliui.DefaultStyles.Keyword, " Collecting diagnostics...")
|
||||
spin.Start()
|
||||
|
||||
notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...)
|
||||
defer notifyCancel()
|
||||
|
||||
workspaceName := inv.Args[0]
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(
|
||||
ctx, inv, client,
|
||||
@@ -67,19 +134,72 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
if !r.disableNetworkTelemetry {
|
||||
opts.EnableTelemetry = true
|
||||
}
|
||||
client := workspacesdk.New(client)
|
||||
conn, err := client.DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
wsClient := workspacesdk.New(client)
|
||||
conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
derpMap := conn.DERPMap()
|
||||
_ = derpMap
|
||||
|
||||
diagCtx, diagCancel := context.WithTimeout(inv.Context(), 30*time.Second)
|
||||
defer diagCancel()
|
||||
diags := conn.GetPeerDiagnostics()
|
||||
|
||||
// Silent ping to determine whether we should show diags
|
||||
_, didP2p, _, _ := conn.Ping(ctx)
|
||||
|
||||
ni := conn.GetNetInfo()
|
||||
connDiags := cliui.ConnDiags{
|
||||
DisableDirect: r.disableDirect,
|
||||
LocalNetInfo: ni,
|
||||
Verbose: r.verbose,
|
||||
PingP2P: didP2p,
|
||||
TroubleshootingURL: appearanceConfig.DocsURL + "/networking/troubleshooting",
|
||||
}
|
||||
|
||||
awsRanges, err := cliutil.FetchAWSIPRanges(diagCtx, cliutil.AWSIPRangesURL)
|
||||
if err != nil {
|
||||
opts.Logger.Debug(inv.Context(), "failed to retrieve AWS IP ranges", slog.Error(err))
|
||||
}
|
||||
|
||||
connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni)
|
||||
|
||||
connInfo, err := wsClient.AgentConnectionInfoGeneric(diagCtx)
|
||||
if err != nil || connInfo.DERPMap == nil {
|
||||
return xerrors.Errorf("Failed to retrieve connection info from server: %w\n", err)
|
||||
}
|
||||
connDiags.ConnInfo = connInfo
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err == nil {
|
||||
connDiags.LocalInterfaces = &ifReport
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
|
||||
}
|
||||
|
||||
agentNetcheck, err := conn.Netcheck(diagCtx)
|
||||
if err == nil {
|
||||
connDiags.AgentNetcheck = &agentNetcheck
|
||||
connDiags.AgentIPIsAWS = isAWSIP(awsRanges, agentNetcheck.NetInfo)
|
||||
} else {
|
||||
var sdkErr *codersdk.Error
|
||||
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "Could not generate full connection report as the workspace agent is outdated\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
spin.Stop()
|
||||
cliui.PeerDiagnostics(inv.Stderr, diags)
|
||||
connDiags.Write(inv.Stderr)
|
||||
results := &pingSummary{
|
||||
Workspace: workspaceName,
|
||||
}
|
||||
n := 0
|
||||
didP2p := false
|
||||
start := time.Now()
|
||||
pingLoop:
|
||||
for {
|
||||
if n > 0 {
|
||||
time.Sleep(pingWait)
|
||||
@@ -89,6 +209,7 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
dur, p2p, pong, err := conn.Ping(ctx)
|
||||
cancel()
|
||||
results.addResult(pong)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.DeadlineExceeded) {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q timed out \n", workspaceName)
|
||||
@@ -144,56 +265,24 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dur.String()),
|
||||
)
|
||||
|
||||
if n == int(pingNum) {
|
||||
break
|
||||
}
|
||||
}
|
||||
diagCtx, diagCancel := context.WithTimeout(inv.Context(), 30*time.Second)
|
||||
defer diagCancel()
|
||||
diags := conn.GetPeerDiagnostics()
|
||||
cliui.PeerDiagnostics(inv.Stdout, diags)
|
||||
|
||||
ni := conn.GetNetInfo()
|
||||
connDiags := cliui.ConnDiags{
|
||||
PingP2P: didP2p,
|
||||
DisableDirect: r.disableDirect,
|
||||
LocalNetInfo: ni,
|
||||
Verbose: r.verbose,
|
||||
}
|
||||
|
||||
awsRanges, err := cliutil.FetchAWSIPRanges(diagCtx, cliutil.AWSIPRangesURL)
|
||||
if err != nil {
|
||||
opts.Logger.Debug(inv.Context(), "failed to retrieve AWS IP ranges", slog.Error(err))
|
||||
}
|
||||
|
||||
connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni)
|
||||
|
||||
connInfo, err := client.AgentConnectionInfoGeneric(diagCtx)
|
||||
if err != nil || connInfo.DERPMap == nil {
|
||||
return xerrors.Errorf("Failed to retrieve connection info from server: %w\n", err)
|
||||
}
|
||||
connDiags.ConnInfo = connInfo
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err == nil {
|
||||
connDiags.LocalInterfaces = &ifReport
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
|
||||
}
|
||||
|
||||
agentNetcheck, err := conn.Netcheck(diagCtx)
|
||||
if err == nil {
|
||||
connDiags.AgentNetcheck = &agentNetcheck
|
||||
connDiags.AgentIPIsAWS = isAWSIP(awsRanges, agentNetcheck.NetInfo)
|
||||
} else {
|
||||
var sdkErr *codersdk.Error
|
||||
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "Could not generate full connection report as the workspace agent is outdated\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
|
||||
select {
|
||||
case <-notifyCtx.Done():
|
||||
break pingLoop
|
||||
default:
|
||||
if n == int(pingNum) {
|
||||
break pingLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connDiags.Write(inv.Stdout)
|
||||
if didP2p {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "✔ You are connected directly (p2p)\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "❗ You are connected via a DERP relay, not directly (p2p)\n%s#common-problems-with-direct-connections\n", connDiags.TroubleshootingURL)
|
||||
}
|
||||
|
||||
results.Write(inv.Stdout)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -215,8 +304,7 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
{
|
||||
Flag: "num",
|
||||
FlagShorthand: "n",
|
||||
Default: "10",
|
||||
Description: "Specifies the number of pings to perform.",
|
||||
Description: "Specifies the number of pings to perform. By default, pings will continue until interrupted.",
|
||||
Value: serpent.Int64Of(&pingNum),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
func TestBuildSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.1,
|
||||
},
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.3,
|
||||
},
|
||||
{
|
||||
Err: "ping error",
|
||||
LatencySeconds: 0.4,
|
||||
},
|
||||
}
|
||||
|
||||
actual := pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, time.Duration(0.1*float64(time.Second)), *actual.Min)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Avg)
|
||||
require.Equal(t, time.Duration(0.3*float64(time.Second)), *actual.Max)
|
||||
require.Equal(t, time.Duration(0.009999999*float64(time.Second)), *actual.Variance)
|
||||
require.Equal(t, actual.Successful, 3)
|
||||
})
|
||||
|
||||
t.Run("One", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
actual := &pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, actual.Successful, 1)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Min)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Avg)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Max)
|
||||
require.Nil(t, actual.Variance)
|
||||
})
|
||||
|
||||
t.Run("NoLatency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
Err: "ping error",
|
||||
},
|
||||
{
|
||||
Err: "ping error",
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
expected := &pingSummary{
|
||||
Workspace: "test",
|
||||
Total: 2,
|
||||
Successful: 0,
|
||||
Min: nil,
|
||||
Avg: nil,
|
||||
Max: nil,
|
||||
Variance: nil,
|
||||
latencySum: 0,
|
||||
runningAvg: 0,
|
||||
m2: 0,
|
||||
}
|
||||
|
||||
actual := &pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
}
|
||||
@@ -66,8 +66,6 @@ func TestPing(t *testing.T) {
|
||||
})
|
||||
|
||||
pty.ExpectMatch("pong from " + workspace.Name)
|
||||
pty.ExpectMatch("✔ received remote agent data from Coder networking coordinator")
|
||||
pty.ExpectMatch("✔ You are connected directly (p2p)")
|
||||
cancel()
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
+5
-2
@@ -29,6 +29,7 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
disableAutostart bool
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -60,6 +61,7 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
@@ -88,8 +90,9 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
}
|
||||
|
||||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
|
||||
@@ -290,12 +290,12 @@ func TestPortForward(t *testing.T) {
|
||||
// runAgent creates a fake workspace and starts an agent locally for that
|
||||
// workspace. The agent will be cleaned up on test completion.
|
||||
// nolint:unused
|
||||
func runAgent(t *testing.T, client *codersdk.Client, owner uuid.UUID, db database.Store) database.Workspace {
|
||||
func runAgent(t *testing.T, client *codersdk.Client, owner uuid.UUID, db database.Store) database.WorkspaceTable {
|
||||
user, err := client.User(context.Background(), codersdk.Me)
|
||||
require.NoError(t, err, "specified user does not exist")
|
||||
require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations")
|
||||
orgID := user.OrganizationIDs[0]
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: owner,
|
||||
}).WithAgent().Do()
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *RootCmd) rename() *serpent.Command {
|
||||
var appearanceConfig codersdk.AppearanceConfig
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -21,6 +22,7 @@ func (r *RootCmd) rename() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(2),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
|
||||
@@ -31,7 +33,7 @@ func (r *RootCmd) rename() *serpent.Command {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n\n",
|
||||
pretty.Sprint(cliui.DefaultStyles.Wrap, "WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes). Please backup any data before proceeding."),
|
||||
)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "See: %s\n\n", "https://coder.com/docs/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "See: %s%s\n\n", appearanceConfig.DocsURL, "/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
|
||||
Validate: func(s string) error {
|
||||
|
||||
+13
-5
@@ -14,7 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func (r *RootCmd) restart() *serpent.Command {
|
||||
var parameterFlags workspaceParameterFlags
|
||||
var (
|
||||
parameterFlags workspaceParameterFlags
|
||||
bflags buildFlags
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -35,7 +38,7 @@ func (r *RootCmd) restart() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
startReq, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, WorkspaceRestart)
|
||||
startReq, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, bflags, WorkspaceRestart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -48,9 +51,13 @@ func (r *RootCmd) restart() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
wbr := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
}
|
||||
if bflags.provisionerLogDebug {
|
||||
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, wbr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,7 +72,7 @@ func (r *RootCmd) restart() *serpent.Command {
|
||||
// workspaces with the active version.
|
||||
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusForbidden {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Unable to restart the workspace with the template version from the last build. Policy may require you to restart with the current active template version.")
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate)
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start workspace with active template version: %w", err)
|
||||
}
|
||||
@@ -87,6 +94,7 @@ func (r *RootCmd) restart() *serpent.Command {
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
|
||||
cmd.Options = append(cmd.Options, bflags.cliOptions()...)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+110
-2
@@ -60,7 +60,115 @@ func TestRestart(t *testing.T) {
|
||||
require.NoError(t, err, "execute failed")
|
||||
})
|
||||
|
||||
t.Run("BuildOptions", func(t *testing.T) {
|
||||
t.Run("PromptEphemeralParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "restart", workspace.Name, "--prompt-ephemeral-parameters")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
ephemeralParameterDescription, ephemeralParameterValue,
|
||||
"Restart workspace?", "yes",
|
||||
"Stopping workspace", "",
|
||||
"Starting workspace", "",
|
||||
"workspace has been restarted", "",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
// Verify if build option is set
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{
|
||||
Name: ephemeralParameterName,
|
||||
Value: ephemeralParameterValue,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EphemeralParameterFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "restart", workspace.Name,
|
||||
"--ephemeral-parameter", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Restart workspace?", "yes",
|
||||
"Stopping workspace", "",
|
||||
"Starting workspace", "",
|
||||
"workspace has been restarted", "",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
// Verify if build option is set
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{
|
||||
Name: ephemeralParameterName,
|
||||
Value: ephemeralParameterValue,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with deprecated build-options flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -114,7 +222,7 @@ func TestRestart(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("BuildOptionFlags", func(t *testing.T) {
|
||||
t.Run("with deprecated build-option flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
|
||||
+35
-8
@@ -256,7 +256,7 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
cmd.Use = fmt.Sprintf("%s %s %s", tokens[0], flags, tokens[1])
|
||||
})
|
||||
|
||||
// Add alises when appropriate.
|
||||
// Add aliases when appropriate.
|
||||
cmd.Walk(func(cmd *serpent.Command) {
|
||||
// TODO: we should really be consistent about naming.
|
||||
if cmd.Name() == "delete" || cmd.Name() == "remove" {
|
||||
@@ -411,7 +411,7 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
{
|
||||
Flag: varNoOpen,
|
||||
Env: "CODER_NO_OPEN",
|
||||
Description: "Suppress opening the browser after logging in.",
|
||||
Description: "Suppress opening the browser when logging in, or starting the server.",
|
||||
Value: serpent.BoolOf(&r.noOpen),
|
||||
Hidden: true,
|
||||
Group: globalGroup,
|
||||
@@ -657,7 +657,12 @@ func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk
|
||||
}
|
||||
|
||||
// No org selected, and we are more than 1? Return an error.
|
||||
return codersdk.Organization{}, xerrors.Errorf("Must select an organization with --org=<org_name>.")
|
||||
validOrgs := make([]string, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
validOrgs = append(validOrgs, org.Name)
|
||||
}
|
||||
|
||||
return codersdk.Organization{}, xerrors.Errorf("Must select an organization with --org=<org_name>. Choose from: %s", strings.Join(validOrgs, ", "))
|
||||
}
|
||||
|
||||
func splitNamedWorkspace(identifier string) (owner string, workspaceName string, err error) {
|
||||
@@ -687,13 +692,26 @@ func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier str
|
||||
return client.WorkspaceByOwnerAndName(ctx, owner, name, codersdk.WorkspaceOptions{})
|
||||
}
|
||||
|
||||
func initAppearance(client *codersdk.Client, outConfig *codersdk.AppearanceConfig) serpent.MiddlewareFunc {
|
||||
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
|
||||
return func(inv *serpent.Invocation) error {
|
||||
cfg, _ := client.Appearance(inv.Context())
|
||||
if cfg.DocsURL == "" {
|
||||
cfg.DocsURL = codersdk.DefaultDocsURL()
|
||||
}
|
||||
*outConfig = cfg
|
||||
return next(inv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createConfig consumes the global configuration flag to produce a config root.
|
||||
func (r *RootCmd) createConfig() config.Root {
|
||||
return config.Root(r.globalConfig)
|
||||
}
|
||||
|
||||
// isTTY returns whether the passed reader is a TTY or not.
|
||||
func isTTY(inv *serpent.Invocation) bool {
|
||||
// isTTYIn returns whether the passed invocation is having stdin read from a TTY
|
||||
func isTTYIn(inv *serpent.Invocation) bool {
|
||||
// If the `--force-tty` command is available, and set,
|
||||
// assume we're in a tty. This is primarily for cases on Windows
|
||||
// where we may not be able to reliably detect this automatically (ie, tests)
|
||||
@@ -708,12 +726,12 @@ func isTTY(inv *serpent.Invocation) bool {
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
// isTTYOut returns whether the passed reader is a TTY or not.
|
||||
// isTTYOut returns whether the passed invocation is having stdout written to a TTY
|
||||
func isTTYOut(inv *serpent.Invocation) bool {
|
||||
return isTTYWriter(inv, inv.Stdout)
|
||||
}
|
||||
|
||||
// isTTYErr returns whether the passed reader is a TTY or not.
|
||||
// isTTYErr returns whether the passed invocation is having stderr written to a TTY
|
||||
func isTTYErr(inv *serpent.Invocation) bool {
|
||||
return isTTYWriter(inv, inv.Stderr)
|
||||
}
|
||||
@@ -1098,7 +1116,16 @@ func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) str
|
||||
//nolint:errorlint
|
||||
func traceError(err error) string {
|
||||
if uw, ok := err.(interface{ Unwrap() error }); ok {
|
||||
a, b := err.Error(), uw.Unwrap().Error()
|
||||
var a, b string
|
||||
if err != nil {
|
||||
a = err.Error()
|
||||
}
|
||||
if uw != nil {
|
||||
uwerr := uw.Unwrap()
|
||||
if uwerr != nil {
|
||||
b = uwerr.Error()
|
||||
}
|
||||
}
|
||||
c := strings.TrimSuffix(a, b)
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberC
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "testuser2" // ensure deterministic ordering
|
||||
})
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
Name: "a-owner",
|
||||
OwnerID: owner.UserID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
@@ -46,19 +46,19 @@ func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberC
|
||||
Ttl: sql.NullInt64{Int64: 8 * time.Hour.Nanoseconds(), Valid: true},
|
||||
}).WithAgent().Do()
|
||||
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
Name: "b-owner",
|
||||
OwnerID: owner.UserID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
AutostartSchedule: sql.NullString{String: sched.String(), Valid: true},
|
||||
}).WithAgent().Do()
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
Name: "c-member",
|
||||
OwnerID: memberUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
Ttl: sql.NullInt64{Int64: 8 * time.Hour.Nanoseconds(), Valid: true},
|
||||
}).WithAgent().Do()
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
Name: "d-member",
|
||||
OwnerID: memberUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
|
||||
+97
-144
@@ -10,7 +10,6 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -32,6 +31,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-systemd/daemon"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
@@ -55,13 +55,17 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/coderd/entitlements"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/retry"
|
||||
"github.com/coder/serpent"
|
||||
"github.com/coder/wgtunnel/tunnelsdk"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/entitlements"
|
||||
"github.com/coder/coder/v2/coderd/notifications/reports"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clilog"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
@@ -93,7 +97,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/updatecheck"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
stringutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -186,14 +189,6 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
|
||||
EmailField: vals.OIDC.EmailField.String(),
|
||||
AuthURLParams: vals.OIDC.AuthURLParams.Value,
|
||||
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
|
||||
GroupField: vals.OIDC.GroupField.String(),
|
||||
GroupFilter: vals.OIDC.GroupRegexFilter.Value(),
|
||||
GroupAllowList: groupAllowList,
|
||||
CreateMissingGroups: vals.OIDC.GroupAutoCreate.Value(),
|
||||
GroupMapping: vals.OIDC.GroupMapping.Value,
|
||||
UserRoleField: vals.OIDC.UserRoleField.String(),
|
||||
UserRoleMapping: vals.OIDC.UserRoleMapping.Value,
|
||||
UserRolesDefault: vals.OIDC.UserRolesDefault.GetSlice(),
|
||||
SignInText: vals.OIDC.SignInText.String(),
|
||||
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
|
||||
IconURL: vals.OIDC.IconURL.String(),
|
||||
@@ -217,10 +212,16 @@ func enablePrometheus(
|
||||
options.PrometheusRegistry.MustRegister(collectors.NewGoCollector())
|
||||
options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||
|
||||
closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0)
|
||||
closeActiveUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.Logger.Named("active_user_metrics"), options.PrometheusRegistry, options.Database, 0)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register active users prometheus metric: %w", err)
|
||||
}
|
||||
afterCtx(ctx, closeActiveUsersFunc)
|
||||
|
||||
closeUsersFunc, err := prometheusmetrics.Users(ctx, options.Logger.Named("user_metrics"), quartz.NewReal(), options.PrometheusRegistry, options.Database, 0)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register users prometheus metric: %w", err)
|
||||
}
|
||||
afterCtx(ctx, closeUsersFunc)
|
||||
|
||||
closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.Logger.Named("workspaces_metrics"), options.PrometheusRegistry, options.Database, 0)
|
||||
@@ -245,7 +246,8 @@ func enablePrometheus(
|
||||
afterCtx(ctx, closeInsightsMetricsCollector)
|
||||
|
||||
if vals.Prometheus.CollectAgentStats {
|
||||
closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0, options.DeploymentValues.Prometheus.AggregateAgentStatsBy.Value())
|
||||
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
|
||||
closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0, options.DeploymentValues.Prometheus.AggregateAgentStatsBy.Value(), experiments.Enabled(codersdk.ExperimentWorkspaceUsage))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register agent stats prometheus metric: %w", err)
|
||||
}
|
||||
@@ -488,8 +490,20 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
)
|
||||
}
|
||||
|
||||
// A newline is added before for visibility in terminal output.
|
||||
cliui.Infof(inv.Stdout, "\nView the Web UI: %s", vals.AccessURL.String())
|
||||
accessURL := vals.AccessURL.String()
|
||||
cliui.Infof(inv.Stdout, lipgloss.NewStyle().
|
||||
Border(lipgloss.DoubleBorder()).
|
||||
Align(lipgloss.Center).
|
||||
Padding(0, 3).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Render(fmt.Sprintf("View the Web UI:\n%s",
|
||||
pretty.Sprint(cliui.DefaultStyles.Hyperlink, accessURL))))
|
||||
if buildinfo.HasSite() {
|
||||
err = openURL(inv, accessURL)
|
||||
if err == nil {
|
||||
cliui.Infof(inv.Stdout, "Opening local browser... You can disable this by passing --no-open.\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Used for zero-trust instance identity with Google Cloud.
|
||||
googleTokenValidator, err := idtoken.NewValidator(ctx, option.WithoutAuthentication())
|
||||
@@ -634,7 +648,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
"new version of coder available",
|
||||
slog.F("new_version", r.Version),
|
||||
slog.F("url", r.URL),
|
||||
slog.F("upgrade_instructions", "https://coder.com/docs/admin/upgrade"),
|
||||
slog.F("upgrade_instructions", fmt.Sprintf("%s/admin/upgrade", vals.DocsURL.String())),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -676,10 +690,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
options.OIDCConfig = oc
|
||||
}
|
||||
|
||||
experiments := coderd.ReadExperiments(
|
||||
options.Logger, options.DeploymentValues.Experiments.Value(),
|
||||
)
|
||||
|
||||
// We'll read from this channel in the select below that tracks shutdown. If it remains
|
||||
// nil, that case of the select will just never fire, but it's important not to have a
|
||||
// "bare" read on this channel.
|
||||
@@ -713,7 +723,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
}
|
||||
|
||||
if options.DeploymentValues.Prometheus.Enable && options.DeploymentValues.Prometheus.CollectDBMetrics {
|
||||
options.Database = dbmetrics.New(options.Database, options.PrometheusRegistry)
|
||||
options.Database = dbmetrics.NewQueryMetrics(options.Database, options.Logger, options.PrometheusRegistry)
|
||||
} else {
|
||||
options.Database = dbmetrics.NewDBMetrics(options.Database, options.Logger, options.PrometheusRegistry)
|
||||
}
|
||||
|
||||
var deploymentID string
|
||||
@@ -736,90 +748,33 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
return xerrors.Errorf("set deployment id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read the app signing key from the DB. We store it hex encoded
|
||||
// since the config table uses strings for the value and we
|
||||
// don't want to deal with automatic encoding issues.
|
||||
appSecurityKeyStr, err := tx.GetAppSecurityKey(ctx)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("get app signing key: %w", err)
|
||||
}
|
||||
// If the string in the DB is an invalid hex string or the
|
||||
// length is not equal to the current key length, generate a new
|
||||
// one.
|
||||
//
|
||||
// If the key is regenerated, old signed tokens and encrypted
|
||||
// strings will become invalid. New signed app tokens will be
|
||||
// generated automatically on failure. Any workspace app token
|
||||
// smuggling operations in progress may fail, although with a
|
||||
// helpful error.
|
||||
if decoded, err := hex.DecodeString(appSecurityKeyStr); err != nil || len(decoded) != len(workspaceapps.SecurityKey{}) {
|
||||
b := make([]byte, len(workspaceapps.SecurityKey{}))
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate fresh app signing key: %w", err)
|
||||
}
|
||||
|
||||
appSecurityKeyStr = hex.EncodeToString(b)
|
||||
err = tx.UpsertAppSecurityKey(ctx, appSecurityKeyStr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert freshly generated app signing key to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
appSecurityKey, err := workspaceapps.KeyFromString(appSecurityKeyStr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode app signing key from database: %w", err)
|
||||
}
|
||||
|
||||
options.AppSecurityKey = appSecurityKey
|
||||
|
||||
// Read the oauth signing key from the database. Like the app security, generate a new one
|
||||
// if it is invalid for any reason.
|
||||
oauthSigningKeyStr, err := tx.GetOAuthSigningKey(ctx)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("get app oauth signing key: %w", err)
|
||||
}
|
||||
if decoded, err := hex.DecodeString(oauthSigningKeyStr); err != nil || len(decoded) != len(options.OAuthSigningKey) {
|
||||
b := make([]byte, len(options.OAuthSigningKey))
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate fresh oauth signing key: %w", err)
|
||||
}
|
||||
|
||||
oauthSigningKeyStr = hex.EncodeToString(b)
|
||||
err = tx.UpsertOAuthSigningKey(ctx, oauthSigningKeyStr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert freshly generated oauth signing key to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
oauthKeyBytes, err := hex.DecodeString(oauthSigningKeyStr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode oauth signing key from database: %w", err)
|
||||
}
|
||||
if len(oauthKeyBytes) != len(options.OAuthSigningKey) {
|
||||
return xerrors.Errorf("oauth signing key in database is not the correct length, expect %d got %d", len(options.OAuthSigningKey), len(oauthKeyBytes))
|
||||
}
|
||||
copy(options.OAuthSigningKey[:], oauthKeyBytes)
|
||||
if options.OAuthSigningKey == [32]byte{} {
|
||||
return xerrors.Errorf("oauth signing key in database is empty")
|
||||
}
|
||||
|
||||
// Read the coordinator resume token signing key from the
|
||||
// database.
|
||||
resumeTokenKey, err := tailnet.ResumeTokenSigningKeyFromDatabase(ctx, tx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get coordinator resume token key from database: %w", err)
|
||||
}
|
||||
options.CoordinatorResumeTokenProvider = tailnet.NewResumeTokenKeyProvider(resumeTokenKey, quartz.NewReal(), tailnet.DefaultResumeTokenExpiry)
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("set deployment id: %w", err)
|
||||
}
|
||||
|
||||
fetcher := &cryptokeys.DBFetcher{
|
||||
DB: options.Database,
|
||||
}
|
||||
|
||||
resumeKeycache, err := cryptokeys.NewSigningCache(ctx,
|
||||
logger,
|
||||
fetcher,
|
||||
codersdk.CryptoKeyFeatureTailnetResume,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Critical(ctx, "failed to properly instantiate tailnet resume signing cache", slog.Error(err))
|
||||
}
|
||||
|
||||
options.CoordinatorResumeTokenProvider = tailnet.NewResumeTokenKeyProvider(
|
||||
resumeKeycache,
|
||||
quartz.NewReal(),
|
||||
tailnet.DefaultResumeTokenExpiry,
|
||||
)
|
||||
|
||||
options.RuntimeConfig = runtimeconfig.NewManager()
|
||||
|
||||
// This should be output before the logs start streaming.
|
||||
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
|
||||
|
||||
@@ -858,7 +813,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
}
|
||||
defer options.Telemetry.Close()
|
||||
} else {
|
||||
logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/admin/telemetry`)
|
||||
logger.Warn(ctx, fmt.Sprintf(`telemetry disabled, unable to notify of security issues. Read more: %s/admin/telemetry`, vals.DocsURL.String()))
|
||||
}
|
||||
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
@@ -941,6 +896,33 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
return xerrors.Errorf("write config url: %w", err)
|
||||
}
|
||||
|
||||
// Manage notifications.
|
||||
cfg := options.DeploymentValues.Notifications
|
||||
metrics := notifications.NewMetrics(options.PrometheusRegistry)
|
||||
helpers := templateHelpers(options)
|
||||
|
||||
// The enqueuer is responsible for enqueueing notifications to the given store.
|
||||
enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, helpers, logger.Named("notifications.enqueuer"), quartz.NewReal())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to instantiate notification store enqueuer: %w", err)
|
||||
}
|
||||
options.NotificationsEnqueuer = enqueuer
|
||||
|
||||
// The notification manager is responsible for:
|
||||
// - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications)
|
||||
// - keeping the store updated with status updates
|
||||
notificationsManager, err := notifications.NewManager(cfg, options.Database, helpers, metrics, logger.Named("notifications.manager"))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to instantiate notification manager: %w", err)
|
||||
}
|
||||
|
||||
// nolint:gocritic // TODO: create own role.
|
||||
notificationsManager.Run(dbauthz.AsSystemRestricted(ctx))
|
||||
|
||||
// Run report generator to distribute periodic reports.
|
||||
notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal())
|
||||
defer notificationReportGenerator.Close()
|
||||
|
||||
// Since errCh only has one buffered slot, all routines
|
||||
// sending on it must be wrapped in a select/default to
|
||||
// avoid leaving dangling goroutines waiting for the
|
||||
@@ -997,34 +979,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
options.WorkspaceUsageTracker = tracker
|
||||
defer tracker.Close()
|
||||
|
||||
// Manage notifications.
|
||||
var (
|
||||
notificationsManager *notifications.Manager
|
||||
)
|
||||
if experiments.Enabled(codersdk.ExperimentNotifications) {
|
||||
cfg := options.DeploymentValues.Notifications
|
||||
metrics := notifications.NewMetrics(options.PrometheusRegistry)
|
||||
helpers := templateHelpers(options)
|
||||
|
||||
// The enqueuer is responsible for enqueueing notifications to the given store.
|
||||
enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, helpers, logger.Named("notifications.enqueuer"), quartz.NewReal())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to instantiate notification store enqueuer: %w", err)
|
||||
}
|
||||
options.NotificationsEnqueuer = enqueuer
|
||||
|
||||
// The notification manager is responsible for:
|
||||
// - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications)
|
||||
// - keeping the store updated with status updates
|
||||
notificationsManager, err = notifications.NewManager(cfg, options.Database, helpers, metrics, logger.Named("notifications.manager"))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to instantiate notification manager: %w", err)
|
||||
}
|
||||
|
||||
// nolint:gocritic // TODO: create own role.
|
||||
notificationsManager.Run(dbauthz.AsSystemRestricted(ctx))
|
||||
}
|
||||
|
||||
// Wrap the server in middleware that redirects to the access URL if
|
||||
// the request is not to a local IP.
|
||||
var handler http.Handler = coderAPI.RootHandler
|
||||
@@ -1144,19 +1098,17 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
// Cancel any remaining in-flight requests.
|
||||
shutdownConns()
|
||||
|
||||
if notificationsManager != nil {
|
||||
// Stop the notification manager, which will cause any buffered updates to the store to be flushed.
|
||||
// If the Stop() call times out, messages that were sent but not reflected as such in the store will have
|
||||
// their leases expire after a period of time and will be re-queued for sending.
|
||||
// See CODER_NOTIFICATIONS_LEASE_PERIOD.
|
||||
cliui.Info(inv.Stdout, "Shutting down notifications manager..."+"\n")
|
||||
err = shutdownWithTimeout(notificationsManager.Stop, 5*time.Second)
|
||||
if err != nil {
|
||||
cliui.Warnf(inv.Stderr, "Notifications manager shutdown took longer than 5s, "+
|
||||
"this may result in duplicate notifications being sent: %s\n", err)
|
||||
} else {
|
||||
cliui.Info(inv.Stdout, "Gracefully shut down notifications manager\n")
|
||||
}
|
||||
// Stop the notification manager, which will cause any buffered updates to the store to be flushed.
|
||||
// If the Stop() call times out, messages that were sent but not reflected as such in the store will have
|
||||
// their leases expire after a period of time and will be re-queued for sending.
|
||||
// See CODER_NOTIFICATIONS_LEASE_PERIOD.
|
||||
cliui.Info(inv.Stdout, "Shutting down notifications manager..."+"\n")
|
||||
err = shutdownWithTimeout(notificationsManager.Stop, 5*time.Second)
|
||||
if err != nil {
|
||||
cliui.Warnf(inv.Stderr, "Notifications manager shutdown took longer than 5s, "+
|
||||
"this may result in duplicate notifications being sent: %s\n", err)
|
||||
} else {
|
||||
cliui.Info(inv.Stdout, "Gracefully shut down notifications manager\n")
|
||||
}
|
||||
|
||||
// Shut down provisioners before waiting for WebSockets
|
||||
@@ -1437,6 +1389,7 @@ func newProvisionerDaemon(
|
||||
|
||||
// Omit any duplicates
|
||||
provisionerTypes = slice.Unique(provisionerTypes)
|
||||
provisionerLogger := logger.Named(fmt.Sprintf("provisionerd-%s", name))
|
||||
|
||||
// Populate the connector with the supported types.
|
||||
connector := provisionerd.LocalProvisioners{}
|
||||
@@ -1493,7 +1446,7 @@ func newProvisionerDaemon(
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
Logger: logger.Named("terraform"),
|
||||
Logger: provisionerLogger,
|
||||
WorkDirectory: workDir,
|
||||
},
|
||||
CachePath: tfDir,
|
||||
@@ -1518,7 +1471,7 @@ func newProvisionerDaemon(
|
||||
// in provisionerdserver.go to learn more!
|
||||
return coderAPI.CreateInMemoryProvisionerDaemon(dialCtx, name, provisionerTypes)
|
||||
}, &provisionerd.Options{
|
||||
Logger: logger.Named(fmt.Sprintf("provisionerd-%s", name)),
|
||||
Logger: provisionerLogger,
|
||||
UpdateInterval: time.Second,
|
||||
ForceCancelInterval: cfg.Provisioner.ForceCancelInterval.Value(),
|
||||
Connector: connector,
|
||||
|
||||
@@ -197,6 +197,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
UpdatedAt: dbtime.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
Status: "",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert user: %w", err)
|
||||
|
||||
+6
-3
@@ -221,7 +221,8 @@ func TestServer(t *testing.T) {
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
|
||||
pty.ExpectMatch("View the Web UI: http://localhost:3000/")
|
||||
pty.ExpectMatch("View the Web UI:")
|
||||
pty.ExpectMatch("http://localhost:3000/")
|
||||
})
|
||||
|
||||
// Validate that an https scheme is prepended to a remote access URL
|
||||
@@ -244,7 +245,8 @@ func TestServer(t *testing.T) {
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
|
||||
pty.ExpectMatch("View the Web UI: https://foobarbaz.mydomain")
|
||||
pty.ExpectMatch("View the Web UI:")
|
||||
pty.ExpectMatch("https://foobarbaz.mydomain")
|
||||
})
|
||||
|
||||
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
|
||||
@@ -262,7 +264,8 @@ func TestServer(t *testing.T) {
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("View the Web UI: https://google.com")
|
||||
pty.ExpectMatch("View the Web UI:")
|
||||
pty.ExpectMatch("https://google.com")
|
||||
})
|
||||
|
||||
t.Run("NoSchemeAccessURL", func(t *testing.T) {
|
||||
|
||||
+10
-7
@@ -36,11 +36,12 @@ type speedtestTableItem struct {
|
||||
|
||||
func (r *RootCmd) speedtest() *serpent.Command {
|
||||
var (
|
||||
direct bool
|
||||
duration time.Duration
|
||||
direction string
|
||||
pcapFile string
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
direct bool
|
||||
duration time.Duration
|
||||
direction string
|
||||
pcapFile string
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) {
|
||||
res, ok := data.(SpeedtestResult)
|
||||
if !ok {
|
||||
@@ -72,6 +73,7 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
@@ -87,8 +89,9 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
}
|
||||
|
||||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
|
||||
+69
-17
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/retry"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -48,6 +49,8 @@ const (
|
||||
var (
|
||||
workspacePollInterval = time.Minute
|
||||
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
|
||||
// gracefulShutdownTimeout is the timeout, per item in the stack of things to close
|
||||
gracefulShutdownTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *RootCmd) ssh() *serpent.Command {
|
||||
@@ -64,6 +67,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
env []string
|
||||
usageApp string
|
||||
disableAutostart bool
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -73,6 +77,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) (retErr error) {
|
||||
// Before dialing the SSH server over TCP, capture Interrupt signals
|
||||
@@ -153,7 +158,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
// log HTTP requests
|
||||
client.SetLogger(logger)
|
||||
}
|
||||
stack := newCloserStack(ctx, logger)
|
||||
stack := newCloserStack(ctx, logger, quartz.NewReal())
|
||||
defer stack.close(nil)
|
||||
|
||||
for _, remoteForward := range remoteForwards {
|
||||
@@ -227,9 +232,11 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
// OpenSSH passes stderr directly to the calling TTY.
|
||||
// This is required in "stdio" mode so a connecting indicator can be displayed.
|
||||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
|
||||
Fetch: client.WorkspaceAgent,
|
||||
FetchLogs: client.WorkspaceAgentLogsAfter,
|
||||
Wait: wait,
|
||||
FetchInterval: 0,
|
||||
Fetch: client.WorkspaceAgent,
|
||||
FetchLogs: client.WorkspaceAgentLogsAfter,
|
||||
Wait: wait,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
@@ -649,9 +656,9 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *
|
||||
// It's possible for a workspace build to fail due to the template requiring starting
|
||||
// workspaces with the active version.
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Workspace was stopped, starting workspace to allow connecting to %q...\n", workspace.Name)
|
||||
_, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, WorkspaceStart)
|
||||
_, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceStart)
|
||||
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusForbidden {
|
||||
_, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, WorkspaceUpdate)
|
||||
_, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceUpdate)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("start workspace with active template version: %w", err)
|
||||
}
|
||||
@@ -936,11 +943,18 @@ type closerStack struct {
|
||||
closed bool
|
||||
logger slog.Logger
|
||||
err error
|
||||
wg sync.WaitGroup
|
||||
allDone chan struct{}
|
||||
|
||||
// for testing
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
func newCloserStack(ctx context.Context, logger slog.Logger) *closerStack {
|
||||
cs := &closerStack{logger: logger}
|
||||
func newCloserStack(ctx context.Context, logger slog.Logger, clock quartz.Clock) *closerStack {
|
||||
cs := &closerStack{
|
||||
logger: logger,
|
||||
allDone: make(chan struct{}),
|
||||
clock: clock,
|
||||
}
|
||||
go cs.closeAfterContext(ctx)
|
||||
return cs
|
||||
}
|
||||
@@ -954,20 +968,58 @@ func (c *closerStack) close(err error) {
|
||||
c.Lock()
|
||||
if c.closed {
|
||||
c.Unlock()
|
||||
c.wg.Wait()
|
||||
<-c.allDone
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
c.err = err
|
||||
c.wg.Add(1)
|
||||
defer c.wg.Done()
|
||||
c.Unlock()
|
||||
defer close(c.allDone)
|
||||
if len(c.closers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := len(c.closers) - 1; i >= 0; i-- {
|
||||
cwn := c.closers[i]
|
||||
cErr := cwn.closer.Close()
|
||||
c.logger.Debug(context.Background(),
|
||||
"closed item from stack", slog.F("name", cwn.name), slog.Error(cErr))
|
||||
// We are going to work down the stack in order. If things close quickly, we trigger the
|
||||
// closers serially, in order. `done` is a channel that indicates the nth closer is done
|
||||
// closing, and we should trigger the (n-1) closer. However, if things take too long we don't
|
||||
// want to wait, so we also start a ticker that works down the stack and sends on `done` as
|
||||
// well.
|
||||
next := len(c.closers) - 1
|
||||
// here we make the buffer 2x the number of closers because we could write once for it being
|
||||
// actually done and once via the countdown for each closer
|
||||
done := make(chan int, len(c.closers)*2)
|
||||
startNext := func() {
|
||||
go func(i int) {
|
||||
defer func() { done <- i }()
|
||||
cwn := c.closers[i]
|
||||
cErr := cwn.closer.Close()
|
||||
c.logger.Debug(context.Background(),
|
||||
"closed item from stack", slog.F("name", cwn.name), slog.Error(cErr))
|
||||
}(next)
|
||||
next--
|
||||
}
|
||||
done <- len(c.closers) // kick us off right away
|
||||
|
||||
// start a ticking countdown in case we hang/don't close quickly
|
||||
countdown := len(c.closers) - 1
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.clock.TickerFunc(ctx, gracefulShutdownTimeout, func() error {
|
||||
if countdown < 0 {
|
||||
return nil
|
||||
}
|
||||
done <- countdown
|
||||
countdown--
|
||||
return nil
|
||||
}, "closerStack")
|
||||
|
||||
for n := range done { // the nth closer is done
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
if n-1 == next {
|
||||
startNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+86
-17
@@ -2,7 +2,9 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/quartz"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -68,7 +71,7 @@ func TestCloserStack_Mainline(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
uut := newCloserStack(ctx, logger)
|
||||
uut := newCloserStack(ctx, logger, quartz.NewMock(t))
|
||||
closes := new([]*fakeCloser)
|
||||
fc0 := &fakeCloser{closes: closes}
|
||||
fc1 := &fakeCloser{closes: closes}
|
||||
@@ -84,13 +87,27 @@ func TestCloserStack_Mainline(t *testing.T) {
|
||||
require.Equal(t, []*fakeCloser{fc1, fc0}, *closes)
|
||||
}
|
||||
|
||||
func TestCloserStack_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
uut := newCloserStack(ctx, logger, quartz.NewMock(t))
|
||||
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closed)
|
||||
uut.close(nil)
|
||||
}()
|
||||
testutil.RequireRecvCtx(ctx, t, closed)
|
||||
}
|
||||
|
||||
func TestCloserStack_Context(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
uut := newCloserStack(ctx, logger)
|
||||
uut := newCloserStack(ctx, logger, quartz.NewMock(t))
|
||||
closes := new([]*fakeCloser)
|
||||
fc0 := &fakeCloser{closes: closes}
|
||||
fc1 := &fakeCloser{closes: closes}
|
||||
@@ -111,7 +128,7 @@ func TestCloserStack_PushAfterClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
uut := newCloserStack(ctx, logger)
|
||||
uut := newCloserStack(ctx, logger, quartz.NewMock(t))
|
||||
closes := new([]*fakeCloser)
|
||||
fc0 := &fakeCloser{closes: closes}
|
||||
fc1 := &fakeCloser{closes: closes}
|
||||
@@ -134,13 +151,9 @@ func TestCloserStack_CloseAfterContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(testCtx)
|
||||
defer cancel()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
uut := newCloserStack(ctx, logger)
|
||||
ac := &asyncCloser{
|
||||
t: t,
|
||||
ctx: testCtx,
|
||||
complete: make(chan struct{}),
|
||||
started: make(chan struct{}),
|
||||
}
|
||||
uut := newCloserStack(ctx, logger, quartz.NewMock(t))
|
||||
ac := newAsyncCloser(testCtx, t)
|
||||
defer ac.complete()
|
||||
err := uut.push("async", ac)
|
||||
require.NoError(t, err)
|
||||
cancel()
|
||||
@@ -160,11 +173,53 @@ func TestCloserStack_CloseAfterContext(t *testing.T) {
|
||||
t.Fatal("closed before stack was finished")
|
||||
}
|
||||
|
||||
// complete the asyncCloser
|
||||
close(ac.complete)
|
||||
ac.complete()
|
||||
testutil.RequireRecvCtx(testCtx, t, closed)
|
||||
}
|
||||
|
||||
func TestCloserStack_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().TickerFunc("closerStack")
|
||||
defer trap.Close()
|
||||
uut := newCloserStack(ctx, logger, mClock)
|
||||
var ac [3]*asyncCloser
|
||||
for i := range ac {
|
||||
ac[i] = newAsyncCloser(ctx, t)
|
||||
err := uut.push(fmt.Sprintf("async %d", i), ac[i])
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer func() {
|
||||
for _, a := range ac {
|
||||
a.complete()
|
||||
}
|
||||
}()
|
||||
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closed)
|
||||
uut.close(nil)
|
||||
}()
|
||||
trap.MustWait(ctx).Release()
|
||||
// top starts right away, but it hangs
|
||||
testutil.RequireRecvCtx(ctx, t, ac[2].started)
|
||||
// timer pops and we start the middle one
|
||||
mClock.Advance(gracefulShutdownTimeout).MustWait(ctx)
|
||||
testutil.RequireRecvCtx(ctx, t, ac[1].started)
|
||||
|
||||
// middle one finishes
|
||||
ac[1].complete()
|
||||
// bottom starts, but also hangs
|
||||
testutil.RequireRecvCtx(ctx, t, ac[0].started)
|
||||
|
||||
// timer has to pop twice to time out.
|
||||
mClock.Advance(gracefulShutdownTimeout).MustWait(ctx)
|
||||
mClock.Advance(gracefulShutdownTimeout).MustWait(ctx)
|
||||
testutil.RequireRecvCtx(ctx, t, closed)
|
||||
}
|
||||
|
||||
type fakeCloser struct {
|
||||
closes *[]*fakeCloser
|
||||
err error
|
||||
@@ -176,10 +231,11 @@ func (c *fakeCloser) Close() error {
|
||||
}
|
||||
|
||||
type asyncCloser struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
started chan struct{}
|
||||
complete chan struct{}
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
started chan struct{}
|
||||
isComplete chan struct{}
|
||||
comepleteOnce sync.Once
|
||||
}
|
||||
|
||||
func (c *asyncCloser) Close() error {
|
||||
@@ -188,7 +244,20 @@ func (c *asyncCloser) Close() error {
|
||||
case <-c.ctx.Done():
|
||||
c.t.Error("timed out")
|
||||
return c.ctx.Err()
|
||||
case <-c.complete:
|
||||
case <-c.isComplete:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *asyncCloser) complete() {
|
||||
c.comepleteOnce.Do(func() { close(c.isComplete) })
|
||||
}
|
||||
|
||||
func newAsyncCloser(ctx context.Context, t *testing.T) *asyncCloser {
|
||||
return &asyncCloser{
|
||||
t: t,
|
||||
ctx: ctx,
|
||||
isComplete: make(chan struct{}),
|
||||
started: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -53,14 +53,14 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func setupWorkspaceForAgent(t *testing.T, mutations ...func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, database.Workspace, string) {
|
||||
func setupWorkspaceForAgent(t *testing.T, mutations ...func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, database.WorkspaceTable, string) {
|
||||
t.Helper()
|
||||
|
||||
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent(mutations...).Do()
|
||||
@@ -260,7 +260,7 @@ func TestSSH(t *testing.T) {
|
||||
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
@@ -763,7 +763,7 @@ func TestSSH(t *testing.T) {
|
||||
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
@@ -1370,7 +1370,7 @@ func TestSSH(t *testing.T) {
|
||||
admin.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, admin)
|
||||
client, user := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
+24
-15
@@ -13,7 +13,10 @@ import (
|
||||
)
|
||||
|
||||
func (r *RootCmd) start() *serpent.Command {
|
||||
var parameterFlags workspaceParameterFlags
|
||||
var (
|
||||
parameterFlags workspaceParameterFlags
|
||||
bflags buildFlags
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -45,12 +48,12 @@ func (r *RootCmd) start() *serpent.Command {
|
||||
)
|
||||
build = workspace.LatestBuild
|
||||
default:
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceStart)
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceStart)
|
||||
// It's possible for a workspace build to fail due to the template requiring starting
|
||||
// workspaces with the active version.
|
||||
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusForbidden {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with the template version from the last build. Policy may require you to restart with the current active template version.")
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate)
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start workspace with active template version: %w", err)
|
||||
}
|
||||
@@ -73,11 +76,12 @@ func (r *RootCmd) start() *serpent.Command {
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
|
||||
cmd.Options = append(cmd.Options, bflags.cliOptions()...)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.CreateWorkspaceBuildRequest, error) {
|
||||
func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, buildFlags buildFlags, action WorkspaceCLIAction) (codersdk.CreateWorkspaceBuildRequest, error) {
|
||||
version := workspace.LatestBuild.TemplateVersionID
|
||||
|
||||
if workspace.AutomaticUpdates == codersdk.AutomaticUpdatesAlways || action == WorkspaceUpdate {
|
||||
@@ -92,7 +96,7 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
|
||||
return codersdk.CreateWorkspaceBuildRequest{}, err
|
||||
}
|
||||
|
||||
buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions)
|
||||
ephemeralParameters, err := asWorkspaceBuildParameters(parameterFlags.ephemeralParameters)
|
||||
if err != nil {
|
||||
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err)
|
||||
}
|
||||
@@ -113,25 +117,30 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
|
||||
NewWorkspaceName: workspace.Name,
|
||||
LastBuildParameters: lastBuildParameters,
|
||||
|
||||
PromptBuildOptions: parameterFlags.promptBuildOptions,
|
||||
BuildOptions: buildOptions,
|
||||
PromptRichParameters: parameterFlags.promptRichParameters,
|
||||
RichParameters: cliRichParameters,
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameterDefaults: cliRichParameterDefaults,
|
||||
PromptEphemeralParameters: parameterFlags.promptEphemeralParameters,
|
||||
EphemeralParameters: ephemeralParameters,
|
||||
PromptRichParameters: parameterFlags.promptRichParameters,
|
||||
RichParameters: cliRichParameters,
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameterDefaults: cliRichParameterDefaults,
|
||||
})
|
||||
if err != nil {
|
||||
return codersdk.CreateWorkspaceBuildRequest{}, err
|
||||
}
|
||||
|
||||
return codersdk.CreateWorkspaceBuildRequest{
|
||||
wbr := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
RichParameterValues: buildParameters,
|
||||
TemplateVersionID: version,
|
||||
}, nil
|
||||
}
|
||||
if buildFlags.provisionerLogDebug {
|
||||
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
|
||||
}
|
||||
|
||||
return wbr, nil
|
||||
}
|
||||
|
||||
func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.WorkspaceBuild, error) {
|
||||
func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, buildFlags buildFlags, action WorkspaceCLIAction) (codersdk.WorkspaceBuild, error) {
|
||||
if workspace.DormantAt != nil {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Activating dormant workspace...")
|
||||
err := client.UpdateWorkspaceDormancy(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceDormancy{
|
||||
@@ -141,7 +150,7 @@ func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace
|
||||
return codersdk.WorkspaceBuild{}, xerrors.Errorf("activate workspace: %w", err)
|
||||
}
|
||||
}
|
||||
req, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, action)
|
||||
req, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, buildFlags, action)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceBuild{}, err
|
||||
}
|
||||
|
||||
+7
-7
@@ -115,7 +115,7 @@ func TestStart(t *testing.T) {
|
||||
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "start", workspace.Name, "--build-options")
|
||||
inv, root := clitest.New(t, "start", workspace.Name, "--prompt-ephemeral-parameters")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -140,7 +140,7 @@ func TestStart(t *testing.T) {
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
// Verify if build option is set
|
||||
// Verify if ephemeral parameter is set
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestStart(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("BuildOptionFlags", func(t *testing.T) {
|
||||
t.Run("EphemeralParameterFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -170,7 +170,7 @@ func TestStart(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "start", workspace.Name,
|
||||
"--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
|
||||
"--ephemeral-parameter", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -183,7 +183,7 @@ func TestStart(t *testing.T) {
|
||||
pty.ExpectMatch("workspace has been started")
|
||||
<-doneChan
|
||||
|
||||
// Verify if build option is set
|
||||
// Verify if ephemeral parameter is set
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestStart_AlreadyRunning(t *testing.T) {
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: member.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do()
|
||||
@@ -417,7 +417,7 @@ func TestStart_Starting(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{Pubsub: ps, Database: store})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OwnerID: member.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ func TestStatePull(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
wantState := []byte("some state")
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
@@ -49,7 +49,7 @@ func TestStatePull(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
wantState := []byte("some state")
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
@@ -69,7 +69,7 @@ func TestStatePull(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
_, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
wantState := []byte("some state")
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
|
||||
+9
-2
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *RootCmd) stop() *serpent.Command {
|
||||
var bflags buildFlags
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -35,9 +36,13 @@ func (r *RootCmd) stop() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
wbr := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
}
|
||||
if bflags.provisionerLogDebug {
|
||||
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,5 +61,7 @@ func (r *RootCmd) stop() *serpent.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Options = append(cmd.Options, bflags.cliOptions()...)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ func TestSupportBundle(t *testing.T) {
|
||||
DeploymentValues: dc.Values,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: owner.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
@@ -132,7 +132,7 @@ func TestSupportBundle(t *testing.T) {
|
||||
DeploymentValues: dc.Values,
|
||||
})
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: admin.OrganizationID,
|
||||
OwnerID: admin.UserID,
|
||||
}).Do() // without agent!
|
||||
@@ -151,7 +151,7 @@ func TestSupportBundle(t *testing.T) {
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: member.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
@@ -74,7 +74,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
templateName, err := uploadFlags.templateName(inv.Args)
|
||||
templateName, err := uploadFlags.templateName(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
message := uploadFlags.templateMessage(inv)
|
||||
|
||||
var varsFiles []string
|
||||
if !uploadFlags.stdin() {
|
||||
if !uploadFlags.stdin(inv) {
|
||||
varsFiles, err = codersdk.DiscoverVarsFiles(uploadFlags.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -139,7 +139,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if !uploadFlags.stdin() {
|
||||
if !uploadFlags.stdin(inv) {
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
@@ -237,7 +237,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
},
|
||||
{
|
||||
Flag: "require-active-version",
|
||||
Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.",
|
||||
Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. See https://coder.com/docs/templates/general-settings#require-automatic-updates-enterprise for more details.",
|
||||
Value: serpent.BoolOf(&requireActiveVersion),
|
||||
Default: "false",
|
||||
},
|
||||
|
||||
+1
-1
@@ -290,7 +290,7 @@ func (r *RootCmd) templateEdit() *serpent.Command {
|
||||
},
|
||||
{
|
||||
Flag: "require-active-version",
|
||||
Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.",
|
||||
Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. See https://coder.com/docs/templates/general-settings#require-automatic-updates-enterprise for more details.",
|
||||
Value: serpent.BoolOf(&requireActiveVersion),
|
||||
Default: "false",
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/archive"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
@@ -95,7 +96,7 @@ func TestTemplatePull_Stdout(t *testing.T) {
|
||||
|
||||
// Verify .zip format
|
||||
tarReader := tar.NewReader(bytes.NewReader(expected))
|
||||
expectedZip, err := coderd.CreateZipFromTar(tarReader)
|
||||
expectedZip, err := archive.CreateZipFromTar(tarReader, coderd.HTTPFileMaxBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root = clitest.New(t, "templates", "pull", "--zip", template.Name)
|
||||
|
||||
+25
-11
@@ -10,7 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/google/uuid"
|
||||
@@ -52,13 +51,21 @@ func (r *RootCmd) templatePush() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
name, err := uploadFlags.templateName(inv.Args)
|
||||
name, err := uploadFlags.templateName(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(name) > 32 {
|
||||
return xerrors.Errorf("Template name must be no more than 32 characters")
|
||||
err = codersdk.NameValid(name)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("template name %q is invalid: %w", name, err)
|
||||
}
|
||||
|
||||
if versionName != "" {
|
||||
err = codersdk.TemplateVersionNameValid(versionName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("template version name %q is invalid: %w", versionName, err)
|
||||
}
|
||||
}
|
||||
|
||||
var createTemplate bool
|
||||
@@ -80,7 +87,7 @@ func (r *RootCmd) templatePush() *serpent.Command {
|
||||
message := uploadFlags.templateMessage(inv)
|
||||
|
||||
var varsFiles []string
|
||||
if !uploadFlags.stdin() {
|
||||
if !uploadFlags.stdin(inv) {
|
||||
varsFiles, err = codersdk.DiscoverVarsFiles(uploadFlags.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -268,13 +275,19 @@ func (pf *templateUploadFlags) setWorkdir(wd string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (pf *templateUploadFlags) stdin() bool {
|
||||
return pf.directory == "-"
|
||||
func (pf *templateUploadFlags) stdin(inv *serpent.Invocation) (out bool) {
|
||||
defer func() {
|
||||
if out {
|
||||
inv.Logger.Info(inv.Context(), "uploading tar read from stdin")
|
||||
}
|
||||
}()
|
||||
// We let the directory override our isTTY check
|
||||
return pf.directory == "-" || (!isTTYIn(inv) && pf.directory == "")
|
||||
}
|
||||
|
||||
func (pf *templateUploadFlags) upload(inv *serpent.Invocation, client *codersdk.Client) (*codersdk.UploadResponse, error) {
|
||||
var content io.Reader
|
||||
if pf.stdin() {
|
||||
if pf.stdin(inv) {
|
||||
content = inv.Stdin
|
||||
} else {
|
||||
prettyDir := prettyDirectoryPath(pf.directory)
|
||||
@@ -310,7 +323,7 @@ func (pf *templateUploadFlags) upload(inv *serpent.Invocation, client *codersdk.
|
||||
}
|
||||
|
||||
func (pf *templateUploadFlags) checkForLockfile(inv *serpent.Invocation) error {
|
||||
if pf.stdin() || pf.ignoreLockfile {
|
||||
if pf.stdin(inv) || pf.ignoreLockfile {
|
||||
// Just assume there's a lockfile if reading from stdin.
|
||||
return nil
|
||||
}
|
||||
@@ -343,8 +356,9 @@ func (pf *templateUploadFlags) templateMessage(inv *serpent.Invocation) string {
|
||||
return "Uploaded from the CLI"
|
||||
}
|
||||
|
||||
func (pf *templateUploadFlags) templateName(args []string) (string, error) {
|
||||
if pf.stdin() {
|
||||
func (pf *templateUploadFlags) templateName(inv *serpent.Invocation) (string, error) {
|
||||
args := inv.Args
|
||||
if pf.stdin(inv) {
|
||||
// Can't infer name from directory if none provided.
|
||||
if len(args) == 0 {
|
||||
return "", xerrors.New("template name argument must be provided")
|
||||
|
||||
@@ -32,6 +32,7 @@ func (r *RootCmd) templateVersions() *serpent.Command {
|
||||
r.templateVersionsList(),
|
||||
r.archiveTemplateVersion(),
|
||||
r.unarchiveTemplateVersion(),
|
||||
r.templateVersionsPromote(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -169,3 +170,66 @@ func templateVersionsToRows(activeVersionID uuid.UUID, templateVersions ...coder
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func (r *RootCmd) templateVersionsPromote() *serpent.Command {
|
||||
var (
|
||||
templateName string
|
||||
templateVersionName string
|
||||
orgContext = NewOrganizationContext()
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "promote --template=<template_name> --template-version=<template_version_name>",
|
||||
Short: "Promote a template version to active.",
|
||||
Long: "Promote an existing template version to be the active version for the specified template.",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := client.TemplateByName(inv.Context(), organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
|
||||
version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersionName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version by name: %w", err)
|
||||
}
|
||||
|
||||
err = client.UpdateActiveTemplateVersion(inv.Context(), template.ID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: version.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update active template version: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Successfully promoted version %q to active for template %q\n", templateVersionName, templateName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_TEMPLATE_NAME",
|
||||
Description: "Specify the template name.",
|
||||
Required: true,
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
{
|
||||
Flag: "template-version",
|
||||
Description: "Specify the template version name to promote.",
|
||||
Env: "CODER_TEMPLATE_VERSION_NAME",
|
||||
Required: true,
|
||||
Value: serpent.StringOf(&templateVersionName),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
|
||||
@@ -38,3 +41,85 @@ func TestTemplateVersions(t *testing.T) {
|
||||
pty.ExpectMatch("Active")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTemplateVersionsPromote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PromoteVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a template with two versions
|
||||
version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)
|
||||
|
||||
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent(), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = template.ID
|
||||
ctvr.Name = "2.0.0"
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
|
||||
|
||||
// Ensure version1 is active
|
||||
updatedTemplate, err := client.Template(context.Background(), template.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, version1.ID, updatedTemplate.ActiveVersionID)
|
||||
|
||||
args := []string{
|
||||
"templates",
|
||||
"versions",
|
||||
"promote",
|
||||
"--template", template.Name,
|
||||
"--template-version", version2.Name,
|
||||
}
|
||||
|
||||
inv, root := clitest.New(t, args...)
|
||||
//nolint:gocritic // Creating a workspace for another user requires owner permissions.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- inv.Run()
|
||||
}()
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
|
||||
// Verify that version2 is now the active version
|
||||
updatedTemplate, err = client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, version2.ID, updatedTemplate.ActiveVersionID)
|
||||
})
|
||||
|
||||
t.Run("PromoteNonExistentVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "templates", "versions", "promote", "--template", template.Name, "--template-version", "non-existent-version")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "get template version by name")
|
||||
})
|
||||
|
||||
t.Run("PromoteVersionInvalidTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
inv, root := clitest.New(t, "templates", "versions", "promote", "--template", "non-existent-template", "--template-version", "some-version")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "get template by name")
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -31,6 +31,7 @@ SUBCOMMANDS:
|
||||
netcheck Print network debug information for DERP and STUN
|
||||
notifications Manage Coder notifications
|
||||
open Open a workspace
|
||||
organizations Organization related commands
|
||||
ping Ping a workspace
|
||||
port-forward Forward ports from a workspace to the local machine. For
|
||||
reverse port forwarding, use "coder ssh -R".
|
||||
|
||||
+5
-1
@@ -28,7 +28,8 @@ OPTIONS:
|
||||
|
||||
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
|
||||
Specify a file path with values for rich parameters defined in the
|
||||
template.
|
||||
template. The file should be in YAML format, containing key-value
|
||||
pairs for the parameters.
|
||||
|
||||
--start-at string, $CODER_WORKSPACE_START_AT
|
||||
Specify the workspace autostart schedule. Check coder schedule start
|
||||
@@ -41,6 +42,9 @@ OPTIONS:
|
||||
-t, --template string, $CODER_TEMPLATE_NAME
|
||||
Specify a template name.
|
||||
|
||||
--template-version string, $CODER_TEMPLATE_VERSION
|
||||
Specify a template version name.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user