Compare commits

...

558 Commits

Author SHA1 Message Date
Colin Adler 95e578ba10 fix: derp healthcheck test flake (#6982) 2023-04-03 11:36:26 -05:00
Kyle Carberry 861d4afdd8 fix: update tailscale to remove websocket logs (#6981) 2023-04-03 16:28:49 +00:00
Kyle Carberry bc18f6c113 fix: add CODER_AGENT_TAILNET_LISTEN_PORT for specifying a static tailnet port (#6980)
Fixes #5175.
2023-04-03 16:20:19 +00:00
Kyle Carberry 4ee01dc95c fix: remove orphan rbac warning message (#6979)
The request will fail if the user doesn't have permissions, so it seems
useless to output this.
2023-04-03 11:15:56 -05:00
Kyle Carberry 55c0b26977 fix: update status text for the workspace progress bar (#6977)
Fixes #4691.
2023-04-03 11:00:56 -05:00
dependabot[bot] 8e69f02695 chore: bump axios from 0.26.1 to 1.3.4 in /site (#6954)
Bumps [axios](https://github.com/axios/axios) from 0.26.1 to 1.3.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.26.1...v1.3.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-03 10:30:39 -05:00
Ben Potter ded931f0f6 docs: rename quickstart to platforms + Kubernetes guide (#6972)
* rename quickstart to platforms + Kubernetes guide

* fmt
2023-04-03 09:51:07 -05:00
Kyle Carberry 2d051094e7 fix: ignore deleted workspaces in global stats (#6973)
Fixes #6568.
2023-04-03 14:46:09 +00:00
Bruno Quaresma aa43f998d4 feat(site): Promote template version (#6929) 2023-04-03 14:27:17 +00:00
Steven Masley fab8da633b chore: Merge more rbac files (#6927)
* chore: Merge more rbac files

- Remove cache.go -> authz.go
- Remove query.go -> authz.go
- Remove role.go -> roles.go

* Order imports

* fmt
2023-04-03 09:05:06 -05:00
Ben Potter 333718d1fa docs: remove mention of support links (#6970)
* docs: remove mention of support links

* fmt
2023-04-03 13:18:28 +00:00
Marcin Tojek 633dfbdb2e feat: rich parameters: introduce display_name (#6919)
* model

* DB

* fix: DisplayName

* proto

* Proto

* Update go dep

* fixme

* fix format

* config

* fmt

* fix

* Fix

* fix

* chore(UI): redirecting from workspace page if 404 (#6880)

* model

* CLI: Display parameter

* fix

* update dep

* fix

* fix

* fix

* UI changes

* fmt

---------

Co-authored-by: Kira Pilot <kira@coder.com>
2023-04-03 14:37:47 +02:00
Mathias Fredriksson d9d44c1188 ci: Print go test stats (#6855)
Fixes #6676
2023-04-03 11:07:25 +00:00
Colin Adler 7738274b3e feat(coderd): add DERP healthcheck (#6936) 2023-04-03 06:28:42 +00:00
Colin Adler f4d16a1ae5 fix: add span.SetStatus to codersdk.Request (#6964) 2023-04-03 05:52:42 +00:00
dependabot[bot] 47afafa4d4 chore: bump github.com/klauspost/compress from 1.15.15 to 1.16.3 (#6948)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-03 00:35:35 -05:00
Colin Adler a29fc7dd6f chore: update otel to v1.14.0 (#6963) 2023-04-03 00:31:39 -05:00
dependabot[bot] f6f927e44f chore: bump github.com/go-playground/validator/v10 from 10.11.0 to 10.12.0 (#6950)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-03 04:59:49 +00:00
Colin Adler 8683169e71 fix: agent metadata flake (#6962) 2023-04-03 04:45:23 +00:00
dependabot[bot] d2bfa2b9a0 chore: bump aquasecurity/trivy-action from 0.9.1 to 0.9.2 (#6945)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-02 23:37:06 -05:00
dependabot[bot] 390f29cf8c chore: bump actions/stale from 7.0.0 to 8.0.0 (#6944)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-02 23:32:22 -05:00
Eric Paulsen caec0b8aae rm: mac from docker quickstart (#6958) 2023-04-01 22:31:53 -05:00
dependabot[bot] 31690c4b3d chore: bump @xstate/react from 3.0.1 to 3.2.1 in /site (#6955)
Bumps [@xstate/react](https://github.com/statelyai/xstate) from 3.0.1 to 3.2.1.
- [Release notes](https://github.com/statelyai/xstate/releases)
- [Commits](https://github.com/statelyai/xstate/compare/@xstate/react@3.0.1...@xstate/react@3.2.1)

---
updated-dependencies:
- dependency-name: "@xstate/react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 16:37:57 -05:00
Ammar Bandukwala 512fdbf634 chore: debounce agent watch-metadata stream (#6940) 2023-04-01 16:36:21 -05:00
dependabot[bot] e40b0778e9 chore: bump crate-ci/typos from 1.13.14 to 1.14.3 (#6942)
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.13.14 to 1.14.3.
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/v1.13.14...v1.14.3)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 16:35:19 -05:00
dependabot[bot] e60460b120 chore: bump jaxxstorm/action-install-gh-release from 1.9.0 to 1.10.0 (#6943)
Bumps [jaxxstorm/action-install-gh-release](https://github.com/jaxxstorm/action-install-gh-release) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/jaxxstorm/action-install-gh-release/releases)
- [Commits](https://github.com/jaxxstorm/action-install-gh-release/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: jaxxstorm/action-install-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 16:35:11 -05:00
dependabot[bot] 5200591264 chore: bump actions/setup-go from 3 to 4 (#6946)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 16:34:58 -05:00
dependabot[bot] 87d64baf7a chore: bump golang.org/x/crypto from 0.6.0 to 0.7.0 (#6949)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/golang/crypto/releases)
- [Commits](https://github.com/golang/crypto/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 16:34:50 -05:00
Ammar Bandukwala 34debbf837 fix(agent): prevent goroutine pile up in reportMetadataLoop (#6957) 2023-04-01 16:34:42 -05:00
Ammar Bandukwala ccadd0f286 docs: tweak some words and fix a typo in agent-metadata.md 2023-03-31 23:20:30 +00:00
Kyle Carberry 84956c16cc fix: reduce spacing when agent metadata doesn't exist (#6937) 2023-03-31 22:08:42 +00:00
Ammar Bandukwala ca4fa81570 feat: add agent metadata (#6614) 2023-03-31 15:26:19 -05:00
Ammar Bandukwala c191692751 chore(cli): send help to stdout (#6865)
Minimizes pesky `2>&1` when working with help.
2023-03-31 18:51:55 +00:00
Ammar Bandukwala c2a96bdc7c fix: compilation failure with (*OptionSet).SetDefaults
This slipped through CI due to a merge race.
2023-03-31 18:02:55 +00:00
Ammar Bandukwala 599699b3a9 fix: truly allow overridding default string array (#6874) 2023-03-31 12:12:03 -05:00
Cian Johnston 96ff400587 chore(clibase): add some more tests for parsing options from env (#6930) 2023-03-31 17:21:56 +01:00
Steven Masley ce51435507 chore: Merge some rbac files, move some functions around (#6916)
* chore: Merge some rbac files, move some functions around

* Fix imports
2023-03-31 09:46:38 -05:00
Steven Masley 27e17ff2c3 chore: Remove unused Experimental field (#6924) 2023-03-31 09:44:29 -05:00
Kyle Carberry cd807bc0c8 fix: delete old addresses from the active derp mesh mapping (#6926) 2023-03-31 14:25:01 +00:00
Cian Johnston 334d9820fa fix(coderd): update provisionderd authz policy to allow updating user data (#6925) 2023-03-31 15:11:07 +01:00
Bruno Quaresma 90e2bab078 feat(site): Display workspace build error + option to retry in debug mode (#6903) 2023-03-31 14:01:12 +00:00
Steven Masley 901045a95f fix: FE show correct config-ssh prefix (#6904)
* fix: Push correct ssh prefix to FE
2023-03-31 08:48:44 -05:00
Kira Pilot a364318462 chore(UI): redirecting from workspace page if 404 (#6880) 2023-03-31 06:31:48 -07:00
Marcin Tojek 56f00a82e1 chore: update sqlc to v1.17.2 (#6920)
* WIP

* chore: Update sqlc to v1.17.2
2023-03-31 14:05:20 +02:00
Marcin Tojek 2612e32bac chore: image for support links (#6902) 2023-03-30 18:07:27 +00:00
Kira Pilot 3b52d4f336 chore(UI): remove template link from workspaces page row (#6882)
* chore(UI): remove template link from workspaces page row

* remove stack and span as per PR comment
2023-03-30 10:32:23 -07:00
Kyle Carberry b5f5740d0b chore: ensure agent conn routine is closed before exit (#6900)
This caused a leak in `main`!
2023-03-30 17:20:14 +00:00
Kyle Carberry e496bdb687 chore: update slog to fix local leak with http.Transport (#6899) 2023-03-30 12:13:15 -05:00
Kyle Carberry a63c97b8de chore: wrap audit logs in a mutex to fix data race (#6898)
This was seen in `main`!
2023-03-30 12:13:03 -05:00
Kyle Carberry 5780006adb chore: use http-swagger/v2 for go:embed to reduce binary size (#6897)
I'm testing how much this will reduce the size, but it should
be by a few MB!
2023-03-30 16:46:58 +00:00
Cian Johnston afcc179244 chore: fix duplicated sentence in docs/auth.md (#6895) 2023-03-30 16:29:57 +00:00
Kyle Carberry 8f55f5c28b chore: ensure go.mod has proper references to forked deps (#6893)
There were a few that have been merged upstream!
2023-03-30 11:19:11 -05:00
Ben Potter 5953a46785 docs: Open in Coder (#6859)
* docs: git auth via template

* add page

* docs: Open in Coder

* fmt
2023-03-30 16:12:47 +00:00
Kyle Carberry a6b7e8c43a chore: use an older version of fastclock to resolve leak (#6892) 2023-03-30 10:58:56 -05:00
Kyle Carberry 04e404e448 chore: dial the remote socket continually until connect (#6891)
It's possible that the command starts but the socket isn't ready
even when the file exists.
2023-03-30 15:36:23 +00:00
Kyle Carberry 5686fc9983 chore: skip chdir template push test on windows (#6890)
See https://github.com/coder/coder/actions/runs/4565958214/jobs/8057842941
2023-03-30 10:21:13 -05:00
Kyle Carberry 401b9276ae chore: clone styles to prevent race (#6888)
See https://github.com/coder/coder/actions/runs/4565541977/jobs/8056830691?pr=6885
2023-03-30 10:18:24 -05:00
Kyle Carberry 5c1dc1b7fe fix: iterate through all workspace updates on logs overflow (#6885)
This was causing some flakes!
2023-03-30 10:05:45 -05:00
Kyle Carberry e470162305 chore: move away from set-output in GitHub Actions (#6884)
This is deprecated! See:
https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
2023-03-30 09:58:56 -05:00
Kyle Carberry 1f600fc526 chore: use defer instead of t.Cleanup for chdir (#6887)
This _might_ resolve the races in `TestTemplatePush`.
2023-03-30 09:48:03 -05:00
Bruno Quaresma b26f30688f feat(site): Duplicate template (#6853) 2023-03-30 11:42:29 -03:00
Kyle Carberry 6378294071 chore: upgrade from deprecated protobuf import (#6883)
This was displaying a warning when running `go get ./...`.
2023-03-30 14:17:44 +00:00
Marcin Tojek 0ba200c2a1 feat: Enable workspace debug logging (#6838)
* feat: Enable workspace debug logging

* Fix

* Fix

* Fix

* fix

* fix

* Enable RBAC

* unit tests

* Fix

* fix

* fix

* fix

* more tests

* fix: workspacebuild_test use roles

* fix: swagger comment

* fix: ctx.Done

* fix: address PR comments

* break loop
2023-03-30 16:00:33 +02:00
Dean Sheather 665b84de0d feat: use app tickets for web terminal (#6628) 2023-03-30 23:24:51 +10:00
Muhammad Atif Ali a07209efa1 fix: very small typo (#6877) 2023-03-30 07:56:19 -05:00
Bruno Quaresma 7d7aa789b3 fix(site): Only patch version name if name is changed (#6878) 2023-03-30 12:22:55 +00:00
Bruno Quaresma d8762c676f feat(site): Show main.tf file first on template files page (#6854) 2023-03-30 11:33:42 +00:00
Marcin Tojek b120247213 fix: extend regex for template version name (#6876) 2023-03-30 13:27:58 +02:00
Cian Johnston 563c3ade06 feat: allow configuring OIDC email claim and OIDC auth url parameters (#6867)
This commit:

- Allows configuring the OIDC claim Coder uses for email addresses (by default, this is still email)
- Allows customising the parameters sent to the upstream identity provider when requesting a token. This is still access_type=offline by default.
- Updates documentation related to the above.
2023-03-30 09:36:57 +01:00
Ammar Bandukwala 6981f89cd8 Revert "fix: allow overridding default string array (#6873)"
This reverts commit 58d650c2bb.
2023-03-29 20:24:07 -05:00
Ammar Bandukwala 58d650c2bb fix: allow overridding default string array (#6873)
* fix: allow overridding default string array

* Cleanup code

* fixup! Cleanup code

* fixup! Cleanup code

* fixup! Cleanup code

* fixup! Cleanup code
2023-03-30 01:09:20 +00:00
dependabot[bot] 1c7adc0ebd chore: bump github.com/fatih/color from 1.14.1 to 1.15.0 (#6868)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-29 21:42:35 +00:00
Rodrigo Maia 3d91fe8895 fix(site): fix redirection to login after logout/change password (#6870)
* fix(site): fix redirection to login after logout/change password

* chore: add login verification assert

* prettier
2023-03-29 21:39:56 +00:00
Steven Masley 90da09bc2c chore: Make deployment admin page show better durations (#6856)
* chore: Make deployment admin page show better durations

Also fix group mappings
2023-03-29 16:26:20 -05:00
Colin Adler 872037bf85 feat: allow http2 in coder server (#6871) 2023-03-29 14:45:57 -05:00
Bruno Quaresma 175dde1c52 chore(site): Try to fix flake test (#6848) 2023-03-29 16:10:04 -03:00
Mathias Fredriksson 90d18dd2e5 fix(agent): Close stdin and stdout separately to fix pty output loss (#6862)
Fixes #6656
Closes #6840
2023-03-29 21:58:38 +03:00
dependabot[bot] 349bfad2e9 chore: bump github.com/opencontainers/runc from 1.1.2 to 1.1.5 (#6864)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-29 17:46:54 +00:00
Ben Potter 311327cb11 docs: git auth via template (#6850) 2023-03-28 17:11:30 -05:00
Steven Masley a8346bd8ea feat: Allow unsetting ssh config options from deployment (#6847)
This allows deleting ssh config options
2023-03-28 11:06:42 -05:00
Ammar Bandukwala 1176256a44 feat: improve CLI error messages (#6778) 2023-03-28 16:03:34 +00:00
Cian Johnston b38d1ed4a5 Revert "fix(UI): redirect if user is not permissioned to see workspace (#6786)" (#6836)
This reverts commit fc21e159b8.
2023-03-28 09:38:39 +00:00
Mathias Fredriksson 891bbda995 fix(agent): More protection for lost output of SSH PTY commands (#6833)
Fixes #6656 (part 2)
2023-03-28 09:11:15 +00:00
Ammar Bandukwala 164528176a fix(clibase): allow empty values to unset defaults (#6832) 2023-03-28 01:58:06 +00:00
Ammar Bandukwala 773580c7c9 fix: correct minor formatting issues in CLI (#6813)
* fix: remove excess newlines from server startup

* Don't log benign closed pipe errors
2023-03-28 01:01:25 +00:00
Ammar Bandukwala 42b3d90221 chore: rename "InsertOrUpdate" to Upsert around the codebase (#6823)
* chore: rename "InsertOrUpdate" to Upsert around the codebase

The shorter name uses up less line width, is easier to read
and is used more often.

* make gen
2023-03-28 00:55:10 +00:00
Ammar Bandukwala f88f273cd6 fix: resolve template name from working directory "." (#6822) 2023-03-27 22:58:20 +00:00
Kyle Carberry a2d3635207 fix: update tailscale to resolve h2 vs http/1.1 (#6827)
DERP will always fallback to WebSockets if h2 is chosen now!
2023-03-27 21:05:10 +00:00
Kyle Carberry 5e01e6e448 fix: check if logs are completed before publishing (#6824) 2023-03-27 15:50:53 -05:00
Muhammad Atif Ali 48f9521fcb fix: fix PATH of flyctl cli in fly-docker-image template (#6772)
* Update main.tf

Upgrade coder provider

* fix path in startup_script

* Revert "Update main.tf"

This reverts commit 5037f87a36.
2023-03-27 20:04:03 +00:00
Kyle Carberry ad0c0df104 chore: update tailscale to fix http2 upgrade (#6811)
See https://github.com/coder/tailscale/pull/15
2023-03-27 14:22:11 -05:00
Kyle Carberry ffb4cd5962 chore: fix race when expecting parameters (#6814)
See https://github.com/coder/coder/actions/runs/4532378002/jobs/7983840014
2023-03-27 14:18:33 -05:00
Bruno Quaresma dd4e1f74ff feat(site): Ask for version name and if it is active when publishing a new version on editor (#6756) 2023-03-27 17:26:57 +00:00
Ammar Bandukwala b439c3e167 fix: permit SSH by default when startup script fails (#6798) 2023-03-27 14:59:58 +00:00
Steven Masley 7fa5afa268 fix: Users that can update a template can also read the file (#6776)
* fix: Users that can update a template can also read the file

This currently has a strange RBAC story. An issue will be filed
to streamline this.
This is a hotfix to resolve current functionality

* Only showsource code tab if the user has permission to edit the template


---------

Co-authored-by: Bruno Quaresma <bruno_nonato_quaresma@hotmail.com>
2023-03-27 09:21:41 -05:00
Kira Pilot fc21e159b8 fix(UI): redirect if user is not permissioned to see workspace (#6786)
* fix(UI): redirect if user is not permissioned to see workspace

* fix tests
2023-03-27 06:25:31 -07:00
Kira Pilot 08afe3cfad chore(UI): remove private icon from apps in dashboard (#6801)
* chore(UI): remove private icon from apps in dashboard

* clean up redudant snapshots
2023-03-27 06:24:42 -07:00
Muhammad Atif Ali 0b22c88538 fix(docs): fix broken links (#6796)
* Update change-management.md to fix broken links

* Update offline.md

* Update docker-in-docker.md

* fmt
2023-03-27 09:21:47 -04:00
Marcin Tojek 8187992e7f fix: Validate template version name (#6804)
* WIP

* Update

* Validation
2023-03-27 13:54:01 +02:00
Ammar Bandukwala e0cc4ee7f8 fix: correct english in CreateWorkspacePage (#6797) 2023-03-27 08:49:18 -03:00
Bruno Quaresma e3a965bcc9 fix(site): Add page header into permissions page (#6760) 2023-03-27 11:32:20 +00:00
Eric Paulsen b287ec5eec docs: clarify mac docker install (#6788)
* clarify mac docker install

* point MacOS users to standalone binary

* macOS
2023-03-26 12:39:20 +00:00
Ammar Bandukwala 6c0f37c28e fix: probably fix lipgloss race condition (#6784) 2023-03-24 21:24:51 +02:00
Mathias Fredriksson 76bdde7f1b fix(agent): Prevent SSH TTYs from losing command output on exit (#6777) 2023-03-24 18:23:41 +00:00
Kyle Carberry d7d210de36 Revert "chore: update tailscale to fix http2 upgrade (#6761)" (#6779)
This reverts commit 622fc6d9c2.
2023-03-24 12:40:05 -05:00
Kyle Carberry 622fc6d9c2 chore: update tailscale to fix http2 upgrade (#6761)
See https://github.com/coder/tailscale/pull/15
2023-03-24 11:20:21 -05:00
Marcin Tojek c9cbc63cd4 feat: Fine-tune logs presentation (#6771)
* Process debug mode logs

* Debug logs are grey

* Fix
2023-03-24 13:29:18 +01:00
Marcin Tojek c7fb5f960c feat: preserve original order of rich parameters (#6747)
* WIP

* orderedParameters

* fix

* WIP

* TestS
2023-03-24 09:37:27 +01:00
Ammar Bandukwala 9822745365 fix: accept CODER_AGENT_TOKEN (#6765) 2023-03-23 18:38:15 -05:00
Ammar Bandukwala 2bd6d2908e feat: convert entire CLI to clibase (#6491)
I'm sorry.
2023-03-23 17:42:20 -05:00
Bruno Quaresma b71b8daa21 fix(site): Add helper text and prevent undefined when deleting empty value (#6757) 2023-03-23 20:44:40 +00:00
Kyle Carberry 2383f64d89 fix: add dbauthz for streaming startup logs (#6758)
This was causing logs to end early!
2023-03-23 15:02:29 -05:00
Bruno Quaresma 88e24db643 refactor(site): Group template permissions, settings and variables under a settings layout (#6737) 2023-03-23 16:43:12 -03:00
Kyle Carberry cb7375450b feat: add startup script logs to the ui (#6558)
* Add startup script logs to the database

* Add coderd endpoints for startup script logs

* Push startup script logs from agent

* Pull startup script logs on frontend

* Rename queries

* Add constraint

* Start creating log sending loop

* Add log sending to the agent

* Add tests for streaming logs

* Shorten notify channel name

* Add FE

* Improve bulk log performance

* Finish UI display

* Fix startup log visibility

* Add warning for overflow

* Fix agent queue logs overflow

* Display staartup logs in a virtual DOM for performance

* Fix agent queue with loads of logs

* Fix authorize test

* Remove faulty test

* Fix startup and shutdown reporting error

* Fix gen

* Fix comments

* Periodically purge old database entries

* Add test fixture for migration

* Add Storybook

* Check if there are logs when displaying features

* Fix startup component overflow gap

* Fix startup log wrapping

---------

Co-authored-by: Asher <ash@coder.com>
2023-03-23 14:09:13 -05:00
Steven Masley a6fa8cac58 chore: add typescript api for ssh config (#6741)
* chore: add typescript api for ssh config
2023-03-23 17:46:39 +00:00
Bruno Quaresma 8857971552 feat(coder): Add PATCH /templateversions/:templateversion endpoint (#6698) 2023-03-23 13:26:50 -03:00
Kyle Carberry ed9a3b9251 fix: use a background context when piping derp connections (#6750)
This was causing boatloads of connects to reestablish every time...

See https://github.com/coder/coder/issues/6746
2023-03-23 09:54:07 -05:00
Eric Paulsen 7949db8e03 docs: add GHE token & auth URLs (#6751)
* docs: add GHE token & auth URLs

* cleanup
2023-03-23 09:53:42 -05:00
Bruno Quaresma eaacc26da7 fix(site): Fix missing parameters detection on update workspace (#6740) 2023-03-23 08:05:19 +01:00
Kyle Carberry dab4a0e6ef fix: add ResourceUserData to provisionerd to allow git auth (#6743)
This was breaking passing a git auth token through!
2023-03-22 17:23:49 -05:00
Kira Pilot 25e92fd2f4 fix(audit): audit login/logout for new 3rd-party auth (#6733)
* fix(audit): audit login/logout for new 3rd-party auth

* no longer auditing unknown users
2023-03-22 12:52:13 -07:00
Kyle Carberry df31636e72 feat: pass access_token to coder_git_auth resource (#6713)
This allows template authors to leverage git auth to perform
custom actions, like clone repositories.
2023-03-22 19:37:08 +00:00
Colin Adler 79ae7cd639 chore: add artifacthub-repo.yml (#6739) 2023-03-22 19:09:40 +00:00
Colin Adler c1e1c47c45 chore: bump github.com/u-root/u-root (#6738) 2023-03-22 13:52:50 -05:00
Eric Paulsen c71fa498b5 feat: podAnnotations (#6703)
* feat: podAnnotations

* rm: test values

* feedback

* fix: unknown revision
2023-03-22 14:35:24 -04:00
Steven Masley 250ee17933 fix: Page offset on workspace pagination was incorrect (#6693)
* fix: Page offset on workspace pagination was incorrect
2023-03-22 18:17:49 +00:00
Kira Pilot 1ccbd54ea2 chore(UI): update autocomplete no options text (#6735) 2023-03-22 11:14:07 -07:00
Marcin Tojek f1d7809ef0 chore: update terraform-provider-coder to v0.6.21 (#6736) 2023-03-22 18:04:56 +00:00
Kyle Carberry ad9c9b468f fix: block updating mutable parameters (#6717) 2023-03-22 11:05:04 -05:00
Steven Masley ab764db8c8 chore: dbgen passing nil slices to postgres is not valid (#6714) 2023-03-22 09:10:49 -05:00
Dean Sheather 5460ab4ba6 chore: switch to new wgtunnel via tunnelsdk (#6489) 2023-03-22 13:13:48 +00:00
Marcin Tojek e85a17b0c8 docs: describe list of strings (#6719) 2023-03-22 07:45:26 -05:00
Kyle Carberry 38d278ac46 fix: use the default value for a rich parameter if unset (#6715)
This fixes an error thrown on the workspace settings page when
a new parameter is added and the workspace hasn't been built yet.
2023-03-22 00:02:55 +00:00
Kyle Carberry abe1e89f80 chore: increase parallelism of TestWorkspaceQuota (#6710)
This does a lot of build operations, so having multiple provisioner
daemons is great.

We were actually approaching the ceiling here for test duration!
2023-03-21 22:44:01 +00:00
Bruno Quaresma 5cbe360176 fix(site): Fix CSP directives for monaco (#6709) 2023-03-21 12:51:02 -07:00
Colin Adler 00860cf1c8 feat: add group mapping option for group sync (#6705)
* feat: add group mapping option for group sync

* fixup! feat: add group mapping option for group sync
2023-03-21 14:25:45 -05:00
Bruno Quaresma 120bc4b750 refactor(site): Only show status after first edition (#6701) 2023-03-21 16:23:33 -03:00
Kyle Carberry 7e854adbb3 fix: poll audit logs to ensure write is last (#6708) 2023-03-21 19:19:09 +00:00
Kyle Carberry 71eecb3515 chore: embed build info in the html to reduce requests (#6605)
This will reduce a request on every page load of Coder!
2023-03-21 19:04:54 +00:00
Ben Potter 74be9c6c55 fix: dallas region in fly.io template (#6700) 2023-03-21 14:04:04 -05:00
Josh Vawdrey 97f77c4507 feat: allow DERP headers to be set (#6572)
* feat: allow DERP headers to be set

* chore: remove custom flag

* Clone DERP header on client create

* Adjust to use interface to cast headers

---------

Co-authored-by: Kyle Carberry <kyle@carberry.com>
2023-03-21 18:43:20 +00:00
Eric Paulsen d8aee26776 docs: clean up OIDC duplicates (#6583)
* docs: clean up OIDC duplicates

* make fmt

* add: upgrade command
2023-03-21 13:40:20 -05:00
Kyle Carberry e1c755be81 chore: remove fast metric cache interval for apps tests (#6702)
This wasn't helping CI run fast, that's for sure!
2023-03-21 18:13:34 +00:00
Kyle Carberry aaa3b31a0b chore: add echo helper to create an agent with token (#6576)
This should reduce some LOC and duplication in tests!
2023-03-21 18:03:38 +00:00
Kyle Carberry d05b48267a fix: update tailscale to resolve websocket stun fallback (#6696)
See https://github.com/coder/tailscale/pull/14
2023-03-21 12:37:34 -05:00
Bruno Quaresma 29d71bb3dd feat(site): Add source code tab on template page, group buttons and add edit file option (#6681) 2023-03-21 16:52:42 +00:00
Bruno Quaresma f97c22540a fix: Only show mutable parameters on workspace settings form (#6690) 2023-03-21 13:47:54 -03:00
Kyle Carberry 247470b1d6 fix: increase timeout of CreateTemplatePage initial render (#6694)
This might fix some test flakes we've been seeing!
2023-03-21 13:24:07 -03:00
Kyle Carberry 1b35ac80f2 fix: ensure agent DisconnectedAt is greater than or equal LastConnectedAt (#6692)
See https://github.com/coder/coder/actions/runs/4471502401/jobs/7856475920
2023-03-21 11:08:39 -05:00
Marcin Tojek fce8a4adf0 feat: preserve order of rich parameters (#6689)
* WIP

* TDD

* Implement

* WIP
2023-03-21 16:28:17 +01:00
Steven Masley 2321160c62 feat: Dbauthz is now default, remove out of experimental (#6650)
* feat: dbauthz always on, out of experimental
* Add ability to do rbac checks in unit tests
* Remove AuthorizeAllEndpoints
* Remove duplicate rbac checks
2023-03-21 09:10:22 -05:00
Dean Sheather 8aae0b64d3 chore: avoid logging http.ErrAbortHandler panics (#6686) 2023-03-21 03:51:21 +00:00
Ben Potter 4bf012cefb fix: typo in kubernetes template (#6683) 2023-03-20 22:39:45 +00:00
Steven Masley 65945aef16 chore: Return copied templates to prevent reference issues (#6679) 2023-03-20 13:13:21 -05:00
Bruno Quaresma cb846bab46 fix(site): Show job error on updating template variables (#6674) 2023-03-20 12:51:59 -03:00
Marcin Tojek de83723310 feat: show Terraform error details (#6643) 2023-03-20 13:23:40 +00:00
Bruno Quaresma a4d86e9d78 fix(site): Don't handle 304 as error (#6655) 2023-03-20 10:08:06 -03:00
Cian Johnston 331a49bf75 fix: dbauthz: fix RBAC call for GetTemplateVersionVariables (#6670)
In GetTemplateVersionVariables we were effectively asking the provisionerd role to call rbac.ActionCreate on rbac.ResourceTemplate, which will never work. Updated this to be rbac.ActionRead instead.
2023-03-20 10:22:16 +00:00
Eric Paulsen 39510f4163 docs: add openshift install (#6165)
* docs: add openshift install

* make: fmt

* Apply suggestions from code review

Update securityContext: set project-specific uid/gid, set readOnlyRootFilesystem=false

* add steps for creating compatible image and template

* Update docs/install/openshift.md

* make fmt

* docs: make changes on feedback

* combine helm steps

---------

Co-authored-by: Cian Johnston <cian@coder.com>
2023-03-20 03:48:53 -05:00
Eric Paulsen fadeb2ba3a docs: add k8s & aws to offline Dockerfile (#6663) 2023-03-20 03:47:36 -05:00
Muhammad Atif Ali 856a8028a5 chore: update fly-docker-image example template (#6661)
* update fly-docker-image example template

* Add unites for volume storage
2023-03-19 14:55:11 -05:00
Kyle Carberry c3fb1b325f feat: add owner_oidc_access_token to coder_workspace data source (#6042)
See the discussion in Discord here:
https://discord.com/channels/747933592273027093/1071182088490987542/1071182088490987542

Related provider PR: coder/terraform-provider-coder#91
2023-03-17 15:25:08 -05:00
Bruno Quaresma ca067cf004 feat(site): Support list(string) rich parameter field (#6653) 2023-03-17 18:53:11 +00:00
Kira Pilot 090e37fc46 feat(audit): auditing token addition and removal (#6649)
* auditing tokens

* adding diffs for token auditing

* added test

* generating docs

* auditing owner field
2023-03-17 10:41:44 -07:00
Kira Pilot 5b07f1e2a3 feat(tokens): improve delete confirmation dialog (#6651) 2023-03-17 10:29:51 -07:00
Marcin Tojek db40c29f26 fix: CLI do not ignore autostop (#6647)
* fix: CLI do not ignore autostop

* make gen
2023-03-17 18:14:46 +01:00
Muhammad Atif Ali e6d52b07b7 docs: add template to provision docker image based workspaces on fly.io (#6526)
* add fly.io example

* fix: `fly_volume` does not allow using - in name.

fix: `fly_volume` does not allow using - in the name.

* fix: provider versions  and settings

* fix: valid `fly_app` name

* chore: ipv6 is not used

* fix: names

strangely `fly_volume` does not allow `-` and `fly_app` does not allow `_`.

* chore: update max RAM

* add fly-auth-api managed variables

* Update README.md

* improve setup flow

- user is not prompted in UI for default values
- org slug is best fetched via CLI

* add metadata

* add to starter templates

---------

Co-authored-by: Ben <me@bpmct.net>
2023-03-17 11:46:44 -05:00
Steven Masley e55d921537 chore: Typescript generator TODOs resolved, adding explainations (#6633)
* chore: Explain usage of eslint comments

* Conform comment

* Fix wording

* Linting
2023-03-17 09:28:16 -05:00
Bruno Quaresma b1c1e1a8a6 refactor(site): Remove wave animation from skeleton (#6644) 2023-03-17 10:44:17 -03:00
Bruno Quaresma 01a6af98b4 fix(site): Do not require immutable parameters (#6637) 2023-03-17 10:09:10 -03:00
Dean Sheather 446bd30c32 chore: regenerate helm golden tests (#6642) 2023-03-17 12:56:40 +01:00
Michael H f59bf732c9 feat: Add ability to configure coder container lifecycle hooks in helm chart (#6432) 2023-03-17 00:55:24 +00:00
Colin Adler 1c05b46b02 chore: add debug logs for OIDC responses (#6641) 2023-03-16 19:33:45 -05:00
Kyle Carberry ffca3a5fb3 fix: remove noop templates plan command (#6617)
Co-authored-by: Colin Adler <colin1adler@gmail.com>
2023-03-16 21:28:22 +00:00
Cian Johnston a1d2c057a2 chore(lima): increase random password length from 8 to 12 (#6639) 2023-03-16 18:42:30 +00:00
Steven Masley fe247c86eb feat: Add deployment side config-ssh options (#6613)
* feat: Allow setting deployment wide ssh config settings
* feat: config-ssh respects deployment ssh config
* The '.' is now configurable
* Move buildinfo into deployment.go
2023-03-16 13:03:37 -05:00
Ben Potter 25e8abd63e chore: rewrite provisioner docs (#6445)
* chore: rewrite provisioner docs

* add checkout
2023-03-16 12:51:39 -05:00
Kyle Carberry b693b9f599 chore: fix workspace audit log flake (#6494)
This happened because sometimes a build would be queued
and completed intermittently in CI.

See https://github.com/coder/coder/actions/runs/4358121985/jobs/7618290591
2023-03-16 12:47:54 -05:00
Marcin Tojek 1f9ae15409 feat: CLI use multiselect for list(string) (#6631)
* feat: CLI use multiselect for list(string)

* fix

* select ui tests

* cli test

* Fix

* Fix
2023-03-16 17:17:48 +01:00
Bruno Quaresma 95177ad0e5 refactor(site): Remove change version from the UI (#6621) 2023-03-16 13:07:33 -03:00
Bruno Quaresma ab90651a7e refactor(site): Add skeletons for table loading state (#6626) 2023-03-16 13:06:15 -03:00
Kira Pilot 811a69f371 feat(site): add ability to create tokens from account tokens page (#6608)
* add token actions

* added basic token form

* removed token switch

* refined date field

* limiting lifetime days to maxTokenLifetime

* broke apart files

* added loader and error

* fixed form layout

* added some unit tests

* fixed be tests

* no authorize check
2023-03-16 08:25:08 -07:00
Bruno Quaresma af618477bd feat(site): Show stop button when workspace is failed (#6622) 2023-03-16 14:34:55 +00:00
Bruno Quaresma 107ae55642 feat(site): Add workspace settings page (#6612) 2023-03-16 11:00:19 -03:00
Bruno Quaresma 854bcce5e0 fix(site): Fix filter hover (#6624) 2023-03-16 06:31:41 -07:00
Marcin Tojek a7c734c60b feat: support list(string) as coder_parameter (#6618)
* feat: support list(string) as coder_parameter

* Fix
2023-03-16 11:07:10 +01:00
Benjamin Sejas 7076dee522 feat(agent): Add SSH max timeout option for coder agent (#6596)
* feat(agent): Add SSH max timeout option for coder agent

* Fix lint and update test golden snapshot
2023-03-15 09:08:50 -05:00
Eric Paulsen 2f3848e9b2 rm: aws windows count (#6485)
Co-authored-by: Ben Potter <ben@coder.com>
2023-03-15 13:53:32 +00:00
Muhammad Atif Ali c5475912c9 refactor(docs): refactoring to remove dublication (#6297) 2023-03-15 13:25:51 +00:00
Muhammad Atif Ali a0e096bcfe chore: update templates to use rich parameters (#6397) 2023-03-15 13:11:52 +00:00
Bruno Quaresma 3cf235c564 feat(site): Ask for parameter values when updating a workspace (#6586) 2023-03-14 13:20:49 -03:00
Kyle Carberry f91b3acf93 fix: group routine workspace agent stats by id (#6601)
Before this was creating separate rows for distinct stat entries, which
resulted in significantly more data being sent to telemetry.
2023-03-14 10:52:03 -05:00
Kyle Carberry 17bc5794d4 fix: direct embedded derp traffic directly to the server (#6595)
Prior to this change, DERP traffic would route from `coderd` to the
`CODER_ACCESS_URL` to reach the internal DERP server, which may have
resulted in slower connections due to proxying, or the failure of
web traffic entirely.

If your Coder deployment has a proxy in front of it, your traffic through
web terminals, apps, and port-forwarding is about to get a lot faster!
2023-03-14 14:46:47 +00:00
Bruno Quaresma e3768495e4 docs: Add more info about slow tests in the FE (#6584) 2023-03-14 06:54:17 -07:00
Bruno Quaresma b806d1cfcf fix(site): Display delete template error from the API (#6589) 2023-03-14 10:44:27 -03:00
Mathias Fredriksson 348530000f fix(coderd): Ensure agent disconnect happens after timeout (#6600)
Fixes #6598
2023-03-14 13:14:47 +00:00
Marcin Tojek 7587850a1c feat: import value from legacy variable to build parameter (#6556) 2023-03-14 12:02:44 +00:00
Marcin Tojek bc26c4a27f chore: skip scaletest/reconnectingpty (#6599) 2023-03-14 11:37:31 +00:00
Dean Sheather aafd2803bb chore: update dogfood IP for Sydney (#6597) 2023-03-14 15:29:52 +10:00
Kyle Carberry 35df1b10d0 feat: add workspace agent stat reporting to telemetry (#6577)
This aggregates stats periodically and sends them by agent ID to
our telemetry server. It should help us identify which editors are
primarily in use.
2023-03-13 14:16:54 -05:00
Bruno Quaresma 813b54942f chore(site): Make FE tests faster (#6543) 2023-03-13 13:35:09 -03:00
Cian Johnston 9b2abf0952 chore(helm): add unit tests for helm chart (#6557)
This PR adds a minimum set of Helm tests for the Helm chart.
It's heavily based on the approach in [1], but uses a golden-files-based approach instead.
It also runs helm template directly instead of importing the entire Kubernetes API.
Golden files can be updated by running go test ./helm/tests -update or by running make update-golden-files.

[1] https://github.com/coder/enterprise-helm

Fixes #6552
2023-03-13 13:48:44 +00:00
Mathias Fredriksson 179d9e0d24 fix(coderd): Detect agent disconnect via inactivity (#6528)
Fixes #5901
2023-03-13 11:54:53 +02:00
Kyle Carberry 7fa6483d84 fix: allow the BINARY_DIR to be configured in the agent script (#6567) 2023-03-11 19:39:40 +00:00
Steven Masley 37c859ec4c chore: Ensure all audit types in ResourceTable match APGL (#6563)
* chore: Ensure all audit types in ResourceTable match APGL
* Implement more checks to ensure all tracked fields are present
* Add unit test to ensure all types are represented in audit table
* Trade compile time safety for syntax
2023-03-10 13:59:42 -06:00
Ammar Bandukwala a65a16122d chore: early merge clibase foundations (#6542) 2023-03-10 13:41:32 -06:00
Cian Johnston 144f374f60 refactor(dbauthz): add authz for system-level functions (#6513)
- Introduces rbac.ResourceSystem
- Grants system.* to system and provisionerd rbac subjects
- Updates dbauthz system queries where applicable
- coderd: Avoid index out of bounds in api.workspaceBuilds
- dbauthz: move GetUsersByIDs out of system, modify RBAC check to ResourceUser
- workspaceapps: Add test case for when owner of app is not found
2023-03-10 18:09:28 +00:00
Kyle Carberry 1db2b12b8e chore: add a test to ensure audit code works with AGPL (#6561)
Fixes the regression seen in v0.19.1.
2023-03-10 17:04:40 +00:00
Kyle Carberry 7eb2c2ff6d Revert "chore: Implement joins with golang templates (#6429)" (#6560)
This reverts commit 8b125d6c5d.
2023-03-10 10:39:02 -06:00
Steven Masley a8433b18e4 fix: Prevent infinite redirects on oidc errors (#6550)
* fix: Prevent infinite redirects on bad oidc scopes
* Show oidc errors
2023-03-10 10:12:29 -06:00
Kyle Carberry 4a07fcd9d2 fix: ensure local derp uses the hostname (#6559)
This broke deployments that use a custom port with an access
URL. We should write a test for this!
2023-03-10 09:58:31 -06:00
Steven Masley 8b125d6c5d chore: Implement joins with golang templates (#6429)
* feat: Implement view for workspace builds to include rbac info

* Removes the need to fetch the workspace to run an rbac check.
* chore: Use workspace build as RBAC object
* chore: Use golang templates instead of sqlc files
2023-03-10 09:44:38 -06:00
Eric Paulsen a666539bfa helm: add value for labels (#6544) 2023-03-10 07:39:55 -05:00
Marcin Tojek 90901ca129 fix: panic on deleted template (#6553) 2023-03-10 12:26:11 +01:00
Marcin Tojek 6023264a81 fix: typo in error message (#6551)
* fix: typo in error message

* fix
2023-03-10 09:20:22 +00:00
Steven Masley 7f25d31745 feat: Allow changing the 'group' oidc claim field (#6546)
* feat: Allow changing the 'group' oidc claim field
* Enable empty groups support
* fix: Delete was wiping all groups, not just the single user's groups
* Update docs
* fix: Dbfake delete group member fixed
2023-03-09 23:31:38 -06:00
Steven Masley 11a930e779 !fix: Disallow access urls without valid scheme (#6548)
Existing deployments will need to prepend a scheme if they have not already.
2023-03-09 21:37:19 -06:00
Kyle Carberry 65878b04ce fix: remove "something went wrong" text for watching a workspace (#6541)
This text wasn't useful to a customer anyways, because we don't get
an error from EventSource. This can happen if you close your laptop
and open it again, so it's better if we don't display it.
2023-03-09 20:54:28 +00:00
Ammar Bandukwala 50432b89be fix: pass git auth config read from environment through server (#6540) 2023-03-09 20:27:54 +00:00
Kyle Carberry 1b3b0ea962 fix: adjust workspace filter query when the path changes (#6539)
Previously, when a status would change the filter wouldn't update.
This makes it update if a new value is passed in.
2023-03-09 14:23:23 -06:00
Kyle Carberry 9a7998301b fix: remove deployment bar info icon and adjust language (#6537)
Re @bpmct's feedback
2023-03-09 20:08:50 +00:00
Ammar Bandukwala 4c2977050d fix: treat empty env as defaults (#6538) 2023-03-09 19:58:58 +00:00
Ammar Bandukwala 62a64d5a34 fix: remove deprecation warnings when option unset (#6532)
Resolves #6531
2023-03-09 19:22:21 +00:00
Ammar Bandukwala 761ed7bf63 fix: rm pull request template (#6533)
It doesn't seem like this was helping anything.
2023-03-10 03:53:48 +10:00
Steven Masley 2abae42cec feat: Ignore agent pprof port in listening ports (#6515)
* feat: Ignore agent pprof port in listening ports
2023-03-09 10:53:00 -06:00
Steven Masley 3de29307b5 chore: Remove some of the external enum comments in typescript (#6530)
* chore: Remove some of the external enum comments in typescript

handle clibase types manually
2023-03-09 10:46:23 -06:00
Marcin Tojek c2787e3a8e docs: describe rich parameters (#6527)
* WIP

* WIP

* docs: describe rich parameters

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Update docs/templates/parameters.md

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Strip migration

* Fix

---------

Co-authored-by: Cian Johnston <public@cianjohnston.ie>
2023-03-09 17:19:48 +01:00
Kyle Carberry 70b093ff2a fix: filter session count sums by created_at (#6529)
Fixes the session totals being waaaaay too high!
2023-03-09 17:08:41 +02:00
Kyle Carberry 1cc10f2ffb fix: only sum connection latencies when they are set (#6524)
This was producing a median that didn't make sense.
2023-03-09 03:53:09 +00:00
Ben Potter 1199a9330a chore: add fly.io install source (#6509) 2023-03-09 03:16:55 +00:00
Kyle Carberry a78786119d chore: resolve race when running load tests with logs (#6523)
See https://github.com/coder/coder/actions/runs/4370166746/jobs/7644793277
2023-03-08 21:12:20 -06:00
Kyle Carberry 5304b4e483 feat: add connection statistics for workspace agents (#6469)
* fix: don't make session counts cumulative

This made for some weird tracking... we want the point-in-time
number of counts!

* Add databasefake query for getting agent stats

* Add deployment stats endpoint

* The query... works?!?

* Fix aggregation query

* Select from multiple tables instead

* Fix continuous stats

* Increase period of stat refreshes

* Add workspace counts to deployment stats

* fmt

* Add a slight bit of responsiveness

* Fix template version editor overflow

* Add refresh button

* Fix font family on button

* Fix latest stat being reported

* Revert agent conn stats

* Fix linting error

* Fix tests

* Fix gen

* Fix migrations

* Block on sending stat updates

* Add test fixtures

* Fix response structure

* make gen
2023-03-08 21:05:45 -06:00
Kyle Carberry 9d40d2ffdc fix: remove create first user from the cli message (#6493)
This adjusts our setup to just show the Web UI, since you
can now create everything from there!
2023-03-09 02:54:46 +00:00
Kyle Carberry db2bdd1cab fix: pass sdk header transport to websocket connections (#6521)
Fixes #6520.
2023-03-09 02:29:02 +00:00
Kyle Carberry d67552f852 fix: add more cached certificates to azure instance identity (#6519)
This was failing for GovCloud. Now it falls back to fetch, and a test
has been added to notify when certificates are becoming outdated.
2023-03-08 19:32:10 -06:00
Bruno Quaresma 54bbed8c3c chore: Fix default password and add more docs about scripts/develop.sh (#6514) 2023-03-08 21:50:55 +00:00
Kyle Carberry 7df1e3bdd6 fix: centralize deployment config blocks to resolve race (#6512)
See https://github.com/coder/coder/actions/runs/4365865438/jobs/7635236079
2023-03-08 16:55:52 +00:00
Ammar Bandukwala 95626d2076 fix: use correct LOG env names (#6511) 2023-03-08 16:27:32 +00:00
Marcin Tojek 3b87316ad7 feat: propagate job error codes (#6507)
* feat: propagate job error_code

* fix

* Fix

* Fix

* Fix

* add errors to typesGenerated

* Address PR comments

* Fix
2023-03-08 16:32:00 +01:00
Kyle Carberry 524b14adbc fix: fetch provisioner logs after end of logs message (#6495)
I think this should _actually_ fix it. See
https://github.com/coder/coder/actions/runs/4358242625/jobs/7618562167

The problem was that this loop is ran async so even though
messages are sent in order, we were processing too slowly and
the end of logs was published first.
2023-03-09 01:13:08 +10:00
Cian Johnston 26a725f86d fix: provisionerd: add more context to logs emitted, fix log level (#6508)
- Previously, we were logging all provision response logs at level INFO, regardless of the log level of the log streamed from the provisioner. We now log these at the original level (defaulting to INFO).
- Now logging "provision failed" message at level ERROR WARN and including the error field in the message.
2023-03-08 15:12:52 +00:00
Bruno Quaresma 89008125c0 refactor(site): Highlight immutable parameters and do a few tweaks (#6490) 2023-03-08 10:52:42 -03:00
Cian Johnston fe10ba1157 fix: add audit information for WorkspaceBuild.MaxDeadilne (#6504)
* fix: add audit information for WorkspaceBuild.MaxDeadilne

* make gen
2023-03-08 12:41:57 +00:00
Ammar Bandukwala 3b73321a6c feat: refactor deployment config (#6347) 2023-03-07 15:10:01 -06:00
Kyle Carberry bb0a996fc2 chore: fix buffered provisioner job logs close flake (#6492)
See https://github.com/coder/coder/actions/runs/4357599919/jobs/7617111287
2023-03-07 20:08:13 +00:00
Dean Sheather 1bdd2abed7 feat: use JWT ticket to avoid DB queries on apps (#6148)
Issue a JWT ticket on the first request with a short expiry that
contains details about which workspace/agent/app combo the ticket is
valid for.
2023-03-07 19:38:11 +00:00
Hannes Rehburg f8494d2bac Update binary.md (#6415)
Fixed broken Link to release archive
2023-03-07 12:30:50 -06:00
Kyle Carberry f287889cd7 chore: move client pubsub for HA coordinator after register (#6487)
Similar fix that happened for the agent. See:
https://github.com/coder/coder/actions/runs/4356099388/jobs/7613634277
2023-03-07 16:46:54 +00:00
Kyle Carberry 4c204fc348 chore: ensure auto-approve-docs has a deep enough depth (#6484)
* chore: ensure auto-approve-docs has a deep enough depth

This was failing on numerous PRs.

* Remove check because it isn't working
2023-03-07 10:05:00 -06:00
Kyle Carberry 23bebb40e2 fix: bump migration to resolve duplicate id (#6486)
This happened because two PRs with different migrations
merged at the same time!
2023-03-07 10:04:06 -06:00
Marcin Tojek 2d4706ac33 feat: mark coder_parameter as "required" (#6433)
* Add required column

* Pass through providerd

* Pass the required property down

* Optional

* Fix

* Fix

* Fix

* fix

* CLI create: support for optional fields

* Use HTML API to mark fields required

* Fix

* Improve validation

* more fixes

* make fmt

* Fix

* WIP

* Fix: test

* CLI update tets

* OptionalParameterAdded

* Fix: migration
2023-03-07 16:38:31 +01:00
Kyle Carberry f19076cf06 chore: fix coordinator flake by moving pubsub below register (#6482)
After making the in-memory pubsub conform to the expectations of
PostgreSQL, this flake started appearing.

This fixes it because the agent socket is registered when a message
is received.
2023-03-07 09:38:18 -06:00
Kira Pilot ef2e86f309 increase default max-token-duration (#6467) 2023-03-07 06:35:48 -08:00
Kyle Carberry 87ed7a7dba chore: use nil map on agent stats to check if report interval should be returned (#6479)
See https://github.com/coder/coder/actions/runs/4350638262/jobs/7601537088
2023-03-07 14:25:04 +00:00
Dean Sheather 66a6b590a1 feat: add template max_ttl (#6114)
Co-authored-by: Bruno Quaresma <bruno@coder.com>
2023-03-07 14:14:58 +00:00
Cian Johnston 248c53d68d fix: install terraform in base Docker image (#6263)
Updates the base Docker image to install Terraform version 1.3.4 (max supported version) by default.
Also updates documentation to reflect this change.
2023-03-07 13:52:45 +00:00
Mathias Fredriksson 0c2b432c1b feat: Add migration fixer script (for branches) (#6466) 2023-03-07 14:47:33 +02:00
Kyle Carberry fd02f73708 chore: ensure pubsub messages are delivered in order for in-memory variant (#6474)
PostgreSQL provides this guarantee, which led to some flakes in tests.
See: https://github.com/coder/coder/actions/runs/4350034299/jobs/7600478096
2023-03-07 04:36:25 +00:00
Kyle Carberry 74632e460c fix: adjust build state permission to require template update (#6472) 2023-03-07 04:24:32 +00:00
Kyle Carberry 29ced72cda chore: fix stats leaking in tests (#6478)
See https://github.com/coder/coder/actions/runs/4350254306/jobs/7601134509
2023-03-07 04:09:02 +00:00
Kyle Carberry 034641dc77 chore: migrate security checks to run on a cron (#6477)
They were taking a long time and seemed to be reducing concurrency
for our other CI actions.
2023-03-06 21:46:40 -06:00
Kyle Carberry 7a8ccda40e chore: copy forced derp websockets to fix flake (#6475)
See: https://github.com/coder/coder/actions/runs/4350034299/jobs/7600478389
2023-03-06 21:29:41 -06:00
Kyle Carberry 964032d783 chore: ignore query cancel error in activitybump (#6476)
See https://github.com/coder/coder/actions/runs/4350254306/jobs/7600782432

It's fine to ignore this, because workspace agent stats happen async
and might occur when shutting down.
2023-03-06 21:27:21 -06:00
Kyle Carberry 09f87d1df1 chore: remove empty case for metricscache (#6473)
This wasn't necessary to test and just caused flakes.
See: https://github.com/coder/coder/actions/runs/4350034299/jobs/7600340648
2023-03-06 21:13:08 -06:00
Kyle Carberry cf75d7e1fb fix: clean up idle http conns on wsconncache close (#6471)
See https://github.com/coder/coder/actions/runs/4346769070/jobs/7593243836
2023-03-06 20:45:49 -06:00
Kyle Carberry e0137bcff1 fix: ensure tailnet websockets are closed (#6470)
See: https://github.com/coder/tailscale/pull/10
2023-03-06 20:45:24 -06:00
sharkymark d8abe37cd7 docs: AWS EKS sessionAffinity for LB external IP to be issued (#6425) 2023-03-06 23:40:05 +00:00
Steven Masley 8a6635bf5f chore: Never run v2 migrations on v1 database (#6438) 2023-03-06 13:49:03 -06:00
Mathias Fredriksson 22e3ff96be feat(agent): Add shutdown lifecycle states and shutdown_script support (#6139)
* feat(api): Add agent shutdown lifecycle states

* feat(agent): Add shutdown_script support

* feat(agent): Add shutdown_script timeout

* feat(site): Support new agent lifecycle states

---

Co-authored-by: Marcin Tojek <marcin@coder.com>
2023-03-06 21:34:00 +02:00
Ben Potter 02100c64b5 fix: docs auto-approval (#6450)
* fix: docs auto-approval

* empty commit
2023-03-06 13:31:07 -06:00
Bruno Quaresma 136f23fb4c refactor(site): Suport template version variables on template creation (#6434) 2023-03-06 18:36:19 +00:00
Steven Masley 84dd59ecc2 fix: 'coder update' should always start a workspace (#6437)
* fix: 'coder update' should always start a workspace
2023-03-06 08:37:11 -06:00
Steven Masley 260b2b2333 fix: 404 template shows error page (#6441) 2023-03-06 13:58:36 +00:00
Bruno Quaresma a3201bd658 fix(site): Fix template version editor rename (#6251) 2023-03-06 13:53:24 +00:00
Cian Johnston 1483b42259 fix(provisionerd): emit workspace build transition in lower case (#6455) 2023-03-06 11:14:19 +00:00
Steven Masley e78c272a72 chore: System context to oidc login (#6427) 2023-03-04 14:32:07 -06:00
Ammar Bandukwala 02f0968b33 chore: automatically approve Ben's docs PRs (#6444) 2023-03-04 08:07:45 -06:00
Ben Potter 573a8d5717 docs: external provisioners (#6443)
* docs: external provisioners

* better wording

* fix wording

* fix wording in various places

* keep fixing wording

* add another note about template management
2023-03-03 23:49:03 +00:00
Steven Masley 973df199b0 test: Check created_at for prepareData to ensure user order (#6436)
* test: Check created_at for prepareData to ensure user order

* test: Consistent user ordering in dbfake

* import order

* Linting
2023-03-03 13:09:04 -06:00
Muhammad Atif Ali 3cb9b3de24 docs: clarify how to set max token life in ci/cd docs (#6416)
* docs: clarify max token life in ci/cd docs

* Update docs/templates/change-management.md

---------

Co-authored-by: Ben Potter <me@bpmct.net>
2023-03-02 22:22:53 +00:00
Ben Potter 175a41567e docs: clarify requirements for offline deployments (#6418)
* docs: clarify requirements for offline deployments

* fix punctuation

* fix typo and add postgres

* mention update check

* disable update checks
2023-03-02 14:59:31 -06:00
Ben Potter 25b05ed8a4 fix: minimal AWS policy for templates (#6419) 2023-03-02 12:21:02 -06:00
Kira Pilot 71d1e63af0 feat: add ability to name tokens (#6365)
* add tokens switch

* reorged TokensPage

* using Trans component for description

* using Trans component on DeleteDialog

* add owner col

* simplify hook return

* lint

* type for response

* added flag for name

* fixed auth

* lint, prettier, tests

* added unique index for login type token

* remove tokens by name

* better check for unique constraint

* docs

* test: Fix dbfake to insert token name

* fix doc tests

* Update cli/tokens.go

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* Update coderd/database/migrations/000102_add_apikey_name.down.sql

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* add more specificity to IsUniqueViolation check

* fix tests

* Fix AutorizeAllEndpoints

* rename migration

---------

Co-authored-by: Steven Masley <stevenmasley@coder.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2023-03-02 09:39:38 -08:00
Muhammad Atif Ali e3a4861e93 docs: update example coder.conf file (#6319)
Updated example configuration file to match the configuration in tutorial.
2023-03-02 09:55:50 -06:00
Ben Potter ea7a80c5ff docs: clearer postgres sizing recommendations (#6302) 2023-03-02 15:53:55 +00:00
Kyle Carberry 2ff1c6d613 feat: add agent stats for different connection types (#6412)
This allows us to track when our extensions are used, when the
web terminal is used, and average connection latency to the agent.
2023-03-02 08:06:00 -06:00
Ben Potter 537547fcc3 fix: outdated message in latency tooltip (#6043) 2023-03-02 13:24:44 +00:00
Marcin Tojek 67db36bf81 feat: Add form to modify managed Template variables (#6257) 2023-03-02 14:11:16 +01:00
Steven Masley 8cf292f50a feat: Guard search queries against common mistakes (#6404)
* feat: Error on excessive invalid search keys
* feat: Guard search queries against common mistakes
* Raise errors in FE on workspaces table
* All errors should be on newlines
2023-03-01 23:28:56 -06:00
Kyle Carberry 1724cbf872 feat: automatically use websockets if DERP upgrade is unavailable (#6381)
* feat: automatically use websockets if DERP upgrade is unavailable

This might be our biggest hangup for deployments at the moment...

Load balancers by default do not support the DERP protocol, so many
of our prospects and customers run into failing workspace connections.
This automatically swaps to use WebSockets, and reports the reason to
coderd.

In a future contribution, a warning will appear by the agent if it was
forced to use WebSockets instead of DERP.

* Fix nil pointer type in Tailscale dep

* Fix requested changes
2023-03-01 22:18:14 +00:00
dependabot[bot] ce11400b56 chore: bump eslint-plugin-import from 2.26.0 to 2.27.5 in /site (#6391)
Bumps [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import) from 2.26.0 to 2.27.5.
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.26.0...v2.27.5)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 21:55:27 +00:00
dependabot[bot] 28e002e8bb chore: bump eslint-plugin-jest from 27.0.1 to 27.2.1 in /site (#6396)
Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 27.0.1 to 27.2.1.
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v27.0.1...v27.2.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-jest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:21:30 -06:00
dependabot[bot] 454da9e0ef chore: bump eslint from 8.33.0 to 8.35.0 in /site (#6395)
Bumps [eslint](https://github.com/eslint/eslint) from 8.33.0 to 8.35.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.33.0...v8.35.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:21:23 -06:00
dependabot[bot] 32ecb853ed chore: bump github.com/gohugoio/hugo from 0.109.0 to 0.110.0 (#6394)
Bumps [github.com/gohugoio/hugo](https://github.com/gohugoio/hugo) from 0.109.0 to 0.110.0.
- [Release notes](https://github.com/gohugoio/hugo/releases)
- [Changelog](https://github.com/gohugoio/hugo/blob/master/hugoreleaser.toml)
- [Commits](https://github.com/gohugoio/hugo/compare/v0.109.0...v0.110.0)

---
updated-dependencies:
- dependency-name: github.com/gohugoio/hugo
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:21:13 -06:00
dependabot[bot] 570b7f95d2 chore: bump aquasecurity/trivy-action from 0.8.0 to 0.9.1 (#6385)
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.8.0 to 0.9.1.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/9ab158e8597f3b310480b9a69402b419bc03dbd5...8bd2f9fbda2109502356ff8a6a89da55b1ead252)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:19:13 -06:00
dependabot[bot] b2671639a7 chore: bump crate-ci/typos from 1.13.9 to 1.13.14 (#6386)
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.13.9 to 1.13.14.
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/v1.13.9...v1.13.14)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:18:59 -06:00
dependabot[bot] 991c720c09 chore: bump github.com/cenkalti/backoff/v4 from 4.1.3 to 4.2.0 (#6390)
Bumps [github.com/cenkalti/backoff/v4](https://github.com/cenkalti/backoff) from 4.1.3 to 4.2.0.
- [Release notes](https://github.com/cenkalti/backoff/releases)
- [Commits](https://github.com/cenkalti/backoff/compare/v4.1.3...v4.2.0)

---
updated-dependencies:
- dependency-name: github.com/cenkalti/backoff/v4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 15:18:48 -06:00
Dean Sheather 31a37025c4 fix: build for all platforms in depot release pt. 2 (#6407) 2023-03-01 19:48:22 +00:00
Dean Sheather 9af03d6180 fix: build for all platforms in depot release (#6406) 2023-03-01 18:29:02 +00:00
Bruno Quaresma 4dd95c5e01 fix(site): Fix long template descriptions (#6403) 2023-03-01 17:53:59 +00:00
Kyle Carberry 9ea21bf8ee fix: hardcode azure instance identity intermediate certificates (#6402)
This was broken for an airgapped customer!
2023-03-01 17:39:24 +00:00
Kira Pilot 6304bfb5c0 feat: add 'Show all tokens' toggle for owners (#6325)
* add tokens switch

* reorged TokensPage

* using Trans component for description

* using Trans component on DeleteDialog

* add owner col

* simplify hook return

* lint

* type for response

* PR feedback

* fix lint
2023-03-01 08:35:55 -08:00
dependabot[bot] b56e1bb002 chore: bump contributor-assistant/github-action from 2.2.1 to 2.3.0 (#6384)
Bumps [contributor-assistant/github-action](https://github.com/contributor-assistant/github-action) from 2.2.1 to 2.3.0.
- [Release notes](https://github.com/contributor-assistant/github-action/releases)
- [Commits](https://github.com/contributor-assistant/github-action/compare/v2.2.1...v2.3.0)

---
updated-dependencies:
- dependency-name: contributor-assistant/github-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 09:18:01 -06:00
dependabot[bot] c560d6d2ae chore: bump actions/upload-artifact from 2 to 3 (#6383)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 09:17:34 -06:00
Ammar Bandukwala 5f6dd0ca2a docs: fix very small typo 2023-02-28 16:51:53 -06:00
Kyle Carberry 8850ce0d9a fix: use bigint instead of integer in stats migration (#6380)
This broke dogfood!
2023-02-28 13:55:37 -06:00
Kyle Carberry 05e449943d chore: convert agent stats to use a table (#6374)
* chore: convert workspace agent stats from json to table

* chore: convert agent stats to use a table

Backwards compatibility becomes hard when all agent stats are in a JSON blob.
We also want to query this table for new agents that are failing health checks
so we can display it in the UI.

* Fix migration using default values
2023-02-28 13:33:33 -06:00
Ben Potter 7cf1e20aac docs: clarify alpha state of rich parameters (#6369) 2023-02-28 17:16:49 +00:00
Steven Masley 418a8a77dc chore: Skip authz on various functions used for api data building (#6366)
* chore: Skip authz on various functions used for api data building

API already fetches the parent object and does the rbac check. Until
these functions are optimized, skipping authz is better.
It leaves us no worse off than the status quo
2023-02-28 11:04:39 -06:00
Ammar Bandukwala d30da81f42 ci: make fmt 2023-02-27 21:43:14 +00:00
Ammar Bandukwala dc6639bf69 ci: automatically assign new PRs 2023-02-27 16:52:00 +00:00
Kyle Carberry 7f226d4f90 feat: add support for coder_git_auth data source (#6334)
* Add git auth providers schema

* Pipe git auth providers to the schema

* Add git auth providers to the API

* Add gitauth endpoint to query authenticated state

* Add endpoint to query git state

* Use BroadcastChannel to automatically authenticate with Git

* Add error validation for submitting the create workspace form

* Fix panic on template dry-run

* Add tests for the template version Git auth endpoint

* Show error if no gitauth is configured

* Add gitauth to cliui

* Fix unused method receiver

* Fix linting errors

* Fix dbauthz querier test

* Fix make gen

* Add JavaScript test for git auth

* Fix bad error message

* Fix provisionerd test race

See https://github.com/coder/coder/actions/runs/4277960646/jobs/7447232814

* Fix requested changes

* Add comment to CreateWorkspacePageView
2023-02-27 10:18:19 -06:00
Kyle Carberry 3d8b77d6f1 chore: improve clarity of the agent logs (#6345)
I looked through these logs when debugging and there was a bit of spam!
2023-02-27 09:20:24 -06:00
Marcin Tojek ec6f78d09e docs: Describe support links (#6353)
* docs: Describe support links

* Fix: size

* Address PR comments
2023-02-27 15:33:34 +01:00
Marcin Tojek 2e53fb55da feat: Enable custom support links (#6313)
* backend: support links

* frontend: Support links

* fmt

* test: CODER_SUPPORT_LINKS_0_NAME

* Go tests

* Use UpdateAppearanceConfig

* ui: UpdateAppearanceConfig

* fix: fmt

* Fix: site

* Fix: site tests

* fix: fmt

* fix

* test: check default support links
2023-02-27 09:25:04 +01:00
Eric Paulsen 16364db483 add: resource reqs/limits on K8s template (#6308) 2023-02-26 19:23:45 -05:00
Kyle Carberry 7c46f76c82 chore: fetch templates by id without lock in dbfake (#6351)
See https://github.com/coder/coder/actions/runs/4276632002/jobs/7444857508
2023-02-26 15:29:43 -06:00
Kyle Carberry acbe968f41 chore: increase activitybump deadline duration to fix flake (#6350)
This is a bad fix because the test is still dependant on time, but it's
still an improvement.
2023-02-26 13:42:01 -06:00
Kyle Carberry 1fb7365cb1 fix: use -U when installing with rpm (#6349)
Fixes #6275.
2023-02-26 18:11:23 +00:00
Kyle Carberry 17adfd1134 chore: improve times of ratelimit tests (#6346)
From 5s to 130ms!
2023-02-25 22:01:01 +00:00
Muhammad Atif Ali acf000aaa5 add common ml logos (#6318) 2023-02-25 11:22:07 -06:00
Kyle Carberry d613ba9987 security: add X-Content-Type-Options: nosniff to block MIME-sniffing (#6344)
coder/security#12
2023-02-25 11:18:45 -06:00
Mathias Fredriksson cae8b88f60 fix(tailnet): Avoid logging netmap (#6342) 2023-02-25 08:06:38 +00:00
Mathias Fredriksson 5876dc1f97 test(cli): Improve speedtest debugging (#6341) 2023-02-24 20:09:16 +02:00
Ben Potter 29dbfe067c docs: parameters (#6332)
* docs: parameters

* mention legacy parameters
2023-02-24 11:04:07 -07:00
Mathias Fredriksson 677721e4a1 fix(tailnet): Skip nodes without DERP, avoid use of RemoveAllPeers (#6320)
* fix(tailnet): Skip nodes without DERP, avoid use of RemoveAllPeers
2023-02-24 18:16:29 +02:00
Mathias Fredriksson a414de9e81 fix(tailnet): Improve tailnet setup and agentconn stability (#6292)
* fix(tailnet): Improve start and close to detect connection races

* fix: Prevent agentConn use before ready via AwaitReachable

* fix(tailnet): Ensure connstats are closed on conn close

* fix(codersdk): Use AwaitReachable in DialWorkspaceAgent

* fix(tailnet): Improve logging via slog.Helper()
2023-02-24 13:11:28 +02:00
Cian Johnston 473ab208af fix: replace owner_email with workspace_owner, change action to workspace_transition (#6337) 2023-02-24 04:38:58 +00:00
Marcin Tojek 16363fd1ff chore: update terraform-provider-coder (#6333) 2023-02-23 17:32:52 -07:00
Cian Johnston 282507f0fb fix(provisionerd): add more detailed workspace build logging (#6331) 2023-02-24 00:03:12 +00:00
Marcin Tojek 50db90c33d fix: rephrase rich parameters error (#6327) 2023-02-23 21:49:40 +00:00
Dean Sheather 8abe48c155 chore: update docs to recommend MFA via iDP (#6324) 2023-02-23 18:07:21 +00:00
Kyle Carberry 8a4a179565 fix: use proper generated cli.md for deploy (#6323) 2023-02-23 11:07:57 -06:00
Muhammad Atif Ali 7f65a837b1 docs: Update the troubleshooting section (#6287)
* docs: Update the troubleshooting section

Update the troubleshooting section to link web-server docs

* make fmt

* Update docs/templates.md

Co-authored-by: Ben Potter <me@bpmct.net>

---------

Co-authored-by: Ben Potter <me@bpmct.net>
2023-02-23 09:35:02 -07:00
Bruno Quaresma 0bf6229edb fix(site): Fix cancel button borders (#6315) 2023-02-23 15:47:09 +00:00
Cian Johnston 1ba6fab0e0 chore: skip flaky TestSpeedtest (#6322) 2023-02-23 15:29:57 +00:00
Bruno Quaresma 8298a924f6 fix(site): Fix login flow (#6294) 2023-02-23 15:25:12 +00:00
Kira Pilot a32169ccb5 feat: add flag to see all tokens if owner (#6227)
* added query for tokens by user id

* updated query args

* adding owner col

* fix request params

* update-golden-files

* added owners col to ls table output

* added ttoken translations

* prettier

* format table according to arg

* using slice.Contains

* refactored token state

* cleanup
2023-02-23 07:00:27 -08:00
Ammar Bandukwala 7a52a9cfc8 fix: flake in template pull (#6317) 2023-02-23 04:59:07 +00:00
Ammar Bandukwala f6a8c360e5 feat(docs): rework CLI docs (#6312) 2023-02-23 01:53:21 +00:00
Cian Johnston 43e8ba0811 feat(api): add prometheus metric coderd_workspace_builds_total (#6314)
This PR adds the prometheus metric coderd_workspace_builds_total.
It measures the total number of workspace builds, along with a number of labels intended to be useful for an operator debugging a failed workspace build trying to discover the scope of the issue.
2023-02-23 01:28:10 +00:00
Kyle Carberry 2a8a147e7d fix: don't log disconnect error when the database is shutting down (#6309)
* fix: don't log disconnect error when the database is shutting down

Seen in: https://github.com/coder/coder/actions/runs/4244980490/jobs/7379867681

* Generalize the query close error
2023-02-22 16:07:26 -06:00
Colin Adler bbdf24686d chore(examples): update provider versions (#6311) 2023-02-22 14:43:32 -07:00
Muhammad Atif Ali 457ad74d83 fix(docs): update Apache docs to correctly forward WebSockets (#6296)
This revolves #6295
2023-02-22 12:51:51 -07:00
Ammar Bandukwala f7c10adb04 feat(cli): extract tar in template pull (#6289) 2023-02-22 19:29:51 +00:00
Ammar Bandukwala 8231de94ca feat(cli): make minor improvements to speedtest (#6266)
- Remove mostly redundant "Transferred" column
- Rename "Bandwidth" to "Throughput"
- Replace "--reverse" (which has an ambiguous starting state) with "--direction=(up|down)"
- Tolerate AgentStartErrors which may be caused by failing startup
  script
2023-02-22 19:09:37 +00:00
Cian Johnston 6149905a83 fix: disallow deleting self (#6306)
* fix: api: disallow user self-deletion

* feat(site): TableRowMenu: allow disabling individual menu items

* fix(site): UsersTable: disallow deleting self
2023-02-22 16:48:16 +00:00
Bruno Quaresma b412ef0dbb chore(site): Fix chromatic errors (#6301) 2023-02-22 16:11:25 +00:00
Bruno Quaresma d993a97fee refactor(site): Minor design refactoring on template version editor (#6265) 2023-02-22 07:42:37 -07:00
Ammar Bandukwala 3f75f6b8cc chore: update potentially insecure deps (#6290) 2023-02-21 06:15:25 +00:00
Mathias Fredriksson 2bb9b4ac80 test: Fix azureidentity verification test (#6284) 2023-02-20 15:50:39 +00:00
Ammar Bandukwala f05609b4da chore: format Go more aggressively 2023-02-18 18:32:09 -06:00
Colin Adler 19ae411f05 fix: actually test httpapi.WebsocketCloseSprintf (#6261) 2023-02-17 17:50:21 +00:00
Kyle Carberry a79f4a095d fix: allow mapped resources in our terraform provider (#6255) 2023-02-17 16:05:38 +00:00
Bruno Quaresma 7a864bdb28 refactor(site): Refactor template settings (#6239) 2023-02-17 13:27:35 +00:00
Marcin Tojek e161c45b47 Revert "fix: allow mapped resources in our terraform provider (#6242)" (#6248)
This reverts commit d5af536ea2.
2023-02-17 12:55:54 +01:00
Marcin Tojek a69137b1f7 feat: Update CLI to handle managed variables (#6220)
* WIP

* hcl

* useManagedVariables

* fix

* Fix

* Fix

* fix

* go:build

* Fix

* fix: bool flag

* Insert template variables

* API

* fix

* Expose via API

* More wiring

* CLI for testing purposes

* WIP

* Delete FIXME

* planVars

* WIP

* WIP

* UserVariableValues

* no dry run

* Dry run

* Done FIXME

* Fix

* Fix: CLI

* Fix: migration

* API tests

* Test info

* Tests

* More tests

* fix: lint

* Fix: authz

* Address PR comments

* Fix

* fix

* fix

* CLI: create

* unit tests: create templates with variables

* Use last variables

* Fix

* Fix

* Fix

* Push tests

* fix: variable is required if Default is nil

* WIP

* Redact sensitive values

* Fixes

* Fixes

* Fix: arg description

* Fix

* Variable param

* Fix: gen

* Fix

* Fix: goldens
2023-02-17 09:07:45 +01:00
Kyle Carberry d5af536ea2 fix: allow mapped resources in our terraform provider (#6242)
Fixes #6234.
2023-02-16 13:04:40 -06:00
Bruno Quaresma 2c309194e9 refactor(site): Remove untar dep and support nested folders on template version page (#6244) 2023-02-16 18:38:14 +00:00
Bruno Quaresma 8360357834 refactor(site): Redesign dialogs (#6237) 2023-02-16 14:03:07 -03:00
Bruno Quaresma 909fbb6d2c fix(site): Upload template files on template version editor (#6222) 2023-02-16 13:59:48 -03:00
Ben 4c799798c6 fix: use autoplay for AWS quickstart 2023-02-16 16:45:53 +00:00
Ben Potter 779c6549b4 fix: proper video URL in AWS quickstart doc (#6238)
* chore: fixes video in AWS quickstart

* minor language tweaks
2023-02-16 10:39:14 -06:00
Cian Johnston 81c29c018a fix(coder): actually ignore email verified if config value is set (#6236) 2023-02-16 15:17:37 +00:00
Ben Potter fdad136905 fix: add notes to helm chart about access URL (#6231) 2023-02-16 08:46:49 -06:00
Ben Potter 9c22c51d3b docs: use marketplace for AWS quickstart (#6233) 2023-02-16 08:44:48 -06:00
Muhammad Atif Ali 26876dc734 docs: add apache reverse-proxy example (#6213)
* docs: apache reverse proxy

* fixed to correctly pass WebSocket headers

* add a sample configuration file

* updating with suggestions

* Update coder.conf

* fix http to https redirection

* fix: upgrade http to https

* Update examples/web-server/apache/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* add other dns providers documentation link

---------

Co-authored-by: Ben Potter <me@bpmct.net>
Co-authored-by: Ben Potter <ben@coder.com>
2023-02-16 04:50:18 +00:00
Muhammad Atif Ali 99306642bb docs: update caddy reverse proxy example (#6228)
Update caddy reverse proxy example to use `CODER_HTTP_ADDRESS` instead of deprecated `CODER_ADDRESS`

This resolved #5449
2023-02-16 04:31:43 +00:00
Ben Potter 22cc6a3fb6 docs: feature stages (#6223) 2023-02-15 15:51:31 -06:00
Muhammad Atif Ali 786ad8d8b1 docs: add nginx reverse-proxy example (#6185)
* docs: Add nginx reverse-proxy example

This PR adds nginx reverse-proxy example to provision coder with tls certificate using letsencrypt certbot.

This will partially resolve #6086.

* change nginx example to to absolute path

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* refactor: replaced bullets with numbered lists

* remove the ambiguous ip addr.

* fixed a typo

* correctly handle the wildcard subdomain

* simplified after testing

* fmt: prettier formatting

* Adapt to the coder style guide

* fix: agent disconnection

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update docs/admin/configure.md

Co-authored-by: Ben Potter <me@bpmct.net>

* Update examples/web-server/nginx/README.md

Co-authored-by: Ben Potter <me@bpmct.net>

* updated with suggested changes

* updated with requested changes

* add reference to certbot docs for other dns providers

---------

Co-authored-by: Ben Potter <me@bpmct.net>
2023-02-15 13:18:54 -06:00
Marcin Tojek 3b7b96ac28 feat: Expose managed variables via API (#6134)
* WIP

* hcl

* useManagedVariables

* fix

* Fix

* Fix

* fix

* go:build

* Fix

* fix: bool flag

* Insert template variables

* API

* fix

* Expose via API

* More wiring

* CLI for testing purposes

* WIP

* Delete FIXME

* planVars

* WIP

* WIP

* UserVariableValues

* no dry run

* Dry run

* Done FIXME

* Fix

* Fix: CLI

* Fix: migration

* API tests

* Test info

* Tests

* More tests

* fix: lint

* Fix: authz

* Address PR comments

* Fix

* fix

* fix
2023-02-15 18:24:15 +01:00
Cian Johnston f0f39b4892 chore: break down dbauthz.System into smaller roles (#6218)
- rbac: export rbac.Permissions
- dbauthz: move GetDeploymentDAUs, GetTemplateDAUs,
  GetTemplateAverageBuildTime from querier.go to system.go
  and removes auth checks
- dbauthz: remove AsSystem(), add individual roles for
  autostart, provisionerd, add restricted system role for 
  everything else
2023-02-15 16:14:37 +00:00
Eric Paulsen 84da6056b2 docs: add template-level URL override (#6212) 2023-02-15 10:27:40 -05:00
Steven Masley 4cbbd1376d feat: Turn on rbac check caching (#6202)
* chore: Turn on rbac check caching.

Should not affect much unless authz_querier experiment is
enabled
2023-02-15 08:56:07 -06:00
Mathias Fredriksson fac7c02eeb ci: Add support for release/experimental label (#6208)
Co-authored-by: Ben Potter <ben@coder.com>
2023-02-15 14:23:06 +00:00
Ammar Bandukwala 5e4931efaf chore: add comment that explains require_telemetry behavior (#6211) 2023-02-15 01:40:08 +00:00
Kira Pilot 5e60879fb8 feat: audit addition and removal of licenses (#6125)
* added license audit resource

* audit delete licenses

* added filtering

* remove logs

* making the best of the current UUID situation

* fixed lint

* fix tests

* regen docs

* PR feedback

* PR feedback
2023-02-14 16:34:13 -05:00
Ammar Bandukwala 6e3330a03f feat: add support for telemetry-required licenses (#6194) 2023-02-14 20:26:47 +00:00
Eric Paulsen 15c862fcb5 fix: set Coder read-only fs to null (#6207)
* fix: set Coder read-only fs to null

* chore: readme fixes

* chore: readme fixes v2
2023-02-14 11:04:16 -06:00
Steven Masley 80bde1e2c9 chore: Ensure cancelled errors return proper (#6200)
The authz library returns a 404 if the authorization fails. If the
context is cancelled, then a 404 message is inaccurate.
Add a unit test to ensure context cancelled errors are raised
properly
2023-02-14 10:57:19 -06:00
Mathias Fredriksson 860e2829c5 fix: Prevent race between provisionerd connect and close (#6206)
* fix: Prevent race between provisionerd connect and close

* test: Add detection for provisioner creation after test completion
2023-02-14 16:37:43 +00:00
Steven Masley cde7ff8a2d chore: Fix import rbac on userauth.go (#6205) 2023-02-14 15:57:34 +00:00
Kyle Carberry 51f17b1820 fix: allow disabling all password auth even if owner (#6193)
* fix: allow disabling all password auth even if owner

Removes any and all ability to auth with a password.

* Hide create user if password auth is disabled
2023-02-14 08:58:12 -06:00
Mathias Fredriksson 41ae01d2e9 fix: Improve closure of provisioner and agent tailnet dial (#6199) 2023-02-14 14:57:48 +00:00
Mathias Fredriksson 5df7872661 fix: Improve use of context in websocket.NetConn code paths (#6198) 2023-02-14 16:42:55 +02:00
Steven Masley 6fb8aff6d0 feat: Add initial AuthzQuerier implementation (#5919)
feat: Add initial AuthzQuerier implementation
- Adds package database/dbauthz that adds a database.Store implementation where each method goes through AuthZ checks
- Implements all database.Store methods on AuthzQuerier
- Updates and fixes unit tests where required
- Updates coderd initialization to use AuthzQuerier if codersdk.ExperimentAuthzQuerier is enabled
2023-02-14 14:27:06 +00:00
Mathias Fredriksson ebdfdc749d test: Make cli speedtest more reliable (#6187)
We now wait for the agent to be connected/ready before start.
2023-02-14 11:23:04 +02:00
dependabot[bot] 1c4e1d8ded chore: bump github.com/gin-gonic/gin from 1.7.0 to 1.7.7 (#6146)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.7.0 to 1.7.7.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.7.0...v1.7.7)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 19:33:11 -06:00
Kyle Carberry 2d0a69ba47 fix: require client pipe to be closed in provisionerd test (#6188)
https://github.com/coder/coder/actions/runs/4165019548/jobs/7207442687
2023-02-14 00:46:05 +00:00
Steven Masley 733f58c76d chore: Force license uuids to not be null (#6012)
* chore: Force license uuids to not be null

* All unit tests generate uuids for licenses

* Update migration files to new numbers

* Put migration in transaction
2023-02-13 18:21:58 -06:00
Colin Adler a54de6093b feat: add coder ping (#6161) 2023-02-13 10:38:00 -06:00
Mathias Fredriksson 2157bff13f fix: Clean up conn on NewStream error in memDRPC (#6182) 2023-02-13 17:27:10 +02:00
Mathias Fredriksson d355783faa test: Fix cli delete test (#6183)
We were waiting for Cleaning Up which never appeared:

https://github.com/coder/coder/actions/runs/4163971602/jobs/7205007285
2023-02-13 14:54:38 +00:00
Marcin Tojek a064678b8a fix: Update flake.lock to fix Go build (#6173) 2023-02-13 11:05:28 +01:00
Mathias Fredriksson a56df46d0f fix: Update github.com/coder/retry to remove initial delay (#6160) 2023-02-13 11:54:43 +02:00
Kyle Carberry c0c83f17b2 fix: follow tailscale idioms for when to update nodes (#6164) 2023-02-10 16:59:24 -06:00
Bruno Quaresma b171cb562c fix(site): Support underscore files (#6159) 2023-02-10 16:52:56 -03:00
Mathias Fredriksson 2dbe00ae44 fix(api): Allow workspace agent coordinate to report disconnect (#6152) 2023-02-10 20:23:02 +02:00
Steven Masley 6189035e98 feat: Add option to enable hsts header (#6147)
* feat: Add option to enable hsts header
* Update golden files
2023-02-10 10:52:49 -06:00
Bruno Quaresma 77afdf71dc fix(site): Show folders in the template version editor (#6145) 2023-02-10 13:22:20 -03:00
Steven Masley 32fbd10a1f chore: Optimize parial rego execution byte allocations (#6144)
* chore: Implement benchmark for authorizer.Prepare

Identify time + alloc cost before optimizing
2023-02-10 08:39:45 -06:00
Cian Johnston ab9cba9396 chore: update template examples and docs to install code-server under /tmp (#6138)
Updates templates examples and docs to pass the --method standalone argument
to code-server install script, and installs code-server under /tmp/code-server.

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>

---------

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2023-02-10 13:36:46 +00:00
Colin Adler 4432cd08d6 chore: update tailscale (#6091) 2023-02-09 21:43:18 -06:00
Steven Masley e6da7afd33 feat: Add cachable authorizer to elimate duplicate rbac calls (#6107)
* feat: Add cachable authorizer to elimate duplicate rbac calls

Cache is context bound, so only prevents duplicate rbac calls in
the same request context.
2023-02-09 20:14:31 -06:00
Mathias Fredriksson 6f3f7f2937 fix(agent): Allow signal propagation when running as PID 1 (#6141) 2023-02-09 23:07:21 +02:00
Steven Masley af59e2bcfa chore: Optimize rego policy input allocations (#6135)
* chore: Optimize rego policy evaluation allocations

Manually convert to ast.Value instead of using generic
json.Marshal conversion.

* Add a unit test that prevents regressions of rego input

The optimized input is always compared to the normal json
marshal parser.
2023-02-09 13:47:17 -06:00
Eric Paulsen 22f6400ea5 helm: add deployment securityContext values (#6136)
* helm: add deployment securityContext values

* rm: podSecurityContext
2023-02-09 13:26:35 -05:00
Mathias Fredriksson b46d0d693f fix: Extend migration fixture test with down and fix 000045 (#6130) 2023-02-09 16:59:54 +02:00
Marcin Tojek 049984ce7f docs: Add missing body parameter (#6132) 2023-02-09 14:02:03 +00:00
Bruno Quaresma 4493649d7e refactor(site): Add bottom spacing to the dashboard layout (#6084) 2023-02-08 21:53:42 -03:00
Kyle Carberry 4827d9edb8 fix: increase generated password length resolve flake (#6116) 2023-02-08 20:59:07 +00:00
Kyle Carberry d803bb76d5 feat: automatically open workspaces in vscode in the dir specified (#6115)
This makes a one-click magical experience not only possible, but really nice too!
2023-02-09 06:38:34 +10:00
Kyle Carberry 2ed0eafd75 feat: add minimum password entropy requirements (#6090)
* feat: add minimum password entropy requirements

* Fix all the tests

* Fix E2E tests
2023-02-08 14:10:08 -06:00
Marcin Tojek fe725f76bb fix: Allow to stop or remove workspaces using rich and old parameters (#6099)
* fix: Allow to stop or remove workspaces using rich and old parameters

* Fix
2023-02-08 14:09:52 -06:00
Kyle Carberry 1617268859 fix: stop redirecting away from wildcard url (#6113)
Fixes #6097.
2023-02-08 13:48:17 -06:00
Steven Masley 8dba66c535 chore: Use dbgen in unit test (#6111)
* chore: Use dbgen in unit test

- organizationparam_test
- templateparam_test

* Use dbgen in all unit tests vs insert methods

* fixup! Use dbgen in all unit tests vs insert methods

---------

Co-authored-by: Cian Johnston <cian@coder.com>
2023-02-08 13:47:05 -06:00
Kira Pilot 7a1731b620 chore: change build audit log string to be clearer (#6093)
* changed bbuild string

* clean up friendly string

* using Trans component

* general cleanup

* fixed tests

* fix lint

* fixing bolding

* removing dead strings in auditLogRow

* fix tests
2023-02-08 13:06:57 -05:00
Dean Sheather d60ec3e4bf feat: add JSON output format to many CLI commands (#6082) 2023-02-08 17:09:38 +00:00
Ben Potter 5655ec6862 Revert "fix: open terminal and coder_app in a new tab, not window (#6044)" (#6106)
This reverts commit a655f03a1e.
2023-02-08 15:57:27 +00:00
Ben Potter 0ccab0c420 fix: do not build Packer images on dry runs (#6105) 2023-02-08 15:51:16 +00:00
Dean Sheather b5e5959649 chore: fix flake in create-admin-user test (#6103) 2023-02-08 15:36:43 +00:00
Kira Pilot 3da33d23a4 chore: fix status color for redirects on audit page (#6096) 2023-02-08 09:40:51 -05:00
Dean Sheather e17ed9f5e6 chore: diable ironbank image scanning (#6104) 2023-02-08 14:40:27 +00:00
Mathias Fredriksson 33f2c8fef5 ci: Fix scripts/release/publish.sh execrelative path (#6101)
Behavior of `lib.sh` changed in: #5934
2023-02-08 13:32:06 +00:00
Mathias Fredriksson f6da0a6945 ci: Fix silent missing metadata for release notes (#6089)
* Fix use of `mapfile < <(cmd)` which silently hid errors

* Fix script paths since `SCRIPT_DIR` changed in `lib.sh`

* Set `GITHUB_TOKEN` in release.yaml
2023-02-08 14:40:52 +02:00
Marcin Tojek 1dc477819e feat: render Markdown in rich parameter descriptions (#6098) 2023-02-08 12:57:12 +01:00
Eric Paulsen f24547ecb1 feat: add iron bank Dockerfile & manifest (#5934)
* feat: add iron bank Dockerfile & manifest

Co-authored-by: Dean Sheather <dean@deansheather.com>

* add: tfrc file

* mv: ironbank/ /scripts

* fixup! Merge branch 'main' into iron-bank

* feat: add ironbank trivy scanning

* fixup! feat: add ironbank trivy scanning

* fixup! feat: add ironbank trivy scanning

* fixup! feat: add ironbank trivy scanning

* fixup! feat: add ironbank trivy scanning

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
2023-02-07 18:09:33 -05:00
Kyle Carberry 691495d761 feat: add expanded_directory to the agent for extension support (#6087)
This will enable opening the default `dir` of an agent in
the VS Code extension!
2023-02-07 21:35:09 +00:00
Kyle Carberry f6effdb63e fix: redirect the user to the home directory if dir is not set (#6085)
This was blocking SSH connections from being established if a dir
that wasn't created yet is set.
2023-02-07 20:28:41 +00:00
Colin Adler bde4ffebe5 docs: add auth paragraph about oidc group sync (#6064) 2023-02-07 13:22:02 -06:00
Mathias Fredriksson c63dcf13c2 fix(dogfood): Enable pipefail for startup_script (#6017) 2023-02-07 20:15:09 +02:00
Ben Potter a5f3f02ef8 fix: duplicate list when updating workspaces (#6032) 2023-02-07 10:26:04 -06:00
Kyle Carberry e7ebcb54dc fix: use workspace name when opening links via the terminal (#6077)
This was using the `<workspace>.<agent>` syntax before, which was invalid.
2023-02-07 09:58:11 -06:00
Ben Potter c82e38e2d8 docs: using Coder behind a corporate proxy (#6079)
* docs: using Coder with a corporate proxy

* fix typo
2023-02-07 15:11:03 +00:00
Kira Pilot 4155b085b7 feat: add preset filter for audit logins (#6066) 2023-02-07 09:17:06 -05:00
Kyle Carberry bed37b4208 feat: use wildcard url for local links in the web terminal (#6070) 2023-02-07 07:21:06 -06:00
Marcin Tojek 135a4d87f1 fix: use correct migration number (#6071) 2023-02-07 10:28:44 +01:00
Marcin Tojek b86bce8494 feat!: Validate monotonic numbers for rich parameters (#6046)
* Database changes

* protobuf

* Fix: docs

* workspaces_test

* Validation in coderd

* Fix: resources

* omitempty

* UI changes

* UI tests

* fix
2023-02-07 09:36:13 +01:00
Kyle Carberry e3ae664a29 fix: add typegen for templateVersionEditorXService (#6069)
This was borked before, and actually broken!
2023-02-06 20:30:54 -06:00
Kyle Carberry dd9e1f3d3f feat: add template editor to the ui (#5963)
* Add initial editor

* Fix editor file being reset onChange

* Add updating the active build version

* Update nav height

* Add tabs

* Fix title

* Hide timestamps in build logs

* Add create file dialog

* Add validation for empty path

* Hide resources tab

* Fix label names

* Add rename and delete

* Improve UX

* Add padding to the editor

* Add dirty state

* Hide build logs until a build is made

* Add stories

* Add experiment to enable the template editor

* Fix linting errors

* Fix duplicate fields

* Fix theme type
2023-02-07 01:24:04 +00:00
Kyle Carberry 71a893764e chore: remove CreateAnotherUserWithUser (#6068)
This was not idiomatic Go!
2023-02-06 23:48:21 +00:00
Colin Adler b81d8464df fix: audit log query when Postgres TZ isn't UTC (#6067) 2023-02-06 21:36:39 +00:00
Ben Potter d9e22d74ba fix: wrong URL in rootless podman docs (#6063) 2023-02-06 15:32:59 -06:00
Ammar Bandukwala 3724d81413 chore: simplify error handling in template push (#6065) 2023-02-06 20:35:48 +00:00
Kira Pilot 46fe59f5e7 feat: audit login (#5925)
* added migration for api key resource

* sort of working

* auditing login

* passing  the correct user id

* added and fixed tests

* gen documentation

* formatting and lint

* lint

* audit Github oauth and write tests

* audit oauth and write  tests

* added defer fn for login error auditing

* fixed test

* feat: audit logout (#5998)

* Update coderd/userauth.go

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* fix test

* bypassing diff generation if login/logout

* lint

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2023-02-06 15:12:50 -05:00
Dean Sheather 060eeed5c3 chore: fix depot build pt.2 (#6059) 2023-02-07 03:26:29 +10:00
Kyle Carberry bdddc3e7ae fix: change auto-start to automatically update workspaces (#6053)
Fixes #6049.
2023-02-06 11:12:58 -06:00
Dean Sheather d6947aeaca chore: fix depot build (#6057) 2023-02-06 16:49:33 +00:00
Dean Sheather b45c445255 feat: add git to Docker image (#6034) 2023-02-07 02:30:35 +10:00
Ben Potter a655f03a1e fix: open terminal and coder_app in a new tab, not window (#6044)
* fix!: open coder_app in a new tab, not window

* terminal too
2023-02-06 10:22:24 -06:00
Dean Sheather 4fe221a700 feat: add flag to disable password auth (#5991)
Adds a flag --disable-password-auth that prevents the password login
endpoint from working unless the user has the "owner" (aka. site admin)
role.

Adds a subcommand `coder server create-admin-user` which creates a user
directly in the database with the "owner" role, the "admin" role in
every organization, and password auth. This is to avoid lock-out
situations where all accounts have the login type set to an identity
provider and nobody can login.
2023-02-06 14:58:21 +00:00
Ben Potter 968d7e4dc5 docs: rootless podman support (#6026)
* rootless podman WIP

* docs: rootless podman support
2023-02-06 08:05:38 -06:00
Ben Potter e70b3f2973 chore: add alias helper to develop.sh (#6031)
* chore: add alias helper to develop.sh

* restore formatting

* change alias from feedback
2023-02-06 05:02:34 +00:00
Ben Potter 5931d12d4b chore: use node 16 in dogfood images (#5939) 2023-02-05 22:48:41 -06:00
Ben Potter 90bc5d5b5f docs: add note around offline docs provider versions (#6018) 2023-02-06 04:36:20 +00:00
sharkymark a5e386e54b chore: remove jetbrains projector web ide docs, examples. specify no support (#6033) 2023-02-05 19:40:40 -06:00
Ammar Bandukwala f096915c27 chore(ci): automatically delete stale branches (#6036) 2023-02-04 15:18:11 -06:00
Ammar Bandukwala a422cc00e8 Stream template upload (#6035)
Resolves #5718
2023-02-04 20:07:09 +00:00
Steven Masley 77fd34be94 chore: Action consts should actually be typed as an Action (#6027) 2023-02-03 20:36:37 +00:00
Steven Masley b359dbbd8b chore: Allow RecordingAuthorizer to record multiple rbac authz calls (#6024)
* chore: Allow RecordingAuthorizer to record multiple rbac authz calls

Prior iteration only recorded the last call. This is required for
more comprehensive testing
2023-02-03 19:03:46 +00:00
Cian Johnston 571f5d0e02 chore: add log_statement=all for test-postgres-docker (#6025) 2023-02-03 18:58:31 +00:00
Kyle Carberry 2c2bbcc019 chore: update tests to support fish (#6023)
* fix: update tests to add fish support

* Track connections for SSH sessions to prevent leaks

* Revert SSH conn handling
2023-02-03 12:25:11 -06:00
Dean Sheather cf9abe3a6c feat: add session expiry control flags (#5976)
Adds --session-duration which lets admins customize the default session
expiration for browser sessions.

Adds --disable-session-expiry-refresh which allows admins to prevent
session expiry from being automatically bumped upon the API key being
used.
2023-02-03 17:38:36 +00:00
mh013370 2285a5e8a0 feat: add ability to deploy extra k8s yamls with helm chart (#5942) 2023-02-03 17:32:02 +00:00
Steven Masley a750b1948b test: Use database.Now() for UTC in all tests (#6022)
Fixes a lot of comparison issues with timezones
2023-02-03 17:13:54 +00:00
Mathias Fredriksson 95ff29c2be test: Fix golden gen for long wd paths (#6021) 2023-02-03 18:43:33 +02:00
Bruno Quaresma dffd7953bc fix(site): Minor UI fixes related to avatar components (#6019) 2023-02-03 13:09:16 -03:00
Kira Pilot 6c90701a73 chore: ignore trend line in storybook (#6003) 2023-02-03 09:05:38 -05:00
Steven Masley aab9e3a0f7 chore: Add more objects to dbgen (#6013)
* chore: Add WorkspaceApps to dbgen

* GitSSHKey, UserLink, GitAuthLink
2023-02-03 09:34:35 +00:00
Steven Masley fd2f9dc176 test: Fix unit test to use dbfake over databasefake (#6014) 2023-02-03 03:55:26 +00:00
Kyle Carberry 381d6674ca chore: add install_source to telemetry (#6008)
This will help determine the number of installs from marketplaces!
2023-02-03 01:30:54 +00:00
Steven Masley 8b424f03c2 chore: Rename databasefake --> dbfake (#6011) 2023-02-02 19:28:55 -06:00
Bruno Quaresma f60f06e2c6 fix(site): Only display fields with redisplay enabled during workspace creation (#6004)
* fix(site): Only display fields with redisplay_value during workspace creation

* Fix test

---------

Co-authored-by: Kyle Carberry <kyle@carberry.com>
2023-02-03 01:28:47 +00:00
Steven Masley 2384e9c565 fix: Do not try and write to malformed ssh configs (#6000)
Abort writing the ssh config if it has:
- Out of order coder headers
- More than 1 of each coder header
- Missing 1 or the other coder header
2023-02-02 19:23:42 -06:00
Ben Potter c16b93847a feat: build Packer images every release (#6009) 2023-02-03 01:01:06 +00:00
Steven Masley 2478012827 chore: Add organizationmember and parameter gen functions (#6007) 2023-02-02 18:24:59 -06:00
Steven Masley 41e52310bf chore: Add more dbgen functions (#6005) 2023-02-02 17:21:29 -06:00
Steven Masley 5fe4819669 chore: Pass through AllowUserCancelWorkspaceJobs value to fake db insert (#6006) 2023-02-02 23:08:57 +00:00
Kyle Carberry a5e8911d67 fix: index template versions by template and name (#5993)
* fix: index template versions by template and name

We were incorrectly returning template versions by name relative
to organizations. This could result in an incorrect version being
returned if multiple templates had versions with the same name.

* Fix auth referencing

* Fix route location

* Fix authorize route name

* Fix previous call

* Fix authorize route name
2023-02-02 15:47:53 -06:00
Bruno Quaresma ea7e55fcf9 refactor(site): Adjust icon sizes and show password btn in login page (#5992) 2023-02-02 18:28:30 -03:00
Bruno Quaresma 9ff313a260 refactor(site): Remove version and add template link in workspaces page (#5990) 2023-02-02 18:27:56 -03:00
Kira Pilot a70e722e7f fix: hide IP on workspace build logs (#5997) 2023-02-02 20:22:36 +00:00
Kyle Carberry 2fab310ca4 fix: match ubuntu tags on dogfood (#5996)
* fix: match ubuntu tags on dogfood

golangci-lint needs GLIBC>=2.32 which made me notice
that we're building binaries on a difference Ubuntu
version than we dev on.

* Fix sources
2023-02-02 13:58:53 -06:00
Colin Adler 496138b086 feat: assign users to groups returned by OIDC provider (#5965) 2023-02-02 19:53:48 +00:00
Kyle Carberry 026b1cd2a4 chore: update to go 1.20 (#5968)
Co-authored-by: Colin Adler <colin1adler@gmail.com>
2023-02-02 12:36:27 -06:00
Bruno Quaresma 4df1031f8b refactor(site): Add press any key option to reconnect on terminal screen (#5969) 2023-02-02 15:19:18 -03:00
Bruno Quaresma 7b49517c18 refactor(site): Add more info on agent outdated tooltip and update action (#5967) 2023-02-02 15:18:59 -03:00
dependabot[bot] 5f089cb5eb chore: bump aquasecurity/trivy-action from 0.5.0 to 0.8.0 (#5944)
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.5.0 to 0.8.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/7b7aa264d83dc58691451798b4d117d53d21edfe...9ab158e8597f3b310480b9a69402b419bc03dbd5)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 12:11:09 -06:00
dependabot[bot] 5d9263f050 chore: bump chromatic from 6.11.4 to 6.15.0 in /site (#5954)
Bumps [chromatic](https://github.com/chromaui/chromatic-cli) from 6.11.4 to 6.15.0.
- [Release notes](https://github.com/chromaui/chromatic-cli/releases)
- [Changelog](https://github.com/chromaui/chromatic-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chromaui/chromatic-cli/compare/v6.11.4...v6.15.0)

---
updated-dependencies:
- dependency-name: chromatic
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 12:06:51 -06:00
dependabot[bot] e6426d477f chore: bump google.golang.org/grpc from 1.51.0 to 1.52.3 (#5949)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.51.0 to 1.52.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.51.0...v1.52.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 12:05:41 -06:00
dependabot[bot] f5242be0d1 chore: bump github.com/valyala/fasthttp from 1.43.0 to 1.44.0 (#5951)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.43.0 to 1.44.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.43.0...v1.44.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 12:05:30 -06:00
Kyle Carberry 944c9f6307 chore: downgrade linux runner size to 8 cores (#5984)
I doubt the difference in speed is worth the 2x cost.
2023-02-02 12:00:19 -06:00
Kyle Carberry be00e2541c chore: remove unused workspace_owner_count field (#5958)
This added unnecessary database load, because it's not used!
2023-02-02 17:59:43 +00:00
dependabot[bot] 92c5be971c chore: bump @typescript-eslint/eslint-plugin in /site (#5955)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 5.38.1 to 5.50.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v5.50.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:45:48 -06:00
dependabot[bot] c4b70f3ae1 chore: bump eslint from 8.29.0 to 8.33.0 in /site (#5956)
Bumps [eslint](https://github.com/eslint/eslint) from 8.29.0 to 8.33.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.29.0...v8.33.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:45:29 -06:00
dependabot[bot] 57ad53c850 chore: bump github.com/moby/moby (#5947)
Bumps [github.com/moby/moby](https://github.com/moby/moby) from 20.10.21+incompatible to 20.10.23+incompatible.
- [Release notes](https://github.com/moby/moby/releases)
- [Commits](https://github.com/moby/moby/compare/v20.10.21...v20.10.23)

---
updated-dependencies:
- dependency-name: github.com/moby/moby
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:44:55 -06:00
dependabot[bot] f545586320 chore: bump ludeeus/action-shellcheck from 1.1.0 to 2.0.0 (#5945)
Bumps [ludeeus/action-shellcheck](https://github.com/ludeeus/action-shellcheck) from 1.1.0 to 2.0.0.
- [Release notes](https://github.com/ludeeus/action-shellcheck/releases)
- [Commits](https://github.com/ludeeus/action-shellcheck/compare/1.1.0...2.0.0)

---
updated-dependencies:
- dependency-name: ludeeus/action-shellcheck
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:44:43 -06:00
dependabot[bot] e8e61250a6 chore: bump docker/build-push-action from 3 to 4 (#5946)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:44:27 -06:00
dependabot[bot] 445811b0e0 chore: bump golang.org/x/crypto from 0.3.0 to 0.5.0 (#5950)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.3.0 to 0.5.0.
- [Release notes](https://github.com/golang/crypto/releases)
- [Commits](https://github.com/golang/crypto/compare/v0.3.0...v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-02 11:44:14 -06:00
Kyle Carberry b9b402cd0c feat!: generate a self-signed certificate if no certificates are specified (#5973)
* feat: generate a self-signed certificate if no certificates are specified

Clouds like AWS automatically navigate to https://<ip-here>. This
allows us to bind to that immediately, serve a self-signed certificate,
then reroute to the access URL.

* Add new flag and deprecate old one

* Fix redirect if not using tunnel

* Add deprecation notice

* Fix TLS redirect

* Run `make gen`

* Fix bad test

* Fix gen
2023-02-02 17:08:35 +00:00
Marcin Tojek e27f7accd7 feat: Group immutable and mutable rich parameters (#5975) 2023-02-02 18:01:18 +01:00
Steven Masley ab1f6ce090 fix: Deleting a user from a group should only delete from a single group (#5977) 2023-02-02 09:46:42 -06:00
Marcin Tojek 271d68c862 feat: Disallow using legacy params with rich params (#5974)
* feat: Disallow using legacy params with rich params

* Fix

* nolint
2023-02-02 16:44:57 +01:00
Bruno Quaresma 01ebfdc9dd fiix(site): Fix custom logo size in Safari (#5966) 2023-02-02 10:42:05 -03:00
Bruno Quaresma c9f3acabd3 fix(site): Don't hide/show errors during requests (#5962) 2023-02-02 10:38:05 -03:00
Bruno Quaresma 8ef0306c08 Revert "docs: Change console to shell and remove unused elements (#5960)" (#5964)
This reverts commit 61dcf643e8.
2023-02-01 17:05:45 -03:00
Ben Potter 936bd5b231 chore: fix broken link in offline docs 2023-02-01 13:16:46 -06:00
Ben Potter bca6244c4e chore: change docs links to latest release 2023-02-01 13:14:49 -06:00
Bruno Quaresma 61dcf643e8 docs: Change console to shell and remove unused elements (#5960) 2023-02-01 18:27:11 +00:00
Mathias Fredriksson e6f5623627 chore: Rename agent statistics server to http api server (#5961) 2023-02-01 20:05:57 +02:00
Marcin Tojek f9ae105a26 feat: Adjust forms to include Rich Parameters (#5856)
* XService: GetTemplateParameters

* Rich parameter input shows up

* Render option icons

* Icons

* WIP

* For testing purposes: template

* Fix: useState

* WIP: dynamic validation

* Yup validation

* Translations

* Remove temporary template

* make fmt

* WIP

* Fix: tests

* Fix: fmt

* URL param

* Refactor

* Test: rich param value

* Storybook

* Fix

* Refactor for testing purposes

* Typo

* test: string validation

* Button: build parameters

* Full screen page

* Fix: navigate

* XState done

* refactor: postWorkspaceBuild

* RichParameterInput rendered

* Fix: bad initial value

* Validation works

* Maybe

* Fix

* Go back button

* GoBack button

* Form

* Fix

* Storybook

* Fix: CreateWorkspacePage

* fmt

* Test

* ns

* fmt

* All tests

* feat: WorkspaceActions depend on template parameters

* Fix
2023-02-01 18:13:11 +01:00
Ben Potter d5e2454b1b chore: rework docker in docker docs (#5876)
* chore: rework docker in docker docs

* Update docs/templates/docker-in-docker.md

Co-authored-by: Kyle Carberry <kyle@coder.com>

* fix typos

---------

Co-authored-by: Kyle Carberry <kyle@coder.com>
2023-02-01 14:55:57 +00:00
Mathias Fredriksson 52ace4b207 fix(agent): Work around lumberjack reopening log file after close (#5941) 2023-02-01 16:04:09 +02:00
Ammar Bandukwala 89bf8dd169 docs: fix broken link in secrets.md
Resolves #5888
2023-01-31 22:49:08 +00:00
Steven Masley 4a6fc40949 feat: Add database data generator to make fakedbs easier to populate (#5922)
* feat: Add database data generator to make fakedbs easier to populate
2023-01-31 15:10:03 -06:00
Jon Ayers c162c0f284 fix: omit users for 'Everyone' group in response (#5937) 2023-01-31 13:30:20 -06:00
Arthur Normand 69fce0488e feat: Allow hiding password auth, changing OpenID Connect text and OpenID Connect icon (#5101)
* Allow hiding password entry, changing OpenID Connect text and OpenID Connect icon

* Docs

* Cleaning

* Fix Prettier and Go test and TS compile error

* Fix LoginPage test

* Prettier

* Fix storybook

* Add query param to un-hide password auth

* Cleaning

* Hide password by default when OIDC enabled

* Ran prettier, updated goldenfiles and ran "make gen"

* Fixed and added LoginPage test

* Ran prettier

* PR Feedback and split up SignInForm.tsx

* Updated golden files

* Fix auto-genned-files

* make gen -B

* Revert provisioner files?

* Fix lint error

---------

Co-authored-by: Kyle Carberry <kyle@coder.com>
2023-01-31 18:33:25 +00:00
Dean Sheather 480f3b6e43 fix: correct gpg key secret name (#5932) 2023-02-01 04:14:05 +10:00
Ben Potter aa53b86a2d chore: add some helpers to install/upgrade Coder (#5926)
* chore: add some helpers to install/upgrade Coder

* mention winget
2023-01-31 17:33:42 +00:00
Ben Potter ea4a845248 docs: dedicated instructions for Windows installer (#5924)
* docs: dedicated instructions for Windows installer

* changes from feedback
2023-01-31 17:26:37 +00:00
Spike Curtis ac4adabb0a feat: set service account name in helm (#5913)
Signed-off-by: Spike Curtis <spike@coder.com>
2023-01-31 10:35:32 +04:00
Eric Paulsen 5290d5b14a docs: add gitauth validate url var (#5927) 2023-01-30 20:35:22 -05:00
Kyle Carberry 9c1d67e192 docs: support dark and light mode in the readme (#5928)
Shoutout @cmoog
2023-01-30 16:53:54 -06:00
Eric Paulsen e6a3ce7180 docs: add GitLab auth docs (#5923) 2023-01-30 16:14:18 -05:00
Kira Pilot b31b0fd189 fix: audit log broken build links (#5895)
* pushing for guidance

* added test

* PR feedback

* fixed tests

* Update coderd/audit.go

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* runnig make gen

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2023-01-30 18:43:29 +00:00
Kira Pilot 88b5d42967 chore: add more specificity to check_enterprise_imports script (#5920) 2023-01-30 12:00:19 -05:00
Mathias Fredriksson f4d6afb01d feat(agent): Allow specifying log directory via flag or env (#5915) 2023-01-30 18:39:52 +02:00
Kira Pilot fa5b6125a9 fix: ensure fields show up for group resource in docs (#5918)
* fix: ensure fields show up for group resource  in docs

* ran prettier
2023-01-30 10:54:58 -05:00
Marcin Tojek 23176bf036 feat: Support rich parameters in autobuilds (#5916) 2023-01-30 15:34:38 +01:00
Mathias Fredriksson cf8d4029fb feat(agent): Handle signals and shutdown gracefully (#5914)
This change allows the agent to handle common shutdown signals like
interrupt, hangup and terminate and initiate a graceful shutdown.

As long as terraform providers initiate graceful shutdowns via the
aforementioned signals, things like SSH connections will be closed
immediately on shutdown instead of being left hanging/timing out due to
the agent being abruptly killed.

Refs: #4677, #5901
2023-01-30 15:59:13 +02:00
sharkymark 91ef8d90d5 chore: remove code-server release limitation and add image push policy in kubernetes example template (#5831)
* chore: remove code-server release limitation and add image push policy

* restore code-server changes

* chore: remove spaces in pod space

* Format terraform

---------

Co-authored-by: Eric Paulsen <ericpaulsen@coder.com>
Co-authored-by: Kyle Carberry <kyle@carberry.com>
2023-01-30 06:58:45 -05:00
Kyle Carberry 896158c352 fix: remove authentication requirement for /users/login (#5717)
This was noticed by a member of our Discord being incorrectly documented!

https://discord.com/channels/747933592273027093/1063494965130432632/1063503805016182875
2023-01-30 02:46:04 +00:00
dependabot[bot] f5db4bc8be chore: bump ua-parser-js from 1.0.2 to 1.0.33 in /site (#5842)
Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 1.0.2 to 1.0.33.
- [Release notes](https://github.com/faisalman/ua-parser-js/releases)
- [Changelog](https://github.com/faisalman/ua-parser-js/blob/master/changelog.md)
- [Commits](https://github.com/faisalman/ua-parser-js/compare/1.0.2...1.0.33)

---
updated-dependencies:
- dependency-name: ua-parser-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-29 20:33:45 -06:00
mh013370 d5d9cc8d8a feat: add init containers to the helm chart (#5874)
* add init containers to coder deployment

* fix formatting issues
2023-01-29 20:02:55 -06:00
dependabot[bot] 3980f15340 chore: bump crate-ci/typos from 1.13.3 to 1.13.9 (#5906)
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.13.3 to 1.13.9.
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/v1.13.3...v1.13.9)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-30 01:56:34 +00:00
Ammar Bandukwala 4eaa2d63b0 chore(.github): relax stale bot (#5902)
At 60 days it was more spammy than helpful.
2023-01-29 19:52:44 -06:00
Kyle Carberry 12314d7dc5 update readme (#5911) 2023-01-29 19:45:00 -06:00
Kyle Carberry b423218615 chore: remove markdown-lint-check
It's broken for some unfortunate reason!
2023-01-29 23:34:52 +00:00
Kyle Carberry b4a1c32ed3 chore: run markdown-link-check before linting types 2023-01-29 23:29:57 +00:00
Kyle Carberry 0d08065488 fix: use a waitgroup to ensure all connections are cleaned up in agent (#5910)
* fix: use a waitgroup to ensure all connections are cleaned up in agent

There was a race where connections would be created at the same time as close.
The `net.Conn` produced by Tailscale doesn't close then the listener does.

* Remove accidental test
2023-01-29 17:20:30 -06:00
Kyle Carberry ce36a84dd5 fix: specify folder path for markdown-lint-check
This was checking node_modules I believe causing it to hang.
2023-01-29 22:36:47 +00:00
Kyle Carberry a911ddaa7b fix: access GetUserByID in database fake without lock to resolve race (#5909)
See: https://github.com/coder/coder/actions/runs/4038615993/jobs/6942750837
2023-01-29 16:03:29 -06:00
Kyle Carberry 7ad87505c8 chore: move agent functions from codersdk into agentsdk (#5903)
* chore: rename `AgentConn` to `WorkspaceAgentConn`

The codersdk was becoming bloated with consts for the workspace
agent that made no sense to a reader. `Tailnet*` is an example
of these consts.

* chore: remove `Get` prefix from *Client functions

* chore: remove `BypassRatelimits` option in `codersdk.Client`

It feels wrong to have this as a direct option because it's so infrequently
needed by API callers. It's better to directly modify headers in the two
places that we actually use it.

* Merge `appearance.go` and `buildinfo.go` into `deployment.go`

* Merge `experiments.go` and `features.go` into `deployment.go`

* Fix `make gen` referencing old type names

* Merge `error.go` into `client.go`

`codersdk.Response` lived in `error.go`, which is wrong.

* chore: refactor workspace agent functions into agentsdk

It was odd conflating the codersdk that clients should use
with functions that only the agent should use. This separates
them into two SDKs that are closely coupled, but separate.

* Merge `insights.go` into `deployment.go`

* Merge `organizationmember.go` into `organizations.go`

* Merge `quota.go` into `workspaces.go`

* Rename `sse.go` to `serversentevents.go`

* Rename `codersdk.WorkspaceAppHostResponse` to `codersdk.AppHostResponse`

* Format `.vscode/settings.json`

* Fix outdated naming in `api.ts`

* Fix app host response

* Fix unsupported type

* Fix imported type
2023-01-29 15:47:24 -06:00
Kyle Carberry e49f41652f chore: merge codeql checks to run in parallel (#5907)
* chore: merge codeql checks to run in parallel

This reduces a check and should maintain ~the same CI time.

* fix: close reconnecting pty conn when exiting agent

Fixes https://github.com/coder/coder/actions/runs/4038282899/jobs/6942170850

* Fix closing when agent fails

* Fix conpty

* Fix contrib

* Skip runner tests for being flakes

* Fix gpg key test

* Fix golden files

* Fix comments

* Fix closed

* Fix capitalized title

* Add a timeout when checking for dead links
2023-01-29 15:28:22 -06:00
Kyle Carberry 8487127f5c chore: skip reconnecting pty scale tests (#5908)
* fix: close reconnecting pty conn when exiting agent

Fixes https://github.com/coder/coder/actions/runs/4038282899/jobs/6942170850

* Fix conpty

* Fix contrib

* Skip runner tests for being flakes

* Fix gpg key test

* Fix golden files

* Fix comments
2023-01-29 14:53:49 -06:00
Kyle Carberry 33c6260efb chore: merge CI linting jobs (#5904)
* chore: merge CI linting jobs

* Merge the `markdown-lint-check` step into `lint`

* Rename `coder.yaml` to `ci.yaml`

* Improve casing of security workflow

* Remove unused workflows and merge into contrib

* Format ci.yaml

* Fix CodeQL language

* Fix github action name
2023-01-29 13:16:48 -06:00
Kyle Carberry eaf1b95e70 chore: disable auto generated tag for CLI docs (#5905)
This was causing `make gen` to fail as the days went on!
2023-01-29 12:58:12 -06:00
sharkymark 2312bc4a6e docs: added alpha jetbrains gateway coder plugin | remove code-server version parameter (#5900) 2023-01-28 19:09:41 -06:00
Kyle Carberry 7880b941b8 docs: remove quote from README 2023-01-27 21:24:54 -06:00
Mathias Fredriksson a1212014df chore(examples): Add login_before_ready and startup_script_timeout (#5880) 2023-01-27 21:01:48 +00:00
Mathias Fredriksson 90c4d5d28a docs: Document agent readiness issues (startup script) (#5877)
Refs: #5749, #5851
2023-01-27 22:23:47 +02:00
Mathias Fredriksson 981cac5e28 chore: Invert delay_login_until_ready, now login_before_ready (#5893) 2023-01-27 20:07:47 +00:00
Kira Pilot 8a5760a2fe chore: replace AuditableGroup label in docs (#5894) 2023-01-27 13:36:00 -05:00
Bruno Quaresma e0d48e7d79 docs: Add CLI docs (#5879) 2023-01-27 18:10:40 +00:00
Mathias Fredriksson a753703e47 feat(cli): Add support for delay_login_until_ready (#5851) 2023-01-27 19:05:40 +02:00
Kira Pilot cf93fbd39a chore: update Audit docs to include Audit Actions (#5887)
* chore: update Audit docs to include Audit Actions

* regenerated audit docs

* adjusted check_enterprise_imports.sh

* PR feedback

* changing script back for now as CI faiiling
2023-01-27 11:50:21 -05:00
Kyle Carberry b20cb993bd fix: loop variable captured by func literal in parameters test (#5878)
I noticed this when adding a new parameter type. There's a test
case for an empty string that returned false for validation,
but appears like it could be true.

If there is no value for a string, then the default is used.
In this case there is no default, but that's technically fine
I believe.
2023-01-27 09:37:55 -06:00
Kira Pilot e663eaad96 chore: remove flaking e2e test (#5884) 2023-01-26 23:10:01 -03:00
1341 changed files with 94419 additions and 34948 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ RUN mkdir -p /etc/apt/keyrings \
&& echo '{"cgroup-parent":"/actions_job","storage-driver":"vfs"}' >> /etc/docker/daemon.json
# install golang and language tooling
ENV GO_VERSION=1.19
ENV GO_VERSION=1.20
ENV GOPATH=$HOME/go-packages
ENV GOROOT=$HOME/go
ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH
-3
View File
@@ -1,3 +0,0 @@
<!--
Check if your change requires documentation edits before merging: https://coder.com/docs/coder. Make edits in `docs/`.
-->
@@ -1,4 +1,4 @@
name: coder
name: ci
on:
push:
@@ -28,22 +28,73 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
typos:
runs-on: ubuntu-latest
lint:
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@v2
- name: typos-action
uses: crate-ci/typos@v1.13.3
uses: actions/checkout@v3
# Install Go!
- uses: actions/setup-go@v4
with:
go-version: "~1.20"
# Check for any typos!
- name: Check for typos
uses: crate-ci/typos@v1.14.3
with:
config: .github/workflows/typos.toml
- name: Fix Helper
- name: Fix the typos
if: ${{ failure() }}
run: |
echo "::notice:: you can automatically fix typos from your CLI:
cargo install typos-cli
typos -c .github/workflows/typos.toml -w"
# Check for Go linting errors!
- name: Lint Go
uses: golangci/golangci-lint-action@v3.3.1
with:
version: v1.51.0
- name: Lint shell scripts
uses: ludeeus/action-shellcheck@2.0.0
env:
SHELLCHECK_OPTS: --external-sources
with:
ignore: node_modules
# Lint our dashboard!
- name: Cache node_modules
id: cache-node
uses: actions/cache@v3
with:
path: |
**/node_modules
.eslintcache
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
js-${{ runner.os }}-
- name: Install node_modules
run: ./scripts/yarn_install.sh
- name: Lint TypeScript
run: yarn lint
working-directory: site
# Make sure the Helm chart is linted!
- name: Install helm
uses: azure/setup-helm@v3
with:
version: v3.9.2
- name: Lint Helm chart
run: |
cd helm
make lint
# Ensure AGPL and Enterprise are separated!
- name: Check for AGPL code importing Enterprise...
run: ./scripts/check_enterprise_imports.sh
changes:
runs-on: ubuntu-latest
outputs:
@@ -70,108 +121,16 @@ jobs:
- 'site/**'
k8s:
- 'helm/**'
- Dockerfile
- scripts/Dockerfile
- scripts/Dockerfile.base
- scripts/helm.sh
- id: debug
run: |
echo "${{ toJSON(steps.filter )}}"
# Debug step
debug-inputs:
needs:
- changes
runs-on: ubuntu-latest
steps:
- id: log
run: |
echo "${{ toJSON(needs) }}"
style-lint-golangci:
name: style/lint/golangci
timeout-minutes: 5
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "~1.19"
- name: golangci-lint
uses: golangci/golangci-lint-action@v3.3.1
with:
version: v1.48.0
check-enterprise-imports:
name: check/enterprise-imports
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check imports of enterprise code
run: ./scripts/check_enterprise_imports.sh
style-lint-shellcheck:
name: style/lint/shellcheck
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@1.1.0
env:
SHELLCHECK_OPTS: --external-sources
with:
ignore: node_modules
style-lint-typescript:
name: "style/lint/typescript"
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Cache Node
id: cache-node
uses: actions/cache@v3
with:
path: |
**/node_modules
.eslintcache
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
js-${{ runner.os }}-
- name: Install node_modules
run: ./scripts/yarn_install.sh
- name: "yarn lint"
run: yarn lint
working-directory: site
style-lint-k8s:
name: "style/lint/k8s"
timeout-minutes: 5
needs: changes
if: needs.changes.outputs.k8s == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install helm
uses: azure/setup-helm@v3
with:
version: v3.9.2
- name: cd helm && make lint
run: |
cd helm
make lint
gen:
name: "style/gen"
timeout-minutes: 8
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.docs-only == 'false'
steps:
@@ -191,9 +150,9 @@ jobs:
- name: Install node_modules
run: ./scripts/yarn_install.sh
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -215,7 +174,7 @@ jobs:
- name: Install sqlc
run: |
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.16.0/sqlc_1.16.0_linux_amd64.tar.gz | sudo tar -C /usr/bin -xz sqlc
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.17.2/sqlc_1.17.2_linux_amd64.tar.gz | sudo tar -C /usr/bin -xz sqlc
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
- name: Install protoc-gen-go-drpc
@@ -227,8 +186,9 @@ jobs:
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile
# or the version in the comments will differ.
# protoc must be in lockstep with our dogfood Dockerfile or the
# version in the comments will differ. This is also defined in
# security.yaml
set -x
cd dogfood
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
@@ -243,8 +203,7 @@ jobs:
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
style-fmt:
name: "style/fmt"
fmt:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -280,8 +239,7 @@ jobs:
run: ./scripts/check_unstaged.sh
test-go:
name: "test/go"
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
timeout-minutes: 20
strategy:
matrix:
@@ -292,18 +250,15 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
# Sadly the new "set output" syntax (of writing env vars to
# $GITHUB_OUTPUT) does not work on both powershell and bash so we use the
# deprecated syntax here.
- name: Echo Go Cache Paths
id: go-cache-paths
run: |
echo "::set-output name=GOCACHE::$(go env GOCACHE)"
echo "::set-output name=GOMODCACHE::$(go env GOMODCACHE)"
echo "GOCACHE=$(go env GOCACHE)" >> ${{ runner.os == 'Windows' && '$env:' || '$' }}GITHUB_OUTPUT
echo "GOMODCACHE=$(go env GOMODCACHE)" >> ${{ runner.os == 'Windows' && '$env:' || '$' }}GITHUB_OUTPUT
- name: Go Build Cache
uses: actions/cache@v3
@@ -318,7 +273,7 @@ jobs:
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
- name: Install gotestsum
uses: jaxxstorm/action-install-gh-release@v1.9.0
uses: jaxxstorm/action-install-gh-release@v1.10.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -344,7 +299,14 @@ jobs:
echo "cover=false" >> $GITHUB_OUTPUT
fi
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=5m -short -failfast $COVERAGE_FLAGS
gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" --packages="./..." -- -parallel=8 -timeout=7m -short -failfast $COVERAGE_FLAGS
- name: Print test stats
if: success() || failure()
run: |
# Artifacts are not available after rerunning a job,
# so we need to print the test stats to the log.
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
- uses: actions/upload-artifact@v3
if: success() || failure()
@@ -365,9 +327,8 @@ jobs:
files: ./gotests.coverage
flags: unittest-go-${{ matrix.os }}
test-go-postgres:
name: "test/go/postgres"
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
test-go-psql:
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
# This timeout must be greater than the timeout set by `go test` in
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
@@ -376,9 +337,9 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -399,7 +360,7 @@ jobs:
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
- name: Install gotestsum
uses: jaxxstorm/action-install-gh-release@v1.9.0
uses: jaxxstorm/action-install-gh-release@v1.10.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -415,6 +376,13 @@ jobs:
run: |
make test-postgres
- name: Print test stats
if: success() || failure()
run: |
# Artifacts are not available after rerunning a job,
# so we need to print the test stats to the log.
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
- uses: actions/upload-artifact@v3
if: success() || failure()
with:
@@ -436,7 +404,7 @@ jobs:
deploy:
name: "deploy"
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
timeout-minutes: 30
needs: changes
if: |
@@ -459,9 +427,9 @@ jobs:
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v1
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -532,8 +500,7 @@ jobs:
retention-days: 7
test-js:
name: "test/js"
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
@@ -551,12 +518,12 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: "14"
node-version: "16.16.0"
- name: Install node_modules
run: ./scripts/yarn_install.sh
- run: yarn test:ci
- run: yarn test:ci --max-workers ${{ steps.cpu-cores.outputs.count }}
working-directory: site
- uses: codecov/codecov-action@v3
@@ -572,16 +539,11 @@ jobs:
flags: unittest-js
test-e2e:
name: "test/e2e/${{ matrix.os }}"
needs:
- changes
if: needs.changes.outputs.docs-only == 'false'
runs-on: ${{ matrix.os }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
timeout-minutes: 20
strategy:
matrix:
os:
- ubuntu-latest
steps:
- uses: actions/checkout@v3
@@ -594,9 +556,9 @@ jobs:
.eslintcache
key: js-${{ runner.os }}-e2e-${{ hashFiles('**/yarn.lock') }}
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- uses: hashicorp/setup-terraform@v2
with:
@@ -605,7 +567,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: "14"
node-version: "16.16.0"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -633,9 +595,6 @@ jobs:
- run: yarn playwright:install
working-directory: site
- run: yarn playwright:install-deps
working-directory: site
- run: yarn playwright:test
env:
DEBUG: pw:api
@@ -662,6 +621,10 @@ jobs:
# only get 1 commit on shallow checkout.
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: "16.16.0"
- name: Install dependencies
run: cd site && yarn
@@ -693,23 +656,3 @@ jobs:
buildScriptName: "storybook:build"
projectToken: 695c25b6cb65
workingDir: "./site"
markdown-link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# For the main branch:
- if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: yes
use-verbose-mode: yes
config-file: .github/workflows/mlc_config.json
# For pull requests:
- if: github.ref != 'refs/heads/main' || github.event.pull_request.head.repo.fork
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: yes
use-verbose-mode: yes
check-modified-files-only: yes
base-branch: main
config-file: .github/workflows/mlc_config.json
-26
View File
@@ -1,26 +0,0 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
with:
remote-organization-name: "coder"
remote-repository-name: "cla"
path-to-signatures: "v2022-09-04/signatures.json"
path-to-document: "https://github.com/coder/cla/blob/main/README.md"
# branch should not be protected
branch: "main"
allowlist: dependabot*
@@ -1,8 +1,13 @@
name: Pull Request
name: contrib
on:
issue_comment:
types: [created]
pull_request_target:
types:
- opened
- closed
- synchronize
- labeled
- unlabeled
- opened
@@ -13,23 +18,52 @@ on:
concurrency: pr-${{ github.ref }}
jobs:
lint-title:
name: Lint title
# Dependabot is annoying, but this makes it a bit less so.
auto-approve-dependabot:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
permissions:
pull-requests: write
steps:
- uses: hmarr/auto-approve-action@v3
if: github.actor == 'dependabot[bot]'
cla:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- name: cla
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
with:
remote-organization-name: "coder"
remote-repository-name: "cla"
path-to-signatures: "v2022-09-04/signatures.json"
path-to-document: "https://github.com/coder/cla/blob/main/README.md"
# branch should not be protected
branch: "main"
allowlist: dependabot*
title:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
steps:
- name: Validate PR title
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
requireScope: false
release-labels:
name: Release labels
runs-on: ubuntu-latest
# Depend on lint so that title is Conventional Commits-compatible.
needs: [lint-title]
needs: [title]
# Skip tagging for draft PRs.
if: ${{ success() && !github.event.pull_request.draft }}
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
steps:
- uses: actions/github-script@v6
with:
-13
View File
@@ -1,13 +0,0 @@
# Dependabot is annoying, but this makes it a bit less so.
name: Auto Approve Dependabot
on: pull_request_target
jobs:
auto-approve:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: hmarr/auto-approve-action@v3
if: github.actor == 'dependabot[bot]'
+90
View File
@@ -0,0 +1,90 @@
name: docker-base
on:
push:
branches:
- main
paths:
- scripts/Dockerfile.base
- scripts/Dockerfile
schedule:
# Run every week at 09:43 on Monday, Wednesday and Friday. We build this
# frequently to ensure that packages are up-to-date.
- cron: "43 9 * * 1,3,5"
workflow_dispatch:
permissions:
contents: read
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for depot.dev authentication.
id-token: write
# Avoid running multiple jobs for the same commit.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-docker-base
jobs:
build:
runs-on: ubuntu-latest
if: github.repository_owner == 'coder'
steps:
- uses: actions/checkout@v3
- name: Docker login
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create empty base-build-context directory
run: mkdir base-build-context
- name: Install depot.dev CLI
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
pull: true
no-cache: true
push: true
tags: |
ghcr.io/coder/coder-base:latest
- name: Verify that images are pushed properly
run: |
# retry 10 times with a 5 second delay as the images may not be
# available immediately
for i in {1..10}; do
rc=0
raw_manifests=$(docker buildx imagetools inspect --raw ghcr.io/coder/coder-base:latest) || rc=$?
if [[ "$rc" -eq 0 ]]; then
break
fi
if [[ "$i" -eq 10 ]]; then
echo "Failed to pull manifests after 10 retries"
exit 1
fi
echo "Failed to pull manifests, retrying in 5 seconds"
sleep 5
done
manifests=$(
echo "$raw_manifests" | \
jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)'
)
# Verify all 3 platforms are present.
set -euxo pipefail
echo "$manifests" | grep -q linux/amd64
echo "$manifests" | grep -q linux/arm64
echo "$manifests" | grep -q linux/arm/v7
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: "{{defaultContext}}:dogfood"
push: true
+16
View File
@@ -0,0 +1,16 @@
# Filtering pull requests is much easier when we can reliably guarantee
# that the "Assignee" field is populated.
name: PR Auto Assign
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
assign-author:
runs-on: ubuntu-latest
steps:
- uses: toshimaru/auto-author-assign@v1.6.2
+88 -12
View File
@@ -32,7 +32,7 @@ env:
jobs:
release:
name: Build and publish
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
env:
# Necessary for Docker manifest
DOCKER_CLI_EXPERIMENTAL: "enabled"
@@ -63,6 +63,7 @@ jobs:
- name: Create release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# We always have to set this since there might be commits on
# main that didn't have a PR.
CODER_IGNORE_MISSING_COMMIT_METADATA: "1"
@@ -89,9 +90,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Cache Node
id: cache-node
@@ -112,17 +113,17 @@ jobs:
set -euo pipefail
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.18.1/nfpm_amd64.deb
sudo dpkg -i /tmp/nfpm.deb
rm /tmp/nfpm.deb
- name: Install rcodesign
run: |
set -euo pipefail
# Install a prebuilt binary of rcodesign for linux amd64. Once the
# following PR is merged and released upstream, we can download
# directly from GitHub releases instead:
# https://github.com/indygreg/PyOxidizer/pull/635
wget -O /tmp/rcodesign https://cdn.discordapp.com/attachments/283356472258199552/1016767245717872700/rcodesign
sudo install --mode 755 /tmp/rcodesign /usr/local/bin/rcodesign
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz
sudo tar -xzf /tmp/rcodesign.tar.gz \
-C /usr/bin \
--strip-components=1 \
apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Setup Apple Developer certificate and API key
run: |
@@ -160,6 +161,69 @@ jobs:
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
- name: Determine base image tag
id: image-base-tag
run: |
set -euo pipefail
if [[ "${CODER_RELEASE:-}" != *t* ]] || [[ "${CODER_DRY_RUN:-}" == *t* ]]; then
# Empty value means use the default and avoid building a fresh one.
echo "tag=" >> $GITHUB_OUTPUT
else
echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> $GITHUB_OUTPUT
fi
- name: Create empty base-build-context directory
if: steps.image-base-tag.outputs.tag != ''
run: mkdir base-build-context
- name: Install depot.dev CLI
if: steps.image-base-tag.outputs.tag != ''
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
pull: true
no-cache: true
push: true
tags: |
${{ steps.image-base-tag.outputs.tag }}
- name: Verify that images are pushed properly
run: |
# retry 10 times with a 5 second delay as the images may not be
# available immediately
for i in {1..10}; do
rc=0
raw_manifests=$(docker buildx imagetools inspect --raw "${{ steps.image-base-tag.outputs.tag }}") || rc=$?
if [[ "$rc" -eq 0 ]]; then
break
fi
if [[ "$i" -eq 10 ]]; then
echo "Failed to pull manifests after 10 retries"
exit 1
fi
echo "Failed to pull manifests, retrying in 5 seconds"
sleep 5
done
manifests=$(
echo "$raw_manifests" | \
jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)'
)
# Verify all 3 platforms are present.
set -euxo pipefail
echo "$manifests" | grep -q linux/amd64
echo "$manifests" | grep -q linux/arm64
echo "$manifests" | grep -q linux/arm/v7
- name: Build Linux Docker images
run: |
set -euxo pipefail
@@ -188,6 +252,8 @@ jobs:
--target "$(./scripts/image_tag.sh --version latest)" \
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
fi
env:
CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }}
- name: ls build
run: ls -lh build
@@ -214,7 +280,7 @@ jobs:
./build/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.CODER_GPG_RELEASE_KEY_BASE64 }}
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v1
@@ -236,10 +302,11 @@ jobs:
helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/coder_helm_${version}.tgz gs://helm.coder.com/v2
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/index.yaml gs://helm.coder.com/v2
gsutil -h "Cache-Control:no-cache,max-age=0" cp helm/artifacthub-repo.yml gs://helm.coder.com/v2
- name: Upload artifacts to actions (if dry-run)
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: release-artifacts
path: |
@@ -252,6 +319,15 @@ jobs:
./build/*.rpm
retention-days: 7
- name: Start Packer builds
if: ${{ !inputs.dry_run }}
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
repository: coder/packages
event-type: coder-release
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
publish-winget:
name: Publish to winget-pkgs
runs-on: windows-latest
+69 -35
View File
@@ -1,4 +1,4 @@
name: "Security"
name: "security"
permissions:
actions: read
@@ -6,17 +6,11 @@ permissions:
security-events: write
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
schedule:
# Run every week at 10:24 on Thursday.
- cron: "24 10 * * 4"
# Run every 6 hours Monday-Friday!
- cron: "0 0,6,12,18 * * 1-5"
# Cancel in-progress runs for pull requests when developers push
# additional changes
@@ -26,36 +20,26 @@ concurrency:
jobs:
codeql:
name: CodeQL
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
language: ["go", "javascript"]
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
languages: go, javascript
- name: Setup Go
if: matrix.language == 'go'
uses: actions/setup-go@v3
uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Go Cache Paths
if: matrix.language == 'go'
id: go-cache-paths
run: |
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Go Mod Cache
if: matrix.language == 'go'
uses: actions/cache@v3
with:
path: ${{ steps.go-cache-paths.outputs.GOMODCACHE }}
@@ -63,26 +47,33 @@ jobs:
# Workaround to prevent CodeQL from building the dashboard.
- name: Remove Makefile
if: matrix.language == 'go'
run: |
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
- name: Send Slack notification on failure
if: ${{ failure() }}
run: |
msg="❌ CodeQL Failed\n\nhttps://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl \
-qfsSL \
-X POST \
-H "Content-Type: application/json" \
--data "{\"content\": \"$msg\"}" \
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
trivy:
name: Trivy
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-8-cores' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "~1.19"
go-version: "~1.20"
- name: Go Cache Paths
id: go-cache-paths
@@ -106,16 +97,47 @@ jobs:
restore-keys: |
js-${{ runner.os }}-
- name: Install yq
run: go run github.com/mikefarah/yq/v4@v4.30.6
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
- name: Install protoc-gen-go-drpc
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile or the
# version in the comments will differ. This is also defined in
# ci.yaml.
set -x
cd dogfood
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
protoc_path=/usr/local/bin/protoc
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
chmod +x $protoc_path
protoc --version
- name: Build Coder linux amd64 Docker image
id: build
run: |
set -euo pipefail
image_job="build/coder_$(./scripts/version.sh)_linux_amd64.tag"
DOCKER_IMAGE_NO_PREREQUISITES=true make -j "$image_job"
version="$(./scripts/version.sh)"
image_job="build/coder_${version}_linux_amd64.tag"
# This environment variable force make to not build packages and
# archives (which the Docker image depends on due to technical reasons
# related to concurrent FS writes).
export DOCKER_IMAGE_NO_PREREQUISITES=true
# This environment variables forces scripts/build_docker.sh to build
# the base image tag locally instead of using the cached version from
# the registry.
export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
make -j "$image_job"
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@7b7aa264d83dc58691451798b4d117d53d21edfe
uses: aquasecurity/trivy-action@1f0aa582c8c8f5f7639610d6d38baddfea4fdcee
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
@@ -126,10 +148,22 @@ jobs:
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: trivy-results.sarif
category: "Trivy"
- name: Upload Trivy scan results as an artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: trivy
path: trivy-results.sarif
retention-days: 7
- name: Send Slack notification on failure
if: ${{ failure() }}
run: |
msg="❌ CodeQL Failed\n\nhttps://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl \
-qfsSL \
-X POST \
-H "Content-Type: application/json" \
--data "{\"content\": \"$msg\"}" \
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
+20 -7
View File
@@ -1,35 +1,48 @@
name: Stale Issue Cron
name: Stale Issue and Branch Cleanup
on:
schedule:
# Every day at midnight
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
stale:
issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
# v5.1.0 has a weird bug that makes stalebot add then remove its own label
# https://github.com/actions/stale/pull/775
- uses: actions/stale@v7.0.0
- uses: actions/stale@v8.0.0
with:
stale-issue-label: "stale"
stale-pr-label: "stale"
days-before-stale: 90
# Pull Requests become stale more quickly due to merge conflicts.
# Also, we promote minimizing WIP.
days-before-pr-stale: 7
days-before-pr-close: 3
stale-pr-message: >
This Pull Request is becoming stale. In order to minimize WIP,
This Pull Request is becoming stale. In order to minimize WIP,
prevent merge conflicts and keep the tracker readable, I'm going
close to this PR in 3 days if there isn't more activity.
stale-issue-message: >
This issue is becoming stale. In order to keep the tracker readable
and actionable, I'm going close to this issue in 7 days if there
and actionable, I'm going close to this issue in 7 days if there
isn't more activity.
# Upped from 30 since we have a big tracker and was hitting the limit.
operations-per-run: 60
# Start with the oldest issues, always.
ascending: true
branches:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Run delete-old-branches-action
uses: beatlabs/delete-old-branches-action@v0.0.9
with:
repo_token: ${{ github.token }}
date: "6 months ago"
dry_run: false
delete_tags: false
# extra_protected_branch_regex: ^(foo|bar)$
exclude_open_pr_branches: true
+3
View File
@@ -3,8 +3,10 @@ alog = "alog"
Jetbrains = "JetBrains"
IST = "IST"
MacOS = "macOS"
AKS = "AKS"
[default.extend-words]
AKS = "AKS"
# do as sudo replacement
doas = "doas"
darcula = "darcula"
@@ -22,4 +24,5 @@ extend-exclude = [
# These files contain base64 strings that confuse the detector
"**XService**.ts",
"**identity.go",
"scripts/ci-report/testdata/**",
]
-18
View File
@@ -1,18 +0,0 @@
name: Welcome
on:
pull_request:
types: [opened]
jobs:
test:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: wow-actions/welcome@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FIRST_PR_REACTIONS: "+1, hooray, rocket, heart"
FIRST_PR_COMMENT: |
👋 Welcome @{{ author }} to Coder! Yo @coder/docs this is @{{ author }}'s first pull-request here!
FIRST_PR_MERGED: |
🎉 Thanks for the contribution @{{ author }}! Yo @coder/docs @{{ author }}'s first contribution has been merged! 👀👀👀
+5 -3
View File
@@ -6,7 +6,8 @@
**/*.swp
gotests.coverage
gotests.xml
gotestsum.json
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
@@ -27,9 +28,10 @@ site/test-results/*
site/e2e/test-results/*
site/e2e/states/*.json
site/playwright-report/*
site/.swc
# Make target for updating golden files.
cli/testdata/.gen-golden
# Make target for updating golden files (any dir).
.gen-golden
# Build
/build/
-2
View File
@@ -215,7 +215,6 @@ linters:
- asciicheck
- bidichk
- bodyclose
- deadcode
- dogsled
- errcheck
- errname
@@ -259,4 +258,3 @@ linters:
- typecheck
- unconvert
- unused
- varcheck
+5 -3
View File
@@ -9,7 +9,8 @@
**/*.swp
gotests.coverage
gotests.xml
gotestsum.json
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
@@ -30,9 +31,10 @@ site/test-results/*
site/e2e/test-results/*
site/e2e/states/*.json
site/playwright-report/*
site/.swc
# Make target for updating golden files.
cli/testdata/.gen-golden
# Make target for updating golden files (any dir).
.gen-golden
# Build
/build/
+8 -1
View File
@@ -1,8 +1,10 @@
{
"cSpell.words": [
"afero",
"agentsdk",
"apps",
"ASKPASS",
"authcheck",
"autostop",
"awsidentity",
"bodyclose",
@@ -88,7 +90,6 @@
"pqtype",
"prometheusmetrics",
"promhttp",
"promptui",
"protobuf",
"provisionerd",
"provisionerdserver",
@@ -112,6 +113,7 @@
"stretchr",
"STTY",
"stuntest",
"tanstack",
"tailbroker",
"tailcfg",
"tailexchange",
@@ -133,6 +135,7 @@
"thead",
"tios",
"tmpdir",
"tokenconfig",
"tparallel",
"trialer",
"trimprefix",
@@ -183,6 +186,10 @@
"files.exclude": {
"**/node_modules": true
},
"search.exclude": {
"scripts/metricsdocgen/metrics": true,
"docs/api/*.md": true
},
// Ensure files always have a newline.
"files.insertFinalNewline": true,
"go.lintTool": "golangci-lint",
+27 -4
View File
@@ -368,9 +368,15 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
cp "$<" "$$output_file"
.PHONY: install
fmt: fmt/prettier fmt/terraform fmt/shfmt
fmt: fmt/prettier fmt/terraform fmt/shfmt fmt/go
.PHONY: fmt
fmt/go:
# VS Code users should check out
# https://github.com/mvdan/gofumpt#visual-studio-code
go run mvdan.cc/gofumpt@v0.4.0 -w -l .
.PHONY: fmt/go
fmt/prettier:
echo "--- prettier"
cd site
@@ -418,6 +424,7 @@ gen: \
provisionerd/proto/provisionerd.pb.go \
site/src/api/typesGenerated.ts \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
.prettierignore.include \
@@ -437,6 +444,7 @@ gen/mark-fresh:
provisionerd/proto/provisionerd.pb.go \
site/src/api/typesGenerated.ts \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
.prettierignore.include \
@@ -492,6 +500,11 @@ docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/me
cd site
yarn run format:write:only ../docs/admin/prometheus.md
docs/cli.md: scripts/clidocgen/main.go $(GO_SRC_FILES) docs/manifest.json
BASE_PATH="." go run ./scripts/clidocgen
cd site
yarn run format:write:only ../docs/cli.md ../docs/cli/*.md ../docs/manifest.json
docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go
go run scripts/auditdocgen/main.go
cd site
@@ -501,13 +514,21 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
./scripts/apidocgen/generate.sh
yarn run --cwd=site format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json
update-golden-files: cli/testdata/.gen-golden
update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden
.PHONY: update-golden-files
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(GO_SRC_FILES)
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES)
go test ./cli -run=TestCommandHelp -update
touch "$@"
helm/tests/testdata/.gen-golden: $(wildcard helm/tests/testdata/*.golden) $(GO_SRC_FILES)
go test ./helm/tests -run=TestUpdateGoldenFiles -update
touch "$@"
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
touch "$@"
# Generate a prettierrc for the site package that uses relative paths for
# overrides. This allows us to share the same prettier config between the
# site and the root of the repo.
@@ -579,6 +600,7 @@ test-postgres: test-clean test-postgres-docker
# more consistent execution.
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
--junitfile="gotests.xml" \
--jsonfile="gotests.json" \
--packages="./..." -- \
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
-parallel=4 \
@@ -603,7 +625,8 @@ test-postgres-docker:
-c max_connections=1000 \
-c fsync=off \
-c synchronous_commit=off \
-c full_page_writes=off
-c full_page_writes=off \
-c log_statement=all
while ! pg_isready -h 127.0.0.1
do
echo "$(date) - waiting for database to start"
+72 -52
View File
@@ -1,49 +1,74 @@
# Coder — Your Self-Hosted Remote Development Platform
<div align="center">
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/logo-black.png" style="width: 128px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/logo-white.png" style="width: 128px">
</a>
[!["Join us on
Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=green)](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
<h1>
Self-Hosted Remote Development Environments
</h1>
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/banner-black.png" style="width: 650px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/banner-white.png" style="width: 650px">
</a>
<br>
<br>
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![codecov](https://codecov.io/gh/coder/coder/branch/main/graph/badge.svg?token=TNLW3OAP6G)](https://codecov.io/gh/coder/coder)
[![Go Reference](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![release](https://img.shields.io/github/v/release/coder/coder)](https://github.com/coder/coder/releases/latest)
[![godoc](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder)](https://goreportcard.com/report/github.com/coder/coder)
[![license](https://img.shields.io/github/license/coder/coder)](./LICENSE)
Offload your team's development from local workstations to cloud servers. Onboard developers in minutes. Build, test and compile at the speed of the cloud. Keep your source code and data behind your firewall.
</div>
> "By leveraging Terraform, Coder lets developers run any IDE on any compute platform including on-prem, AWS, Azure, GCP, DigitalOcean, Kubernetes, Docker, and more, with workspaces running on Linux, Windows, or Mac." - **Kevin Fishner Chief of Staff at [HashiCorp](https://hashicorp.com/)**
[Coder](https://coder.com) enables organizations to set up development environments in the cloud. Environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
- Define development environments in Terraform
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
- Automatically shutdown idle resources to save on costs
- Onboard developers in seconds instead of days
<p align="center">
<img src="./docs/images/hero-image.png">
</p>
## Highlights
## Quickstart
- Build and test faster
- Leveraging cloud CPUs, RAM, network speeds, etc.
- Access your environment from any place on any client (even an iPad)
- Onboard instantly then stay up to date continuously
The most convenient way to try Coder is to install it on your local machine and experiment with provisioning development environments using Docker (works on Linux, macOS, and Windows).
## Getting Started
```
# First, install Coder
curl -L https://coder.com/install.sh | sh
# Start the Coder server (caches data in ~/.cache/coder)
coder server
# Navigate to http://localhost:3000 to create your initial user
# Create a Docker template, and provision a workspace
```
## Install
The easiest way to install Coder is to use our
[install script](https://github.com/coder/coder/blob/main/install.sh) for Linux
and macOS. For Windows, use the latest `..._installer.exe` file from GitHub
Releases.
To install, run:
```bash
curl -L https://coder.com/install.sh | sh
```
You can preview what occurs during the install process:
```bash
curl -L https://coder.com/install.sh | sh -s -- --dry-run
```
You can modify the installation process by including flags. Run the help command for reference:
```bash
curl -L https://coder.com/install.sh | sh -s -- --help
```
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. You can modify the installation process by including flags. Run the install script with `--help` for reference.
> See [install](docs/install) for additional methods.
@@ -57,49 +82,44 @@ coder server
coder server --postgres-url <url> --access-url <url>
```
> <sup>1</sup> The automatic setup is great for trying out Coder with small deployments, but do consider using an external database for increased assurance and control.
> <sup>1</sup> For production deployments, set up an external PostgreSQL instance for reliability.
Use `coder --help` to get a complete list of flags and environment variables. Use our [quickstart guide](https://coder.com/docs/v2/latest/quickstart) for a full walkthrough.
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/guides) for a full walkthrough.
## Documentation
Visit our docs [here](https://coder.com/docs/v2).
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
## Templates
Find our templates [here](./examples/templates).
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
## Community and Support
Join our community on [Discord](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) and [Twitter](https://twitter.com/coderhq)!
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
[Suggest improvements and report problems](https://github.com/coder/coder/issues/new/choose)
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
## Contributing
Read the [contributing docs](https://coder.com/docs/v2/latest/CONTRIBUTING).
Contributions are welcome! Read the [contributing docs](https://coder.com/docs/v2/latest/CONTRIBUTING) to get started.
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
## Comparison
## Related
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to:
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
- [The Self-Hosting Paradox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
- [GitHub Codespaces, Coder, and Enterprise Customers](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
- [How our development team shares one giant bare metal machine](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
### Official
| Tool | Type | Delivery Model | Cost | Internet Access Required | Latency and Data Sovereignty | Security isolation model | Product quality | Service Availability | Environments | IDE |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Coder](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Unopinionated (whatever/wherever you choose to deploy thus 100% configurable) | [Defect history](https://github.com/coder/coder/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-Hosted | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 | Anything (vim, emacs, theia, code-server, openvscode-server, entire jetbrains suite inc gateway remote development, visual studio code desktop, visual studio for mac, visual studio for windows) you choose to install and deploy |
| [code-server](https://coder.com/blog/code-server-multiple-users?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/coder/code-server/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64, arm64 | [code-server](https://github.com/coder/code-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
| [openvscode-server](https://github.com/gitpod-io/openvscode-server) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/gitpod-io/openvscode-server) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64 | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
| [Amazon CodeCatalyst](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Pay AWS | Yes | US West (Oregon) | ["all customer multi-tenancy isolation is done through virtual machines" for security reasons](https://devclass.com/2022/12/05/interview-why-aws-prefers-vms-for-code-isolation-and-tips-on-developing-for-lambda/) | N/A | [Service Health](https://health.aws.amazon.com/health/status) | Linux Virtual Machines | Cloud9, Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
| [CodeAnywhere](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Per user | Yes | N/A | N/A | N/A | N/A | N/A | Theia |
| [GitHub Codespaces](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | 2x Azure Compute | Yes | Four regions (US West, US East, Europe West, Southeast Asia) | ["two codespaces are never co-located on the same VM"](https://docs.github.com/en/codespaces/codespaces-reference/security-in-github-codespaces) | N/A | [Incident History](https://www.githubstatus.com/history) | Linux Virtual Machines, [GPUs supported](https://docs.github.com/en/codespaces/developing-in-codespaces/getting-started-with-github-codespaces-for-machine-learning) | Visual Studio Code ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
| [Gitpod](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | [SaaS](https://news.ycombinator.com/item?id=33907897) | [Credits](https://www.gitpod.io/pricing) | Yes | Two regions (Europe, US) | [All customers intermixed on the same machine isolated via runc](https://kinvolk.io/blog/2020/12/improving-kubernetes-and-container-security-with-user-namespaces/) | [Defect history](https://github.com/gitpod-io/gitpod/issues?q=is%3Aissue+label%3A%22type%3A+bug%22+sort%3Aupdated-desc+) | [Incident history](https://www.gitpodstatus.com/history) | Basic Linux containers, [GPUs](https://github.com/gitpod-io/gitpod/issues/10650) and [kubernetes/k3s](https://github.com/gitpod-io/gitpod/issues/4889) is not yet possible | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) inhibiting functionality of [.NET](https://www.isdotnetopen.com), [Python](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx), [C](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [C++](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [Jupyter](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx) and usage of [GitHub Co-pilot](https://github.com/gitpod-io/gitpod/issues/10032). Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway supported |
| [Google Cloud Workstations](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Google | Yes | southamerica-west1, us-east1, us-central1, us-west1, asia-east1, asia-southeast1, europe-north1, europe-southwest1, europe-west1, europe-west2, europe-west3, europe-west4 | N/A | N/A | Not generally available, offered in preview mode. | Linux | code-oss ([with restrictions](https://ghuntley.com/fracture)), Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
| [JetBrains Space](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS + On-Prem ([Dev environments are not supported](https://www.jetbrains.com/help/space-on-premises/space-on-premises-installation.html)) | Pay JetBrains | Yes | EU Ireland region (eu-west-1) | EC2 | N/A | [Service Health](https://status.jetbrains.space/) | Linux Virtual Machines | JetBrains Suite |
| [Microsoft DevBox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Microsoft | Yes | Australia East, Europe West, Japan East, Canada Central, UK South, US East, US East 2, US South Central, and US West 3 | Microsoft Azure Virtual Machine | N/A | Not generally available, offered in preview mode. | Windows Virtual Machine | Any application that runs on Windows via Microsoft Remote Desktop |
- [**VS Code Extension**](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote): Open any Coder workspace in VS Code with a single click
- [**JetBrains Gateway Extension**](https://plugins.jetbrains.com/plugin/19620-coder): Open any Coder workspace in JetBrains Gateway with a single click
- [**Self-Hosted VS Code Extension Marketplace**](https://github.com/coder/code-marketplace): A private extension marketplace that works in restricted or airgapped networks integrating with [code-server](https://github.com/coder/code-server).
_Last updated: 14/12/2022_
### Community
- [**Provision Coder with Terraform**](https://github.com/ElliotG/coder-oss-tf): Provision Coder on Google GKE, Azure AKS, AWS EKS, DigitalOcean DOKS, IBMCloud K8s, OVHCloud K8s, and Scaleway K8s Kapsule with Terraform
- [**Coder GitHub Action**](https://github.com/marketplace/actions/update-coder-template): A GitHub Action that updates Coder templates
- [**Various Templates**](./examples/templates/community-templates.md): Hetzner Cloud, Docker in Docker, and other templates the community has built.
+819 -179
View File
File diff suppressed because it is too large Load Diff
+725 -107
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -11,7 +11,7 @@ import (
"github.com/coder/coder/codersdk"
)
func (*agent) statisticsHandler() http.Handler {
func (a *agent) apiHandler() http.Handler {
r := chi.NewRouter()
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
@@ -19,16 +19,24 @@ func (*agent) statisticsHandler() http.Handler {
})
})
lp := &listeningPortsHandler{}
// Make a copy to ensure the map is not modified after the handler is
// created.
cpy := make(map[int]string)
for k, b := range a.ignorePorts {
cpy[k] = b
}
lp := &listeningPortsHandler{ignorePorts: cpy}
r.Get("/api/v0/listening-ports", lp.handler)
return r
}
type listeningPortsHandler struct {
mut sync.Mutex
ports []codersdk.ListeningPort
mtime time.Time
mut sync.Mutex
ports []codersdk.WorkspaceAgentListeningPort
mtime time.Time
ignorePorts map[int]string
}
// handler returns a list of listening ports. This is tested by coderd's
@@ -43,7 +51,7 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ListeningPortsResponse{
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
Ports: ports,
})
}
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/retry"
)
@@ -18,7 +19,7 @@ import (
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
// PostWorkspaceAgentAppHealth updates the workspace app health.
type PostWorkspaceAgentAppHealth func(context.Context, codersdk.PostWorkspaceAppHealthsRequest) error
type PostWorkspaceAgentAppHealth func(context.Context, agentsdk.PostAppHealthsRequest) error
// WorkspaceAppHealthReporter is a function that checks and reports the health of the workspace apps until the passed context is canceled.
type WorkspaceAppHealthReporter func(ctx context.Context)
@@ -132,7 +133,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
mu.Lock()
lastHealth = copyHealth(health)
mu.Unlock()
err := postWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/testutil"
)
@@ -157,7 +158,7 @@ func TestAppHealth_NotSpamming(t *testing.T) {
// Ensure we haven't made more than 2 (expected 1 + 1 for buffer) requests in the last second.
// if there is a bug where we are spamming the healthcheck route this will catch it.
time.Sleep(time.Second)
require.LessOrEqual(t, *counter, int32(2))
require.LessOrEqual(t, atomic.LoadInt32(counter), int32(2))
}
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
@@ -180,7 +181,7 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
var newApps []codersdk.WorkspaceApp
return append(newApps, apps...), nil
}
postWorkspaceAgentAppHealth := func(_ context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error {
postWorkspaceAgentAppHealth := func(_ context.Context, req agentsdk.PostAppHealthsRequest) error {
mu.Lock()
for id, health := range req.Healths {
for i, app := range apps {
+12 -7
View File
@@ -11,13 +11,13 @@ import (
"github.com/coder/coder/codersdk"
)
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
lp.mut.Lock()
defer lp.mut.Unlock()
if time.Since(lp.mtime) < time.Second {
// copy
ports := make([]codersdk.ListeningPort, len(lp.ports))
ports := make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
copy(ports, lp.ports)
return ports, nil
}
@@ -30,9 +30,14 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
}
seen := make(map[uint16]struct{}, len(tabs))
ports := []codersdk.ListeningPort{}
ports := []codersdk.WorkspaceAgentListeningPort{}
for _, tab := range tabs {
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.MinimumListeningPort {
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort {
continue
}
// Ignore ports that we've been told to ignore.
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
continue
}
@@ -47,9 +52,9 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
if tab.Process != nil {
procName = tab.Process.Name
}
ports = append(ports, codersdk.ListeningPort{
ports = append(ports, codersdk.WorkspaceAgentListeningPort{
ProcessName: procName,
Network: codersdk.ListeningPortNetworkTCP,
Network: "tcp",
Port: tab.LocalAddr.Port,
})
}
@@ -58,7 +63,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort,
lp.mtime = time.Now()
// copy
ports = make([]codersdk.ListeningPort, len(lp.ports))
ports = make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
copy(ports, lp.ports)
return ports, nil
}
+2 -2
View File
@@ -4,9 +4,9 @@ package agent
import "github.com/coder/coder/codersdk"
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
// moment. The UI will not show any "no ports found" message to the user, so
// the user won't suspect a thing.
return []codersdk.ListeningPort{}, nil
return []codersdk.WorkspaceAgentListeningPort{}, nil
}
+17 -4
View File
@@ -1,6 +1,10 @@
package reaper
import "github.com/hashicorp/go-reap"
import (
"os"
"github.com/hashicorp/go-reap"
)
type Option func(o *options)
@@ -22,7 +26,16 @@ func WithPIDCallback(ch reap.PidCh) Option {
}
}
type options struct {
ExecArgs []string
PIDs reap.PidCh
// WithCatchSignals sets the signals that are caught and forwarded to the
// child process. By default no signals are forwarded.
func WithCatchSignals(sigs ...os.Signal) Option {
return func(o *options) {
o.CatchSignals = sigs
}
}
type options struct {
ExecArgs []string
PIDs reap.PidCh
CatchSignals []os.Signal
}
+42 -3
View File
@@ -3,8 +3,11 @@
package reaper_test
import (
"fmt"
"os"
"os/exec"
"os/signal"
"syscall"
"testing"
"time"
@@ -15,9 +18,8 @@ import (
"github.com/coder/coder/testutil"
)
//nolint:paralleltest // Non-parallel subtest.
func TestReap(t *testing.T) {
t.Parallel()
// Don't run the reaper test in CI. It does weird
// things like forkexecing which may have unintended
// consequences in CI.
@@ -28,8 +30,9 @@ func TestReap(t *testing.T) {
// OK checks that's the reaper is successfully reaping
// exited processes and passing the PIDs through the shared
// channel.
//nolint:paralleltest // Signal handling.
t.Run("OK", func(t *testing.T) {
t.Parallel()
pids := make(reap.PidCh, 1)
err := reaper.ForkReap(
reaper.WithPIDCallback(pids),
@@ -64,3 +67,39 @@ func TestReap(t *testing.T) {
}
})
}
//nolint:paralleltest // Signal handling.
func TestReapInterrupt(t *testing.T) {
// Don't run the reaper test in CI. It does weird
// things like forkexecing which may have unintended
// consequences in CI.
if _, ok := os.LookupEnv("CI"); ok {
t.Skip("Detected CI, skipping reaper tests")
}
errC := make(chan error, 1)
pids := make(reap.PidCh, 1)
// Use signals to notify when the child process is ready for the
// next step of our test.
usrSig := make(chan os.Signal, 1)
signal.Notify(usrSig, syscall.SIGUSR1, syscall.SIGUSR2)
defer signal.Stop(usrSig)
go func() {
errC <- reaper.ForkReap(
reaper.WithPIDCallback(pids),
reaper.WithCatchSignals(os.Interrupt),
// Signal propagation does not extend to children of children, so
// we create a little bash script to ensure sleep is interrupted.
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
)
}()
require.Equal(t, <-usrSig, syscall.SIGUSR1)
err := syscall.Kill(os.Getpid(), syscall.SIGINT)
require.NoError(t, err)
require.Equal(t, <-usrSig, syscall.SIGUSR2)
require.NoError(t, <-errC)
}
+26 -3
View File
@@ -4,6 +4,7 @@ package reaper
import (
"os"
"os/signal"
"syscall"
"github.com/hashicorp/go-reap"
@@ -15,6 +16,24 @@ func IsInitProcess() bool {
return os.Getpid() == 1
}
func catchSignals(pid int, sigs []os.Signal) {
if len(sigs) == 0 {
return
}
sc := make(chan os.Signal, 1)
signal.Notify(sc, sigs...)
defer signal.Stop(sc)
for {
s := <-sc
sig, ok := s.(syscall.Signal)
if ok {
_ = syscall.Kill(pid, sig)
}
}
}
// ForkReap spawns a goroutine that reaps children. In order to avoid
// complications with spawning `exec.Commands` in the same process that
// is reaping, we forkexec a child process. This prevents a race between
@@ -51,13 +70,17 @@ func ForkReap(opt ...Option) error {
}
//#nosec G204
pid, _ := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
pid, err := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
if err != nil {
return xerrors.Errorf("fork exec: %w", err)
}
go catchSignals(pid, opts.CatchSignals)
var wstatus syscall.WaitStatus
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
for xerrors.Is(err, syscall.EINTR) {
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
}
return nil
return err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
// Create socket parent dir if not exists.
parentDir := filepath.Dir(addr)
err = os.MkdirAll(parentDir, 0700)
err = os.MkdirAll(parentDir, 0o700)
if err != nil {
h.log.Warn(ctx, "create parent dir for SSH unix forward request",
slog.F("parent_dir", parentDir),
+187 -49
View File
@@ -3,16 +3,19 @@ package cli
import (
"context"
"fmt"
"io"
"net/http"
"net/http/pprof"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"sync"
"time"
"cloud.google.com/go/compute/metadata"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
@@ -21,52 +24,50 @@ import (
"github.com/coder/coder/agent"
"github.com/coder/coder/agent/reaper"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/codersdk/agentsdk"
)
func workspaceAgent() *cobra.Command {
func (r *RootCmd) workspaceAgent() *clibase.Cmd {
var (
auth string
pprofAddress string
noReap bool
auth string
logDir string
pprofAddress string
noReap bool
sshMaxTimeout time.Duration
tailnetListenPort int64
)
cmd := &cobra.Command{
Use: "agent",
cmd := &clibase.Cmd{
Use: "agent",
Short: `Starts the Coder workspace agent.`,
// This command isn't useful to manually execute.
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
go dumpHandler(ctx)
rawURL, err := cmd.Flags().GetString(varAgentURL)
if err != nil {
return xerrors.Errorf("CODER_AGENT_URL must be set: %w", err)
}
coderURL, err := url.Parse(rawURL)
if err != nil {
return xerrors.Errorf("parse %q: %w", rawURL, err)
}
logWriter := &lumberjack.Logger{
Filename: filepath.Join(os.TempDir(), "coder-agent.log"),
MaxSize: 5, // MB
}
defer logWriter.Close()
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
agentPorts := map[int]string{}
isLinux := runtime.GOOS == "linux"
// Spawn a reaper so that we don't accumulate a ton
// of zombie processes.
if reaper.IsInitProcess() && !noReap && isLinux {
logWriter := &lumberjack.Logger{
Filename: filepath.Join(logDir, "coder-agent-init.log"),
MaxSize: 5, // MB
}
defer logWriter.Close()
logger := slog.Make(sloghuman.Sink(inv.Stderr), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
logger.Info(ctx, "spawning reaper process")
// Do not start a reaper on the child process. It's important
// to do this else we fork bomb ourselves.
args := append(os.Args, "--no-reap")
err := reaper.ForkReap(reaper.WithExecArgs(args...))
err := reaper.ForkReap(
reaper.WithExecArgs(args...),
reaper.WithCatchSignals(InterruptSignals...),
)
if err != nil {
logger.Error(ctx, "failed to reap", slog.Error(err))
return xerrors.Errorf("fork reap: %w", err)
@@ -76,30 +77,62 @@ func workspaceAgent() *cobra.Command {
return nil
}
// Handle interrupt signals to allow for graceful shutdown,
// note that calling stopNotify disables the signal handler
// and the next interrupt will terminate the program (you
// probably want cancel instead).
//
// Note that we don't want to handle these signals in the
// process that runs as PID 1, that's why we do this after
// the reaper forked.
ctx, stopNotify := signal.NotifyContext(ctx, InterruptSignals...)
defer stopNotify()
// dumpHandler does signal handling, so we call it after the
// reaper.
go dumpHandler(ctx)
ljLogger := &lumberjack.Logger{
Filename: filepath.Join(logDir, "coder-agent.log"),
MaxSize: 5, // MB
}
defer ljLogger.Close()
logWriter := &closeWriter{w: ljLogger}
defer logWriter.Close()
logger := slog.Make(sloghuman.Sink(inv.Stderr), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
version := buildinfo.Version()
logger.Info(ctx, "starting agent",
slog.F("url", coderURL),
slog.F("url", r.agentURL),
slog.F("auth", auth),
slog.F("version", version),
)
client := codersdk.New(coderURL)
client.Logger = logger
client := agentsdk.New(r.agentURL)
client.SDK.Logger = logger
// Set a reasonable timeout so requests can't hang forever!
client.HTTPClient.Timeout = 10 * time.Second
// The timeout needs to be reasonably long, because requests
// with large payloads can take a bit. e.g. startup scripts
// may take a while to insert.
client.SDK.HTTPClient.Timeout = 30 * time.Second
// Enable pprof handler
// This prevents the pprof import from being accidentally deleted.
_ = pprof.Handler
pprofSrvClose := serveHandler(ctx, logger, nil, pprofAddress, "pprof")
defer pprofSrvClose()
// Do a best effort here. If this fails, it's not a big deal.
if port, err := urlPort(pprofAddress); err == nil {
agentPorts[port] = "pprof"
}
// exchangeToken returns a session token.
// This is abstracted to allow for the same looping condition
// regardless of instance identity auth type.
var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error)
var exchangeToken func(context.Context) (agentsdk.AuthenticateResponse, error)
switch auth {
case "token":
token, err := cmd.Flags().GetString(varAgentToken)
token, err := inv.ParsedFlags().GetString(varAgentToken)
if err != nil {
return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err)
}
@@ -112,8 +145,8 @@ func workspaceAgent() *cobra.Command {
if gcpClientRaw != nil {
gcpClient, _ = gcpClientRaw.(*metadata.Client)
}
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
return client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient)
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
return client.AuthGoogleInstanceIdentity(ctx, "", gcpClient)
}
case "aws-instance-identity":
// This is *only* done for testing to mock client authentication.
@@ -123,11 +156,11 @@ func workspaceAgent() *cobra.Command {
if awsClientRaw != nil {
awsClient, _ = awsClientRaw.(*http.Client)
if awsClient != nil {
client.HTTPClient = awsClient
client.SDK.HTTPClient = awsClient
}
}
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
return client.AuthAWSInstanceIdentity(ctx)
}
case "azure-instance-identity":
// This is *only* done for testing to mock client authentication.
@@ -137,11 +170,11 @@ func workspaceAgent() *cobra.Command {
if azureClientRaw != nil {
azureClient, _ = azureClientRaw.(*http.Client)
if azureClient != nil {
client.HTTPClient = azureClient
client.SDK.HTTPClient = azureClient
}
}
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
return client.AuthWorkspaceAzureInstanceIdentity(ctx)
exchangeToken = func(ctx context.Context) (agentsdk.AuthenticateResponse, error) {
return client.AuthAzureInstanceIdentity(ctx)
}
}
@@ -155,11 +188,13 @@ func workspaceAgent() *cobra.Command {
}
closer := agent.New(agent.Options{
Client: client,
Logger: logger,
Client: client,
Logger: logger,
LogDir: logDir,
TailnetListenPort: uint16(tailnetListenPort),
ExchangeToken: func(ctx context.Context) (string, error) {
if exchangeToken == nil {
return client.SessionToken(), nil
return client.SDK.SessionToken(), nil
}
resp, err := exchangeToken(ctx)
if err != nil {
@@ -171,15 +206,59 @@ func workspaceAgent() *cobra.Command {
EnvironmentVariables: map[string]string{
"GIT_ASKPASS": executablePath,
},
AgentPorts: agentPorts,
SSHMaxTimeout: sshMaxTimeout,
})
<-ctx.Done()
return closer.Close()
},
}
cliflag.StringVarP(cmd.Flags(), &auth, "auth", "", "CODER_AGENT_AUTH", "token", "Specify the authentication type to use for the agent")
cliflag.BoolVarP(cmd.Flags(), &noReap, "no-reap", "", "", false, "Do not start a process reaper.")
cliflag.StringVarP(cmd.Flags(), &pprofAddress, "pprof-address", "", "CODER_AGENT_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
cmd.Options = clibase.OptionSet{
{
Flag: "auth",
Default: "token",
Description: "Specify the authentication type to use for the agent.",
Env: "CODER_AGENT_AUTH",
Value: clibase.StringOf(&auth),
},
{
Flag: "log-dir",
Default: os.TempDir(),
Description: "Specify the location for the agent log files.",
Env: "CODER_AGENT_LOG_DIR",
Value: clibase.StringOf(&logDir),
},
{
Flag: "pprof-address",
Default: "127.0.0.1:6060",
Env: "CODER_AGENT_PPROF_ADDRESS",
Value: clibase.StringOf(&pprofAddress),
Description: "The address to serve pprof.",
},
{
Flag: "no-reap",
Env: "",
Description: "Do not start a process reaper.",
Value: clibase.BoolOf(&noReap),
},
{
Flag: "ssh-max-timeout",
Default: "0",
Env: "CODER_AGENT_SSH_MAX_TIMEOUT",
Description: "Specify the max timeout for a SSH connection.",
Value: clibase.DurationOf(&sshMaxTimeout),
},
{
Flag: "tailnet-listen-port",
Default: "0",
Env: "CODER_AGENT_TAILNET_LISTEN_PORT",
Description: "Specify a static port for Tailscale to use for listening.",
Value: clibase.Int64Of(&tailnetListenPort),
},
}
return cmd
}
@@ -205,3 +284,62 @@ func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler,
_ = srv.Close()
}
}
// closeWriter is a wrapper around an io.WriteCloser that prevents
// writes after Close. This is necessary because lumberjack will
// re-open the file on write.
type closeWriter struct {
w io.WriteCloser
mu sync.Mutex // Protects following.
closed bool
}
func (c *closeWriter) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
return c.w.Close()
}
func (c *closeWriter) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return 0, io.ErrClosedPipe
}
return c.w.Write(p)
}
// extractPort handles different url strings.
// - localhost:6060
// - http://localhost:6060
func extractPort(u string) (int, error) {
port, firstError := urlPort(u)
if firstError == nil {
return port, nil
}
// Try with a scheme
port, err := urlPort("http://" + u)
if err == nil {
return port, nil
}
return -1, xerrors.Errorf("invalid url %q: %w", u, firstError)
}
// urlPort extracts the port from a valid URL.
func urlPort(u string) (int, error) {
parsed, err := url.Parse(u)
if err != nil {
return -1, xerrors.Errorf("invalid url %q: %w", u, err)
}
if parsed.Port() != "" {
port, err := strconv.ParseInt(parsed.Port(), 10, 64)
if err == nil && port > 0 {
return int(port), nil
}
}
return -1, xerrors.Errorf("invalid port: %s", u)
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func Test_extractPort(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlString string
want int
wantErr bool
}{
{
name: "Empty",
urlString: "",
wantErr: true,
},
{
name: "NoScheme",
urlString: "localhost:6060",
want: 6060,
},
{
name: "WithScheme",
urlString: "http://localhost:6060",
want: 6060,
},
{
name: "NoPort",
urlString: "http://localhost",
wantErr: true,
},
{
name: "NoPortNoScheme",
urlString: "localhost",
wantErr: true,
},
{
name: "OnlyPort",
urlString: "6060",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := extractPort(tt.urlString)
if tt.wantErr {
require.Error(t, err, fmt.Sprintf("extractPort(%v)", tt.urlString))
} else {
require.NoError(t, err, fmt.Sprintf("extractPort(%v)", tt.urlString))
require.Equal(t, tt.want, got, fmt.Sprintf("extractPort(%v)", tt.urlString))
}
})
}
}
+69 -41
View File
@@ -2,6 +2,8 @@ package cli_test
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -14,10 +16,50 @@ import (
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
)
func TestWorkspaceAgent(t *testing.T) {
t.Parallel()
t.Run("LogDirectory", func(t *testing.T) {
t.Parallel()
authToken := uuid.NewString()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
logDir := t.TempDir()
inv, _ := clitest.New(t,
"agent",
"--auth", "token",
"--agent-token", authToken,
"--agent-url", client.URL.String(),
"--log-dir", logDir,
)
pty := ptytest.New(t).Attach(inv)
clitest.Start(t, inv)
pty.ExpectMatch("starting agent")
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
info, err := os.Stat(filepath.Join(logDir, "coder-agent.log"))
require.NoError(t, err)
require.Greater(t, info.Size(), int64(0))
})
t.Run("Azure", func(t *testing.T) {
t.Parallel()
instanceID := "instanceidentifier"
@@ -50,16 +92,14 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
inv, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
inv = inv.WithContext(
//nolint:revive,staticcheck
context.WithValue(inv.Context(), "azure-client", metadataClient),
)
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
go func() {
// A linting error occurs for weakly typing the context value here.
//nolint // The above seems reasonable for a one-off test.
ctx := context.WithValue(ctx, "azure-client", metadataClient)
errC <- cmd.ExecuteContext(ctx)
}()
clitest.Start(t, inv)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
@@ -71,9 +111,6 @@ func TestWorkspaceAgent(t *testing.T) {
require.NoError(t, err)
defer dialer.Close()
require.True(t, dialer.AwaitReachable(context.Background()))
cancelFunc()
err = <-errC
require.NoError(t, err)
})
t.Run("AWS", func(t *testing.T) {
@@ -108,36 +145,29 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
go func() {
// A linting error occurs for weakly typing the context value here.
//nolint // The above seems reasonable for a one-off test.
ctx := context.WithValue(ctx, "aws-client", metadataClient)
errC <- cmd.ExecuteContext(ctx)
}()
inv, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
inv = inv.WithContext(
//nolint:revive,staticcheck
context.WithValue(inv.Context(), "aws-client", metadataClient),
)
clitest.Start(t, inv)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
workspace, err := client.Workspace(inv.Context(), workspace.ID)
require.NoError(t, err)
resources := workspace.LatestBuild.Resources
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
dialer, err := client.DialWorkspaceAgent(inv.Context(), resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer dialer.Close()
require.True(t, dialer.AwaitReachable(context.Background()))
cancelFunc()
err = <-errC
require.NoError(t, err)
})
t.Run("GoogleCloud", func(t *testing.T) {
t.Parallel()
instanceID := "instanceidentifier"
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
validator, metadataClient := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
IncludeProvisionerDaemon: true,
@@ -166,16 +196,18 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
go func() {
// A linting error occurs for weakly typing the context value here.
//nolint // The above seems reasonable for a one-off test.
ctx := context.WithValue(ctx, "gcp-client", metadata)
errC <- cmd.ExecuteContext(ctx)
}()
inv, cfg := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
ptytest.New(t).Attach(inv)
clitest.SetupConfig(t, client, cfg)
clitest.Start(t,
inv.WithContext(
//nolint:revive,staticcheck
context.WithValue(context.Background(), "gcp-client", metadataClient),
),
)
ctx := inv.Context()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
@@ -202,9 +234,5 @@ func TestWorkspaceAgent(t *testing.T) {
require.NoError(t, err)
_, err = uuid.Parse(strings.TrimSpace(string(token)))
require.NoError(t, err)
cancelFunc()
err = <-errC
require.NoError(t, err)
})
}
+85
View File
@@ -0,0 +1,85 @@
// Package clibase offers an all-in-one solution for a highly configurable CLI
// application. Within Coder, we use it for all of our subcommands, which
// demands more functionality than cobra/viber offers.
//
// The Command interface is loosely based on the chi middleware pattern and
// http.Handler/HandlerFunc.
package clibase
import (
"strings"
"golang.org/x/exp/maps"
)
// Group describes a hierarchy of groups that an option or command belongs to.
type Group struct {
Parent *Group `json:"parent,omitempty"`
Name string `json:"name,omitempty"`
Children []Group `json:"children,omitempty"`
Description string `json:"description,omitempty"`
}
func (g *Group) AddChild(child Group) {
child.Parent = g
g.Children = append(g.Children, child)
}
// Ancestry returns the group and all of its parents, in order.
func (g *Group) Ancestry() []Group {
if g == nil {
return nil
}
groups := []Group{*g}
for p := g.Parent; p != nil; p = p.Parent {
// Prepend to the slice so that the order is correct.
groups = append([]Group{*p}, groups...)
}
return groups
}
func (g *Group) FullName() string {
var names []string
for _, g := range g.Ancestry() {
names = append(names, g.Name)
}
return strings.Join(names, " / ")
}
// Annotations is an arbitrary key-mapping used to extend the Option and Command types.
// Its methods won't panic if the map is nil.
type Annotations map[string]string
// Mark sets a value on the annotations map, creating one
// if it doesn't exist. Mark does not mutate the original and
// returns a copy. It is suitable for chaining.
func (a Annotations) Mark(key string, value string) Annotations {
var aa Annotations
if a != nil {
aa = maps.Clone(a)
} else {
aa = make(Annotations)
}
aa[key] = value
return aa
}
// IsSet returns true if the key is set in the annotations map.
func (a Annotations) IsSet(key string) bool {
if a == nil {
return false
}
_, ok := a[key]
return ok
}
// Get retrieves a key from the map, returning false if the key is not found
// or the map is nil.
func (a Annotations) Get(key string) (string, bool) {
if a == nil {
return "", false
}
v, ok := a[key]
return v, ok
}
+520
View File
@@ -0,0 +1,520 @@
package clibase
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"unicode"
"github.com/spf13/pflag"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
)
// Cmd describes an executable command.
type Cmd struct {
// Parent is the direct parent of the command.
Parent *Cmd
// Children is a list of direct descendants.
Children []*Cmd
// Use is provided in form "command [flags] [args...]".
Use string
// Aliases is a list of alternative names for the command.
Aliases []string
// Short is a one-line description of the command.
Short string
// Hidden determines whether the command should be hidden from help.
Hidden bool
// RawArgs determines whether the command should receive unparsed arguments.
// No flags are parsed when set, and the command is responsible for parsing
// its own flags.
RawArgs bool
// Long is a detailed description of the command,
// presented on its help page. It may contain examples.
Long string
Options OptionSet
Annotations Annotations
// Middleware is called before the Handler.
// Use Chain() to combine multiple middlewares.
Middleware MiddlewareFunc
Handler HandlerFunc
HelpHandler HandlerFunc
}
// AddSubcommands adds the given subcommands, setting their
// Parent field automatically.
func (c *Cmd) AddSubcommands(cmds ...*Cmd) {
for _, cmd := range cmds {
cmd.Parent = c
c.Children = append(c.Children, cmd)
}
}
// Walk calls fn for the command and all its children.
func (c *Cmd) Walk(fn func(*Cmd)) {
fn(c)
for _, child := range c.Children {
child.Parent = c
child.Walk(fn)
}
}
// PrepareAll performs initialization and linting on the command and all its children.
func (c *Cmd) PrepareAll() error {
if c.Use == "" {
return xerrors.New("command must have a Use field so that it has a name")
}
var merr error
slices.SortFunc(c.Options, func(a, b Option) bool {
return a.Flag < b.Flag
})
for _, opt := range c.Options {
if opt.Name == "" {
switch {
case opt.Flag != "":
opt.Name = opt.Flag
case opt.Env != "":
opt.Name = opt.Env
case opt.YAML != "":
opt.Name = opt.YAML
default:
merr = errors.Join(merr, xerrors.Errorf("option must have a Name, Flag, Env or YAML field"))
}
}
if opt.Description != "" {
// Enforce that description uses sentence form.
if unicode.IsLower(rune(opt.Description[0])) {
merr = errors.Join(merr, xerrors.Errorf("option %q description should start with a capital letter", opt.Name))
}
if !strings.HasSuffix(opt.Description, ".") {
merr = errors.Join(merr, xerrors.Errorf("option %q description should end with a period", opt.Name))
}
}
}
slices.SortFunc(c.Children, func(a, b *Cmd) bool {
return a.Name() < b.Name()
})
for _, child := range c.Children {
child.Parent = c
err := child.PrepareAll()
if err != nil {
merr = errors.Join(merr, xerrors.Errorf("command %v: %w", child.Name(), err))
}
}
return merr
}
// Name returns the first word in the Use string.
func (c *Cmd) Name() string {
return strings.Split(c.Use, " ")[0]
}
// FullName returns the full invocation name of the command,
// as seen on the command line.
func (c *Cmd) FullName() string {
var names []string
if c.Parent != nil {
names = append(names, c.Parent.FullName())
}
names = append(names, c.Name())
return strings.Join(names, " ")
}
// FullName returns usage of the command, preceded
// by the usage of its parents.
func (c *Cmd) FullUsage() string {
var uses []string
if c.Parent != nil {
uses = append(uses, c.Parent.FullName())
}
uses = append(uses, c.Use)
return strings.Join(uses, " ")
}
// Invoke creates a new invocation of the command, with
// stdio discarded.
//
// The returned invocation is not live until Run() is called.
func (c *Cmd) Invoke(args ...string) *Invocation {
return &Invocation{
Command: c,
Args: args,
Stdout: io.Discard,
Stderr: io.Discard,
Stdin: strings.NewReader(""),
}
}
// Invocation represents an instance of a command being executed.
type Invocation struct {
ctx context.Context
Command *Cmd
parsedFlags *pflag.FlagSet
Args []string
// Environ is a list of environment variables. Use EnvsWithPrefix to parse
// os.Environ.
Environ Environ
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
}
// WithOS returns the invocation as a main package, filling in the invocation's unset
// fields with OS defaults.
func (inv *Invocation) WithOS() *Invocation {
return inv.with(func(i *Invocation) {
i.Stdout = os.Stdout
i.Stderr = os.Stderr
i.Stdin = os.Stdin
i.Args = os.Args[1:]
i.Environ = ParseEnviron(os.Environ(), "")
})
}
func (inv *Invocation) Context() context.Context {
if inv.ctx == nil {
return context.Background()
}
return inv.ctx
}
func (inv *Invocation) ParsedFlags() *pflag.FlagSet {
if inv.parsedFlags == nil {
panic("flags not parsed, has Run() been called?")
}
return inv.parsedFlags
}
type runState struct {
allArgs []string
commandDepth int
flagParseErr error
}
func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet {
fs2 := pflag.NewFlagSet("", pflag.ContinueOnError)
fs2.Usage = func() {}
fs.VisitAll(func(f *pflag.Flag) {
if f.Name == without {
return
}
fs2.AddFlag(f)
})
return fs2
}
// run recursively executes the command and its children.
// allArgs is wired through the stack so that global flags can be accepted
// anywhere in the command invocation.
func (inv *Invocation) run(state *runState) error {
err := inv.Command.Options.ParseEnv(inv.Environ)
if err != nil {
return xerrors.Errorf("parsing env: %w", err)
}
// Now the fun part, argument parsing!
children := make(map[string]*Cmd)
for _, child := range inv.Command.Children {
child.Parent = inv.Command
for _, name := range append(child.Aliases, child.Name()) {
if _, ok := children[name]; ok {
return xerrors.Errorf("duplicate command name: %s", name)
}
children[name] = child
}
}
if inv.parsedFlags == nil {
inv.parsedFlags = pflag.NewFlagSet(inv.Command.Name(), pflag.ContinueOnError)
// We handle Usage ourselves.
inv.parsedFlags.Usage = func() {}
}
// If we find a duplicate flag, we want the deeper command's flag to override
// the shallow one. Unfortunately, pflag has no way to remove a flag, so we
// have to create a copy of the flagset without a value.
inv.Command.Options.FlagSet().VisitAll(func(f *pflag.Flag) {
if inv.parsedFlags.Lookup(f.Name) != nil {
inv.parsedFlags = copyFlagSetWithout(inv.parsedFlags, f.Name)
}
inv.parsedFlags.AddFlag(f)
})
var parsedArgs []string
if !inv.Command.RawArgs {
// Flag parsing will fail on intermediate commands in the command tree,
// so we check the error after looking for a child command.
state.flagParseErr = inv.parsedFlags.Parse(state.allArgs)
parsedArgs = inv.parsedFlags.Args()
}
// Set defaults for flags that weren't set by the user.
skipDefaults := make(map[int]struct{}, len(inv.Command.Options))
for i, opt := range inv.Command.Options {
if fl := inv.parsedFlags.Lookup(opt.Flag); fl != nil && fl.Changed {
skipDefaults[i] = struct{}{}
}
if opt.envChanged {
skipDefaults[i] = struct{}{}
}
}
err = inv.Command.Options.SetDefaults(skipDefaults)
if err != nil {
return xerrors.Errorf("setting defaults: %w", err)
}
// Run child command if found (next child only)
// We must do subcommand detection after flag parsing so we don't mistake flag
// values for subcommand names.
if len(parsedArgs) > state.commandDepth {
nextArg := parsedArgs[state.commandDepth]
if child, ok := children[nextArg]; ok {
child.Parent = inv.Command
inv.Command = child
state.commandDepth++
return inv.run(state)
}
}
// Flag parse errors are irrelevant for raw args commands.
if !inv.Command.RawArgs && state.flagParseErr != nil && !errors.Is(state.flagParseErr, pflag.ErrHelp) {
return xerrors.Errorf(
"parsing flags (%v) for %q: %w",
state.allArgs,
inv.Command.FullName(), state.flagParseErr,
)
}
if inv.Command.RawArgs {
// If we're at the root command, then the name is omitted
// from the arguments, so we can just use the entire slice.
if state.commandDepth == 0 {
inv.Args = state.allArgs
} else {
argPos, err := findArg(inv.Command.Name(), state.allArgs, inv.parsedFlags)
if err != nil {
panic(err)
}
inv.Args = state.allArgs[argPos+1:]
}
} else {
// In non-raw-arg mode, we want to skip over flags.
inv.Args = parsedArgs[state.commandDepth:]
}
mw := inv.Command.Middleware
if mw == nil {
mw = Chain()
}
ctx := inv.ctx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
inv = inv.WithContext(ctx)
if inv.Command.Handler == nil || errors.Is(state.flagParseErr, pflag.ErrHelp) {
if inv.Command.HelpHandler == nil {
return xerrors.Errorf("no handler or help for command %s", inv.Command.FullName())
}
return inv.Command.HelpHandler(inv)
}
err = mw(inv.Command.Handler)(inv)
if err != nil {
return &RunCommandError{
Cmd: inv.Command,
Err: err,
}
}
return nil
}
type RunCommandError struct {
Cmd *Cmd
Err error
}
func (e *RunCommandError) Unwrap() error {
return e.Err
}
func (e *RunCommandError) Error() string {
return fmt.Sprintf("running command %q: %+v", e.Cmd.FullName(), e.Err)
}
// findArg returns the index of the first occurrence of arg in args, skipping
// over all flags.
func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) {
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "-") {
if arg == want {
return i, nil
}
continue
}
// This is a flag!
if strings.Contains(arg, "=") {
// The flag contains the value in the same arg, just skip.
continue
}
// We need to check if NoOptValue is set, then we should not wait
// for the next arg to be the value.
f := fs.Lookup(strings.TrimLeft(arg, "-"))
if f == nil {
return -1, xerrors.Errorf("unknown flag: %s", arg)
}
if f.NoOptDefVal != "" {
continue
}
if i == len(args)-1 {
return -1, xerrors.Errorf("flag %s requires a value", arg)
}
// Skip the value.
i++
}
return -1, xerrors.Errorf("arg %s not found", want)
}
// Run executes the command.
// If two command share a flag name, the first command wins.
//
//nolint:revive
func (inv *Invocation) Run() (err error) {
defer func() {
// Pflag is panicky, so additional context is helpful in tests.
if flag.Lookup("test.v") == nil {
return
}
if r := recover(); r != nil {
err = xerrors.Errorf("panic recovered for %s: %v", inv.Command.FullName(), r)
panic(err)
}
}()
err = inv.run(&runState{
allArgs: inv.Args,
})
return err
}
// WithContext returns a copy of the Invocation with the given context.
func (inv *Invocation) WithContext(ctx context.Context) *Invocation {
return inv.with(func(i *Invocation) {
i.ctx = ctx
})
}
// with returns a copy of the Invocation with the given function applied.
func (inv *Invocation) with(fn func(*Invocation)) *Invocation {
i2 := *inv
fn(&i2)
return &i2
}
// MiddlewareFunc returns the next handler in the chain,
// or nil if there are no more.
type MiddlewareFunc func(next HandlerFunc) HandlerFunc
func chain(ms ...MiddlewareFunc) MiddlewareFunc {
return MiddlewareFunc(func(next HandlerFunc) HandlerFunc {
if len(ms) > 0 {
return chain(ms[1:]...)(ms[0](next))
}
return next
})
}
// Chain returns a Handler that first calls middleware in order.
//
//nolint:revive
func Chain(ms ...MiddlewareFunc) MiddlewareFunc {
// We need to reverse the array to provide top-to-bottom execution
// order when defining a command.
reversed := make([]MiddlewareFunc, len(ms))
for i := range ms {
reversed[len(ms)-1-i] = ms[i]
}
return chain(reversed...)
}
func RequireNArgs(want int) MiddlewareFunc {
return RequireRangeArgs(want, want)
}
// RequireRangeArgs returns a Middleware that requires the number of arguments
// to be between start and end (inclusive). If end is -1, then the number of
// arguments must be at least start.
func RequireRangeArgs(start, end int) MiddlewareFunc {
if start < 0 {
panic("start must be >= 0")
}
return func(next HandlerFunc) HandlerFunc {
return func(i *Invocation) error {
got := len(i.Args)
switch {
case start == end && got != start:
switch start {
case 0:
if len(i.Command.Children) > 0 {
return xerrors.Errorf("unrecognized subcommand %q", i.Args[0])
}
return xerrors.Errorf("wanted no args but got %v %v", got, i.Args)
default:
return xerrors.Errorf(
"wanted %v args but got %v %v",
start,
got,
i.Args,
)
}
case start > 0 && end == -1:
switch {
case got < start:
return xerrors.Errorf(
"wanted at least %v args but got %v",
start,
got,
)
default:
return next(i)
}
case start > end:
panic("start must be <= end")
case got < start || got > end:
return xerrors.Errorf(
"wanted between %v and %v args but got %v",
start, end,
got,
)
default:
return next(i)
}
}
}
}
// HandlerFunc handles an Invocation of a command.
type HandlerFunc func(i *Invocation) error
+596
View File
@@ -0,0 +1,596 @@
package clibase_test
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
)
// ioBufs is the standard input, output, and error for a command.
type ioBufs struct {
Stdin bytes.Buffer
Stdout bytes.Buffer
Stderr bytes.Buffer
}
// fakeIO sets Stdin, Stdout, and Stderr to buffers.
func fakeIO(i *clibase.Invocation) *ioBufs {
var b ioBufs
i.Stdout = &b.Stdout
i.Stderr = &b.Stderr
i.Stdin = &b.Stdin
return &b
}
func TestCommand(t *testing.T) {
t.Parallel()
cmd := func() *clibase.Cmd {
var (
verbose bool
lower bool
prefix string
)
return &clibase.Cmd{
Use: "root [subcommand]",
Options: clibase.OptionSet{
clibase.Option{
Name: "verbose",
Flag: "verbose",
Value: clibase.BoolOf(&verbose),
},
clibase.Option{
Name: "prefix",
Flag: "prefix",
Value: clibase.StringOf(&prefix),
},
},
Children: []*clibase.Cmd{
{
Use: "toupper [word]",
Short: "Converts a word to upper case",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
),
Aliases: []string{"up"},
Options: clibase.OptionSet{
clibase.Option{
Name: "lower",
Flag: "lower",
Value: clibase.BoolOf(&lower),
},
},
Handler: (func(i *clibase.Invocation) error {
i.Stdout.Write([]byte(prefix))
w := i.Args[0]
if lower {
w = strings.ToLower(w)
} else {
w = strings.ToUpper(w)
}
_, _ = i.Stdout.Write(
[]byte(
w,
),
)
if verbose {
i.Stdout.Write([]byte("!!!"))
}
return nil
}),
},
},
}
}
t.Run("SimpleOK", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke("toupper", "hello")
io := fakeIO(i)
i.Run()
require.Equal(t, "HELLO", io.Stdout.String())
})
t.Run("Alias", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"up", "hello",
)
io := fakeIO(i)
i.Run()
require.Equal(t, "HELLO", io.Stdout.String())
})
t.Run("NoSubcommand", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"na",
)
io := fakeIO(i)
err := i.Run()
require.Empty(t, io.Stdout.String())
require.Error(t, err)
})
t.Run("BadArgs", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"toupper",
)
io := fakeIO(i)
err := i.Run()
require.Empty(t, io.Stdout.String())
require.Error(t, err)
})
t.Run("UnknownFlags", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"toupper", "--unknown",
)
io := fakeIO(i)
err := i.Run()
require.Empty(t, io.Stdout.String())
require.Error(t, err)
})
t.Run("Verbose", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"--verbose", "toupper", "hello",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "HELLO!!!", io.Stdout.String())
})
t.Run("Verbose=", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"--verbose=true", "toupper", "hello",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "HELLO!!!", io.Stdout.String())
})
t.Run("PrefixSpace", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"--prefix", "conv: ", "toupper", "hello",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "conv: HELLO", io.Stdout.String())
})
t.Run("GlobalFlagsAnywhere", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"toupper", "--prefix", "conv: ", "hello", "--verbose",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "conv: HELLO!!!", io.Stdout.String())
})
t.Run("LowerVerbose", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"toupper", "--verbose", "hello", "--lower",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "hello!!!", io.Stdout.String())
})
t.Run("ParsedFlags", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"toupper", "--verbose", "hello", "--lower",
)
_ = fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t,
"true",
i.ParsedFlags().Lookup("verbose").Value.String(),
)
})
t.Run("NoDeepChild", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"root", "level", "level", "toupper", "--verbose", "hello", "--lower",
)
fio := fakeIO(i)
require.Error(t, i.Run(), fio.Stdout.String())
})
}
func TestCommand_DeepNest(t *testing.T) {
t.Parallel()
cmd := &clibase.Cmd{
Use: "1",
Children: []*clibase.Cmd{
{
Use: "2",
Children: []*clibase.Cmd{
{
Use: "3",
Handler: func(i *clibase.Invocation) error {
i.Stdout.Write([]byte("3"))
return nil
},
},
},
},
},
}
inv := cmd.Invoke("2", "3")
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Equal(t, "3", stdio.Stdout.String())
}
func TestCommand_FlagOverride(t *testing.T) {
t.Parallel()
var flag string
cmd := &clibase.Cmd{
Use: "1",
Options: clibase.OptionSet{
{
Name: "flag",
Flag: "f",
Value: clibase.DiscardValue,
},
},
Children: []*clibase.Cmd{
{
Use: "2",
Options: clibase.OptionSet{
{
Name: "flag",
Flag: "f",
Value: clibase.StringOf(&flag),
},
},
Handler: func(i *clibase.Invocation) error {
return nil
},
},
},
}
err := cmd.Invoke("2", "--f", "mhmm").Run()
require.NoError(t, err)
require.Equal(t, "mhmm", flag)
}
func TestCommand_MiddlewareOrder(t *testing.T) {
t.Parallel()
mw := func(letter string) clibase.MiddlewareFunc {
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return (func(i *clibase.Invocation) error {
_, _ = i.Stdout.Write([]byte(letter))
return next(i)
})
}
}
cmd := &clibase.Cmd{
Use: "toupper [word]",
Short: "Converts a word to upper case",
Middleware: clibase.Chain(
mw("A"),
mw("B"),
mw("C"),
),
Handler: (func(i *clibase.Invocation) error {
return nil
}),
}
i := cmd.Invoke(
"hello", "world",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "ABC", io.Stdout.String())
}
func TestCommand_RawArgs(t *testing.T) {
t.Parallel()
cmd := func() *clibase.Cmd {
return &clibase.Cmd{
Use: "root",
Options: clibase.OptionSet{
{
Name: "password",
Flag: "password",
Value: clibase.StringOf(new(string)),
},
},
Children: []*clibase.Cmd{
{
Use: "sushi <args...>",
Short: "Throws back raw output",
RawArgs: true,
Handler: (func(i *clibase.Invocation) error {
if v := i.ParsedFlags().Lookup("password").Value.String(); v != "codershack" {
return xerrors.Errorf("password %q is wrong!", v)
}
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
return nil
}),
},
},
}
}
t.Run("OK", func(t *testing.T) {
// Flag parsed before the raw arg command should still work.
t.Parallel()
i := cmd().Invoke(
"--password", "codershack", "sushi", "hello", "--verbose", "world",
)
io := fakeIO(i)
require.NoError(t, i.Run())
require.Equal(t, "hello --verbose world", io.Stdout.String())
})
t.Run("BadFlag", func(t *testing.T) {
// Verbose before the raw arg command should fail.
t.Parallel()
i := cmd().Invoke(
"--password", "codershack", "--verbose", "sushi", "hello", "world",
)
io := fakeIO(i)
require.Error(t, i.Run())
require.Empty(t, io.Stdout.String())
})
t.Run("NoPassword", func(t *testing.T) {
// Flag parsed before the raw arg command should still work.
t.Parallel()
i := cmd().Invoke(
"sushi", "hello", "--verbose", "world",
)
_ = fakeIO(i)
require.Error(t, i.Run())
})
}
func TestCommand_RootRaw(t *testing.T) {
t.Parallel()
cmd := &clibase.Cmd{
RawArgs: true,
Handler: func(i *clibase.Invocation) error {
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
return nil
},
}
inv := cmd.Invoke("hello", "--verbose", "--friendly")
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Equal(t, "hello --verbose --friendly", stdio.Stdout.String())
}
func TestCommand_HyphenHyphen(t *testing.T) {
t.Parallel()
cmd := &clibase.Cmd{
Handler: (func(i *clibase.Invocation) error {
i.Stdout.Write([]byte(strings.Join(i.Args, " ")))
return nil
}),
}
inv := cmd.Invoke("--", "--verbose", "--friendly")
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Equal(t, "--verbose --friendly", stdio.Stdout.String())
}
func TestCommand_ContextCancels(t *testing.T) {
t.Parallel()
var gotCtx context.Context
cmd := &clibase.Cmd{
Handler: (func(i *clibase.Invocation) error {
gotCtx = i.Context()
if err := gotCtx.Err(); err != nil {
return xerrors.Errorf("unexpected context error: %w", i.Context().Err())
}
return nil
}),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
require.Error(t, gotCtx.Err())
}
func TestCommand_Help(t *testing.T) {
t.Parallel()
cmd := func() *clibase.Cmd {
return &clibase.Cmd{
Use: "root",
HelpHandler: (func(i *clibase.Invocation) error {
i.Stdout.Write([]byte("abdracadabra"))
return nil
}),
Handler: (func(i *clibase.Invocation) error {
return xerrors.New("should not be called")
}),
}
}
t.Run("NoHandler", func(t *testing.T) {
t.Parallel()
c := cmd()
c.HelpHandler = nil
err := c.Invoke("--help").Run()
require.Error(t, err)
})
t.Run("Long", func(t *testing.T) {
t.Parallel()
inv := cmd().Invoke("--help")
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Contains(t, stdio.Stdout.String(), "abdracadabra")
})
t.Run("Short", func(t *testing.T) {
t.Parallel()
inv := cmd().Invoke("-h")
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Contains(t, stdio.Stdout.String(), "abdracadabra")
})
}
func TestCommand_SliceFlags(t *testing.T) {
t.Parallel()
cmd := func(want ...string) *clibase.Cmd {
var got []string
return &clibase.Cmd{
Use: "root",
Options: clibase.OptionSet{
{
Name: "arr",
Flag: "arr",
Default: "bad,bad,bad",
Value: clibase.StringArrayOf(&got),
},
},
Handler: (func(i *clibase.Invocation) error {
require.Equal(t, want, got)
return nil
}),
}
}
err := cmd("good", "good", "good").Invoke("--arr", "good", "--arr", "good", "--arr", "good").Run()
require.NoError(t, err)
err = cmd("bad", "bad", "bad").Invoke().Run()
require.NoError(t, err)
}
func TestCommand_EmptySlice(t *testing.T) {
t.Parallel()
cmd := func(want ...string) *clibase.Cmd {
var got []string
return &clibase.Cmd{
Use: "root",
Options: clibase.OptionSet{
{
Name: "arr",
Flag: "arr",
Default: "def,def,def",
Env: "ARR",
Value: clibase.StringArrayOf(&got),
},
},
Handler: (func(i *clibase.Invocation) error {
require.Equal(t, want, got)
return nil
}),
}
}
// Base-case, uses default.
err := cmd("def", "def", "def").Invoke().Run()
require.NoError(t, err)
// Empty-env uses default, too.
inv := cmd("def", "def", "def").Invoke()
inv.Environ.Set("ARR", "")
require.NoError(t, err)
// Reset to nothing at all via flag.
inv = cmd().Invoke("--arr", "")
inv.Environ.Set("ARR", "cant see")
err = inv.Run()
require.NoError(t, err)
// Reset to a specific value with flag.
inv = cmd("great").Invoke("--arr", "great")
inv.Environ.Set("ARR", "")
err = inv.Run()
require.NoError(t, err)
}
func TestCommand_DefaultsOverride(t *testing.T) {
t.Parallel()
var got string
cmd := &clibase.Cmd{
Options: clibase.OptionSet{
{
Name: "url",
Flag: "url",
Default: "def.com",
Env: "URL",
Value: clibase.StringOf(&got),
},
},
Handler: (func(i *clibase.Invocation) error {
_, _ = fmt.Fprintf(i.Stdout, "%s", got)
return nil
}),
}
// Base case
inv := cmd.Invoke()
stdio := fakeIO(inv)
err := inv.Run()
require.NoError(t, err)
require.Equal(t, "def.com", stdio.Stdout.String())
// Flag overrides
inv = cmd.Invoke("--url", "good.com")
stdio = fakeIO(inv)
err = inv.Run()
require.NoError(t, err)
require.Equal(t, "good.com", stdio.Stdout.String())
// Env overrides
inv = cmd.Invoke()
inv.Environ.Set("URL", "good.com")
stdio = fakeIO(inv)
err = inv.Run()
require.NoError(t, err)
require.Equal(t, "good.com", stdio.Stdout.String())
}
+76
View File
@@ -0,0 +1,76 @@
package clibase
import "strings"
// name returns the name of the environment variable.
func envName(line string) string {
return strings.ToUpper(
strings.SplitN(line, "=", 2)[0],
)
}
// value returns the value of the environment variable.
func envValue(line string) string {
tokens := strings.SplitN(line, "=", 2)
if len(tokens) < 2 {
return ""
}
return tokens[1]
}
// Var represents a single environment variable of form
// NAME=VALUE.
type EnvVar struct {
Name string
Value string
}
type Environ []EnvVar
func (e Environ) ToOS() []string {
var env []string
for _, v := range e {
env = append(env, v.Name+"="+v.Value)
}
return env
}
func (e Environ) Lookup(name string) (string, bool) {
for _, v := range e {
if v.Name == name {
return v.Value, true
}
}
return "", false
}
func (e Environ) Get(name string) string {
v, _ := e.Lookup(name)
return v
}
func (e *Environ) Set(name, value string) {
for i, v := range *e {
if v.Name == name {
(*e)[i].Value = value
return
}
}
*e = append(*e, EnvVar{Name: name, Value: value})
}
// ParseEnviron returns all environment variables starting with
// prefix without said prefix.
func ParseEnviron(environ []string, prefix string) Environ {
var filtered []EnvVar
for _, line := range environ {
name := envName(line)
if strings.HasPrefix(name, prefix) {
filtered = append(filtered, EnvVar{
Name: strings.TrimPrefix(name, prefix),
Value: envValue(line),
})
}
}
return filtered
}
+44
View File
@@ -0,0 +1,44 @@
package clibase_test
import (
"reflect"
"testing"
"github.com/coder/coder/cli/clibase"
)
func TestFilterNamePrefix(t *testing.T) {
t.Parallel()
type args struct {
environ []string
prefix string
}
tests := []struct {
name string
args args
want clibase.Environ
}{
{"empty", args{[]string{}, "SHIRE"}, nil},
{
"ONE",
args{
[]string{
"SHIRE_BRANDYBUCK=hmm",
},
"SHIRE_",
},
[]clibase.EnvVar{
{Name: "BRANDYBUCK", Value: "hmm"},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := clibase.ParseEnviron(tt.args.environ, tt.args.prefix); !reflect.DeepEqual(got, tt.want) {
t.Errorf("FilterNamePrefix() = %v, want %v", got, tt.want)
}
})
}
}
+188
View File
@@ -0,0 +1,188 @@
package clibase
import (
"os"
"github.com/hashicorp/go-multierror"
"github.com/spf13/pflag"
"golang.org/x/xerrors"
)
// Option is a configuration option for a CLI application.
type Option struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
// Flag is the long name of the flag used to configure this option. If unset,
// flag configuring is disabled.
Flag string `json:"flag,omitempty"`
// FlagShorthand is the one-character shorthand for the flag. If unset, no
// shorthand is used.
FlagShorthand string `json:"flag_shorthand,omitempty"`
// Env is the environment variable used to configure this option. If unset,
// environment configuring is disabled.
Env string `json:"env,omitempty"`
// YAML is the YAML key used to configure this option. If unset, YAML
// configuring is disabled.
YAML string `json:"yaml,omitempty"`
// Default is parsed into Value if set.
Default string `json:"default,omitempty"`
// Value includes the types listed in values.go.
Value pflag.Value `json:"value,omitempty"`
// Annotations enable extensions to clibase higher up in the stack. It's useful for
// help formatting and documentation generation.
Annotations Annotations `json:"annotations,omitempty"`
// Group is a group hierarchy that helps organize this option in help, configs
// and other documentation.
Group *Group `json:"group,omitempty"`
// UseInstead is a list of options that should be used instead of this one.
// The field is used to generate a deprecation warning.
UseInstead []Option `json:"use_instead,omitempty"`
Hidden bool `json:"hidden,omitempty"`
envChanged bool
}
// OptionSet is a group of options that can be applied to a command.
type OptionSet []Option
// Add adds the given Options to the OptionSet.
func (s *OptionSet) Add(opts ...Option) {
*s = append(*s, opts...)
}
// FlagSet returns a pflag.FlagSet for the OptionSet.
func (s *OptionSet) FlagSet() *pflag.FlagSet {
if s == nil {
return &pflag.FlagSet{}
}
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
for _, opt := range *s {
if opt.Flag == "" {
continue
}
var noOptDefValue string
{
no, ok := opt.Value.(NoOptDefValuer)
if ok {
noOptDefValue = no.NoOptDefValue()
}
}
val := opt.Value
if val == nil {
val = DiscardValue
}
fs.AddFlag(&pflag.Flag{
Name: opt.Flag,
Shorthand: opt.FlagShorthand,
Usage: opt.Description,
Value: val,
DefValue: "",
Changed: false,
Deprecated: "",
NoOptDefVal: noOptDefValue,
Hidden: opt.Hidden,
})
}
fs.Usage = func() {
_, _ = os.Stderr.WriteString("Override (*FlagSet).Usage() to print help text.\n")
}
return fs
}
// ParseEnv parses the given environment variables into the OptionSet.
// Use EnvsWithPrefix to filter out prefixes.
func (s *OptionSet) ParseEnv(vs []EnvVar) error {
if s == nil {
return nil
}
var merr *multierror.Error
// We parse environment variables first instead of using a nested loop to
// avoid N*M complexity when there are a lot of options and environment
// variables.
envs := make(map[string]string)
for _, v := range vs {
envs[v.Name] = v.Value
}
for i, opt := range *s {
if opt.Env == "" {
continue
}
envVal, ok := envs[opt.Env]
// Currently, empty values are treated as if the environment variable is
// unset. This behavior is technically not correct as there is now no
// way for a user to change a Default value to an empty string from
// the environment. Unfortunately, we have old configuration files
// that rely on the faulty behavior.
//
// TODO: We should remove this hack in May 2023, when deployments
// have had months to migrate to the new behavior.
if !ok || envVal == "" {
continue
}
opt.envChanged = true
(*s)[i] = opt
if err := opt.Value.Set(envVal); err != nil {
merr = multierror.Append(
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
)
}
}
return merr.ErrorOrNil()
}
// SetDefaults sets the default values for each Option, skipping values
// that have already been set as indicated by the skip map.
func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
if s == nil {
return nil
}
var merr *multierror.Error
for i, opt := range *s {
// Skip values that may have already been set by the user.
if len(skip) > 0 {
if _, ok := skip[i]; ok {
continue
}
}
if opt.Default == "" {
continue
}
if opt.Value == nil {
merr = multierror.Append(
merr,
xerrors.Errorf(
"parse %q: no Value field set\nFull opt: %+v",
opt.Name, opt,
),
)
continue
}
if err := opt.Value.Set(opt.Default); err != nil {
merr = multierror.Append(
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
)
}
}
return merr.ErrorOrNil()
}
+169
View File
@@ -0,0 +1,169 @@
package clibase_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clibase"
)
func TestOptionSet_ParseFlags(t *testing.T) {
t.Parallel()
t.Run("SimpleString", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
Flag: "workspace-name",
FlagShorthand: "n",
},
}
var err error
err = os.FlagSet().Parse([]string{"--workspace-name", "foo"})
require.NoError(t, err)
require.EqualValues(t, "foo", workspaceName)
err = os.FlagSet().Parse([]string{"-n", "f"})
require.NoError(t, err)
require.EqualValues(t, "f", workspaceName)
})
t.Run("StringArray", func(t *testing.T) {
t.Parallel()
var names clibase.StringArray
os := clibase.OptionSet{
clibase.Option{
Name: "name",
Value: &names,
Flag: "name",
FlagShorthand: "n",
},
}
err := os.SetDefaults(nil)
require.NoError(t, err)
err = os.FlagSet().Parse([]string{"--name", "foo", "--name", "bar"})
require.NoError(t, err)
require.EqualValues(t, []string{"foo", "bar"}, names)
})
t.Run("ExtraFlags", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
},
}
err := os.FlagSet().Parse([]string{"--some-unknown", "foo"})
require.Error(t, err)
})
}
func TestOptionSet_ParseEnv(t *testing.T) {
t.Parallel()
t.Run("SimpleString", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
Env: "WORKSPACE_NAME",
},
}
err := os.ParseEnv([]clibase.EnvVar{
{Name: "WORKSPACE_NAME", Value: "foo"},
})
require.NoError(t, err)
require.EqualValues(t, "foo", workspaceName)
})
t.Run("EmptyValue", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
Default: "defname",
Env: "WORKSPACE_NAME",
},
}
err := os.SetDefaults(nil)
require.NoError(t, err)
err = os.ParseEnv(clibase.ParseEnviron([]string{"CODER_WORKSPACE_NAME="}, "CODER_"))
require.NoError(t, err)
require.EqualValues(t, "defname", workspaceName)
})
t.Run("StringSlice", func(t *testing.T) {
t.Parallel()
var actual clibase.StringArray
expected := []string{"foo", "bar", "baz"}
os := clibase.OptionSet{
clibase.Option{
Name: "name",
Value: &actual,
Env: "NAMES",
},
}
err := os.SetDefaults(nil)
require.NoError(t, err)
err = os.ParseEnv([]clibase.EnvVar{
{Name: "NAMES", Value: "foo,bar,baz"},
})
require.NoError(t, err)
require.EqualValues(t, expected, actual)
})
t.Run("StructMapStringString", func(t *testing.T) {
t.Parallel()
var actual clibase.Struct[map[string]string]
expected := map[string]string{"foo": "bar", "baz": "zap"}
os := clibase.OptionSet{
clibase.Option{
Name: "labels",
Value: &actual,
Env: "LABELS",
},
}
err := os.SetDefaults(nil)
require.NoError(t, err)
err = os.ParseEnv([]clibase.EnvVar{
{Name: "LABELS", Value: `{"foo":"bar","baz":"zap"}`},
})
require.NoError(t, err)
require.EqualValues(t, expected, actual.Value)
})
}
+384
View File
@@ -0,0 +1,384 @@
package clibase
import (
"encoding/csv"
"encoding/json"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/spf13/pflag"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// NoOptDefValuer describes behavior when no
// option is passed into the flag.
//
// This is useful for boolean or otherwise binary flags.
type NoOptDefValuer interface {
NoOptDefValue() string
}
// values.go contains a standard set of value types that can be used as
// Option Values.
type Int64 int64
func Int64Of(i *int64) *Int64 {
return (*Int64)(i)
}
func (i *Int64) Set(s string) error {
ii, err := strconv.ParseInt(s, 10, 64)
*i = Int64(ii)
return err
}
func (i Int64) Value() int64 {
return int64(i)
}
func (i Int64) String() string {
return strconv.Itoa(int(i))
}
func (Int64) Type() string {
return "int"
}
type Bool bool
func BoolOf(b *bool) *Bool {
return (*Bool)(b)
}
func (b *Bool) Set(s string) error {
if s == "" {
*b = Bool(false)
return nil
}
bb, err := strconv.ParseBool(s)
*b = Bool(bb)
return err
}
func (*Bool) NoOptDefValue() string {
return "true"
}
func (b Bool) String() string {
return strconv.FormatBool(bool(b))
}
func (b Bool) Value() bool {
return bool(b)
}
func (Bool) Type() string {
return "bool"
}
type String string
func StringOf(s *string) *String {
return (*String)(s)
}
func (*String) NoOptDefValue() string {
return ""
}
func (s *String) Set(v string) error {
*s = String(v)
return nil
}
func (s String) String() string {
return string(s)
}
func (s String) Value() string {
return string(s)
}
func (String) Type() string {
return "string"
}
var _ pflag.SliceValue = &StringArray{}
// StringArray is a slice of strings that implements pflag.Value and pflag.SliceValue.
type StringArray []string
func StringArrayOf(ss *[]string) *StringArray {
return (*StringArray)(ss)
}
func (s *StringArray) Append(v string) error {
*s = append(*s, v)
return nil
}
func (s *StringArray) Replace(vals []string) error {
*s = vals
return nil
}
func (s *StringArray) GetSlice() []string {
return *s
}
func readAsCSV(v string) ([]string, error) {
return csv.NewReader(strings.NewReader(v)).Read()
}
func writeAsCSV(vals []string) string {
var sb strings.Builder
err := csv.NewWriter(&sb).Write(vals)
if err != nil {
return fmt.Sprintf("error: %s", err)
}
return sb.String()
}
func (s *StringArray) Set(v string) error {
if v == "" {
*s = nil
return nil
}
ss, err := readAsCSV(v)
if err != nil {
return err
}
*s = append(*s, ss...)
return nil
}
func (s StringArray) String() string {
return writeAsCSV([]string(s))
}
func (s StringArray) Value() []string {
return []string(s)
}
func (StringArray) Type() string {
return "string-array"
}
type Duration time.Duration
func DurationOf(d *time.Duration) *Duration {
return (*Duration)(d)
}
func (d *Duration) Set(v string) error {
dd, err := time.ParseDuration(v)
*d = Duration(dd)
return err
}
func (d *Duration) Value() time.Duration {
return time.Duration(*d)
}
func (d *Duration) String() string {
return time.Duration(*d).String()
}
func (Duration) Type() string {
return "duration"
}
type URL url.URL
func URLOf(u *url.URL) *URL {
return (*URL)(u)
}
func (u *URL) Set(v string) error {
uu, err := url.Parse(v)
if err != nil {
return err
}
*u = URL(*uu)
return nil
}
func (u *URL) String() string {
uu := url.URL(*u)
return uu.String()
}
func (u *URL) MarshalJSON() ([]byte, error) {
return json.Marshal(u.String())
}
func (u *URL) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
return u.Set(s)
}
func (*URL) Type() string {
return "url"
}
func (u *URL) Value() *url.URL {
return (*url.URL)(u)
}
// HostPort is a host:port pair.
type HostPort struct {
Host string
Port string
}
func (hp *HostPort) Set(v string) error {
if v == "" {
return xerrors.Errorf("must not be empty")
}
var err error
hp.Host, hp.Port, err = net.SplitHostPort(v)
return err
}
func (hp *HostPort) String() string {
if hp.Host == "" && hp.Port == "" {
return ""
}
// Warning: net.JoinHostPort must be used over concatenation to support
// IPv6 addresses.
return net.JoinHostPort(hp.Host, hp.Port)
}
func (hp *HostPort) MarshalJSON() ([]byte, error) {
return json.Marshal(hp.String())
}
func (hp *HostPort) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
if s == "" {
hp.Host = ""
hp.Port = ""
return nil
}
return hp.Set(s)
}
func (*HostPort) Type() string {
return "host:port"
}
var (
_ yaml.Marshaler = new(Struct[struct{}])
_ yaml.Unmarshaler = new(Struct[struct{}])
)
// Struct is a special value type that encodes an arbitrary struct.
// It implements the flag.Value interface, but in general these values should
// only be accepted via config for ergonomics.
//
// The string encoding type is YAML.
type Struct[T any] struct {
Value T
}
func (s *Struct[T]) Set(v string) error {
return yaml.Unmarshal([]byte(v), &s.Value)
}
func (s *Struct[T]) String() string {
byt, err := yaml.Marshal(s.Value)
if err != nil {
return "decode failed: " + err.Error()
}
return string(byt)
}
func (s *Struct[T]) MarshalYAML() (interface{}, error) {
var n yaml.Node
err := n.Encode(s.Value)
if err != nil {
return nil, err
}
return n, nil
}
func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
return n.Decode(&s.Value)
}
func (s *Struct[T]) Type() string {
return fmt.Sprintf("struct[%T]", s.Value)
}
func (s *Struct[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(s.Value)
}
func (s *Struct[T]) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &s.Value)
}
// DiscardValue does nothing but implements the pflag.Value interface.
// It's useful in cases where you want to accept an option, but access the
// underlying value directly instead of through the Option methods.
var DiscardValue discardValue
type discardValue struct{}
func (discardValue) Set(string) error {
return nil
}
func (discardValue) String() string {
return ""
}
func (discardValue) Type() string {
return "discard"
}
var _ pflag.Value = (*Enum)(nil)
type Enum struct {
Choices []string
Value *string
}
func EnumOf(v *string, choices ...string) *Enum {
return &Enum{
Choices: choices,
Value: v,
}
}
func (e *Enum) Set(v string) error {
for _, c := range e.Choices {
if v == c {
*e.Value = v
return nil
}
}
return xerrors.Errorf("invalid choice: %s, should be one of %v", v, e.Choices)
}
func (e *Enum) Type() string {
return fmt.Sprintf("enum[%v]", strings.Join(e.Choices, "|"))
}
func (e *Enum) String() string {
return *e.Value
}
+105
View File
@@ -0,0 +1,105 @@
package clibase
import (
"github.com/iancoleman/strcase"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// deepMapNode returns the mapping node at the given path,
// creating it if it doesn't exist.
func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node {
if len(path) == 0 {
return n
}
// Name is every two nodes.
for i := 0; i < len(n.Content)-1; i += 2 {
if n.Content[i].Value == path[0] {
// Found matching name, recurse.
return deepMapNode(n.Content[i+1], path[1:], headComment)
}
}
// Not found, create it.
nameNode := yaml.Node{
Kind: yaml.ScalarNode,
Value: path[0],
HeadComment: headComment,
}
valueNode := yaml.Node{
Kind: yaml.MappingNode,
}
n.Content = append(n.Content, &nameNode)
n.Content = append(n.Content, &valueNode)
return deepMapNode(&valueNode, path[1:], headComment)
}
// ToYAML converts the option set to a YAML node, that can be
// converted into bytes via yaml.Marshal.
//
// The node is returned to enable post-processing higher up in
// the stack.
func (s OptionSet) ToYAML() (*yaml.Node, error) {
root := yaml.Node{
Kind: yaml.MappingNode,
}
for _, opt := range s {
if opt.YAML == "" {
continue
}
nameNode := yaml.Node{
Kind: yaml.ScalarNode,
Value: opt.YAML,
HeadComment: wordwrap.WrapString(opt.Description, 80),
}
var valueNode yaml.Node
if m, ok := opt.Value.(yaml.Marshaler); ok {
v, err := m.MarshalYAML()
if err != nil {
return nil, xerrors.Errorf(
"marshal %q: %w", opt.Name, err,
)
}
valueNode, ok = v.(yaml.Node)
if !ok {
return nil, xerrors.Errorf(
"marshal %q: unexpected underlying type %T",
opt.Name, v,
)
}
} else {
valueNode = yaml.Node{
Kind: yaml.ScalarNode,
Value: opt.Value.String(),
}
}
var group []string
for _, g := range opt.Group.Ancestry() {
if g.Name == "" {
return nil, xerrors.Errorf(
"group name is empty for %q, groups: %+v",
opt.Name,
opt.Group,
)
}
group = append(group, strcase.ToLowerCamel(g.Name))
}
var groupDesc string
if opt.Group != nil {
groupDesc = wordwrap.WrapString(opt.Group.Description, 80)
}
parentValueNode := deepMapNode(
&root, group,
groupDesc,
)
parentValueNode.Content = append(
parentValueNode.Content,
&nameNode,
&valueNode,
)
}
return &root, nil
}
+57
View File
@@ -0,0 +1,57 @@
package clibase_test
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/coder/coder/cli/clibase"
)
func TestOption_ToYAML(t *testing.T) {
t.Parallel()
t.Run("RequireKey", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
Default: "billie",
},
}
node, err := os.ToYAML()
require.NoError(t, err)
require.Len(t, node.Content, 0)
})
t.Run("SimpleString", func(t *testing.T) {
t.Parallel()
var workspaceName clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Workspace Name",
Value: &workspaceName,
Default: "billie",
Description: "The workspace's name.",
Group: &clibase.Group{Name: "Names"},
YAML: "workspaceName",
},
}
err := os.SetDefaults(nil)
require.NoError(t, err)
n, err := os.ToYAML()
require.NoError(t, err)
// Visually inspect for now.
byt, err := yaml.Marshal(n)
require.NoError(t, err)
t.Logf("Raw YAML:\n%s", string(byt))
})
}
-185
View File
@@ -1,185 +0,0 @@
// Package cliflag extends flagset with environment variable defaults.
//
// Usage:
//
// cliflag.String(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard")
//
// Will produce the following usage docs:
//
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
package cliflag
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/coder/coder/cli/cliui"
)
// IsSetBool returns the value of the boolean flag if it is set.
// It returns false if the flag isn't set or if any error occurs attempting
// to parse the value of the flag.
func IsSetBool(cmd *cobra.Command, name string) bool {
val, ok := IsSet(cmd, name)
if !ok {
return false
}
b, err := strconv.ParseBool(val)
return err == nil && b
}
// IsSet returns the string value of the flag and whether it was set.
func IsSet(cmd *cobra.Command, name string) (string, bool) {
flag := cmd.Flag(name)
if flag == nil {
return "", false
}
return flag.Value.String(), flag.Changed
}
// String sets a string flag on the given flag set.
func String(flagset *pflag.FlagSet, name, shorthand, env, def, usage string) {
v, ok := os.LookupEnv(env)
if !ok || v == "" {
v = def
}
flagset.StringP(name, shorthand, v, fmtUsage(usage, env))
}
// StringVarP sets a string flag on the given flag set.
func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string, env string, def string, usage string) {
v, ok := os.LookupEnv(env)
if !ok || v == "" {
v = def
}
flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env))
}
func StringArray(flagset *pflag.FlagSet, name, shorthand, env string, def []string, usage string) {
v, ok := os.LookupEnv(env)
if !ok || v == "" {
if v == "" {
def = []string{}
} else {
def = strings.Split(v, ",")
}
}
flagset.StringArrayP(name, shorthand, def, fmtUsage(usage, env))
}
func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) {
val, ok := os.LookupEnv(env)
if ok {
if val == "" {
def = []string{}
} else {
def = strings.Split(val, ",")
}
}
flagset.StringArrayVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
}
// Uint8VarP sets a uint8 flag on the given flag set.
func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string, env string, def uint8, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
vi64, err := strconv.ParseUint(val, 10, 8)
if err != nil {
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
}
// IntVarP sets a uint8 flag on the given flag set.
func IntVarP(flagset *pflag.FlagSet, ptr *int, name string, shorthand string, env string, def int, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
vi64, err := strconv.ParseUint(val, 10, 8)
if err != nil {
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.IntVarP(ptr, name, shorthand, int(vi64), fmtUsage(usage, env))
}
func Bool(flagset *pflag.FlagSet, name, shorthand, env string, def bool, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
return
}
valb, err := strconv.ParseBool(val)
if err != nil {
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.BoolP(name, shorthand, valb, fmtUsage(usage, env))
}
// BoolVarP sets a bool flag on the given flag set.
func BoolVarP(flagset *pflag.FlagSet, ptr *bool, name string, shorthand string, env string, def bool, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
valb, err := strconv.ParseBool(val)
if err != nil {
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.BoolVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
}
// DurationVarP sets a time.Duration flag on the given flag set.
func DurationVarP(flagset *pflag.FlagSet, ptr *time.Duration, name string, shorthand string, env string, def time.Duration, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
valb, err := time.ParseDuration(val)
if err != nil {
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.DurationVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
}
func fmtUsage(u string, env string) string {
if env != "" {
// Avoid double dotting.
dot := "."
if strings.HasSuffix(u, ".") {
dot = ""
}
u = fmt.Sprintf("%s%s\n"+cliui.Styles.Placeholder.Render("Consumes $%s"), u, dot, env)
}
return u
}
-277
View File
@@ -1,277 +0,0 @@
package cliflag_test
import (
"fmt"
"strconv"
"testing"
"time"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cryptorand"
)
// Testcliflag cannot run in parallel because it uses t.Setenv.
//
//nolint:paralleltest
func TestCliflag(t *testing.T) {
t.Run("StringDefault", func(t *testing.T) {
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.String(10)
cliflag.String(flagset, name, shorthand, env, def, usage)
got, err := flagset.GetString(name)
require.NoError(t, err)
require.Equal(t, def, got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("StringEnvVar", func(t *testing.T) {
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.String(10)
cliflag.String(flagset, name, shorthand, env, def, usage)
got, err := flagset.GetString(name)
require.NoError(t, err)
require.Equal(t, envValue, got)
})
t.Run("StringVarPDefault", func(t *testing.T) {
var ptr string
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.String(10)
cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetString(name)
require.NoError(t, err)
require.Equal(t, def, got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("StringVarPEnvVar", func(t *testing.T) {
var ptr string
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.String(10)
cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetString(name)
require.NoError(t, err)
require.Equal(t, envValue, got)
})
t.Run("EmptyEnvVar", func(t *testing.T) {
var ptr string
flagset, name, shorthand, _, usage := randomFlag()
def, _ := cryptorand.String(10)
cliflag.StringVarP(flagset, &ptr, name, shorthand, "", def, usage)
got, err := flagset.GetString(name)
require.NoError(t, err)
require.Equal(t, def, got)
require.Contains(t, flagset.FlagUsages(), usage)
require.NotContains(t, flagset.FlagUsages(), "Consumes")
})
t.Run("StringArrayDefault", func(t *testing.T) {
var ptr []string
flagset, name, shorthand, env, usage := randomFlag()
def := []string{"hello"}
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetStringArray(name)
require.NoError(t, err)
require.Equal(t, def, got)
})
t.Run("StringArrayEnvVar", func(t *testing.T) {
var ptr []string
flagset, name, shorthand, env, usage := randomFlag()
t.Setenv(env, "wow,test")
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
got, err := flagset.GetStringArray(name)
require.NoError(t, err)
require.Equal(t, []string{"wow", "test"}, got)
})
t.Run("StringArrayEnvVarEmpty", func(t *testing.T) {
var ptr []string
flagset, name, shorthand, env, usage := randomFlag()
t.Setenv(env, "")
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
got, err := flagset.GetStringArray(name)
require.NoError(t, err)
require.Equal(t, []string{}, got)
})
t.Run("UInt8Default", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Int63n(10)
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
got, err := flagset.GetUint8(name)
require.NoError(t, err)
require.Equal(t, uint8(def), got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("UInt8EnvVar", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Int63n(10)
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
def, _ := cryptorand.Int()
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
got, err := flagset.GetUint8(name)
require.NoError(t, err)
require.Equal(t, uint8(envValue), got)
})
t.Run("UInt8FailParse", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.Int63n(10)
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
got, err := flagset.GetUint8(name)
require.NoError(t, err)
require.Equal(t, uint8(def), got)
})
t.Run("IntDefault", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Int63n(10)
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(def), got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("IntEnvVar", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Int63n(10)
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
def, _ := cryptorand.Int()
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(envValue), got)
})
t.Run("IntFailParse", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.Int63n(10)
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(def), got)
})
t.Run("BoolDefault", func(t *testing.T) {
var ptr bool
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Bool()
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetBool(name)
require.NoError(t, err)
require.Equal(t, def, got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("BoolEnvVar", func(t *testing.T) {
var ptr bool
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Bool()
t.Setenv(env, strconv.FormatBool(envValue))
def, _ := cryptorand.Bool()
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetBool(name)
require.NoError(t, err)
require.Equal(t, envValue, got)
})
t.Run("BoolFailParse", func(t *testing.T) {
var ptr bool
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.Bool()
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetBool(name)
require.NoError(t, err)
require.Equal(t, def, got)
})
t.Run("DurationDefault", func(t *testing.T) {
var ptr time.Duration
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Duration()
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetDuration(name)
require.NoError(t, err)
require.Equal(t, def, got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("DurationEnvVar", func(t *testing.T) {
var ptr time.Duration
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Duration()
t.Setenv(env, envValue.String())
def, _ := cryptorand.Duration()
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetDuration(name)
require.NoError(t, err)
require.Equal(t, envValue, got)
})
t.Run("DurationFailParse", func(t *testing.T) {
var ptr time.Duration
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.Duration()
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetDuration(name)
require.NoError(t, err)
require.Equal(t, def, got)
})
}
func randomFlag() (*pflag.FlagSet, string, string, string, string) {
fsname, _ := cryptorand.String(10)
flagset := pflag.NewFlagSet(fsname, pflag.PanicOnError)
name, _ := cryptorand.String(10)
shorthand, _ := cryptorand.String(1)
env, _ := cryptorand.String(10)
usage, _ := cryptorand.String(10)
return flagset, name, shorthand, env, usage
}
+155 -16
View File
@@ -3,43 +3,71 @@ package clitest
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/testutil"
)
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
return NewWithSubcommands(t, cli.AGPL(), args...)
func New(t *testing.T, args ...string) (*clibase.Invocation, config.Root) {
var root cli.RootCmd
cmd, err := root.Command(root.AGPL())
require.NoError(t, err)
return NewWithCommand(t, cmd, args...)
}
func NewWithSubcommands(
t *testing.T, subcommands []*cobra.Command, args ...string,
) (*cobra.Command, config.Root) {
cmd := cli.Root(subcommands)
dir := t.TempDir()
root := config.Root(dir)
cmd.SetArgs(append([]string{"--global-config", dir}, args...))
type logWriter struct {
prefix string
t *testing.T
}
// We could consider using writers
// that log via t.Log here instead.
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
func (l *logWriter) Write(p []byte) (n int, err error) {
trimmed := strings.TrimSpace(string(p))
if trimmed == "" {
return len(p), nil
}
l.t.Log(
l.prefix + ": " + trimmed,
)
return len(p), nil
}
return cmd, root
func NewWithCommand(
t *testing.T, cmd *clibase.Cmd, args ...string,
) (*clibase.Invocation, config.Root) {
configDir := config.Root(t.TempDir())
i := &clibase.Invocation{
Command: cmd,
Args: append([]string{"--global-config", string(configDir)}, args...),
Stdin: io.LimitReader(nil, 0),
Stdout: (&logWriter{prefix: "stdout", t: t}),
Stderr: (&logWriter{prefix: "stderr", t: t}),
}
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
// These can be overridden by the test.
return i, configDir
}
// SetupConfig applies the URL and SessionToken of the client to the config.
@@ -78,7 +106,7 @@ func extractTar(t *testing.T, data []byte, directory string) {
path := filepath.Join(directory, header.Name)
mode := header.FileInfo().Mode()
if mode == 0 {
mode = 0600
mode = 0o600
}
switch header.Typeflag {
case tar.TypeDir:
@@ -98,3 +126,114 @@ func extractTar(t *testing.T, data []byte, directory string) {
}
}
}
// Start runs the command in a goroutine and cleans it up when
// the test completed.
func Start(t *testing.T, inv *clibase.Invocation) {
t.Helper()
closeCh := make(chan struct{})
go func() {
defer close(closeCh)
err := StartWithWaiter(t, inv).Wait()
switch {
case errors.Is(err, context.Canceled):
return
default:
assert.NoError(t, err)
}
}()
t.Cleanup(func() {
<-closeCh
})
}
// Run runs the command and asserts that there is no error.
func Run(t *testing.T, inv *clibase.Invocation) {
t.Helper()
err := inv.Run()
require.NoError(t, err)
}
type ErrorWaiter struct {
waitOnce sync.Once
cachedError error
c <-chan error
t *testing.T
}
func (w *ErrorWaiter) Wait() error {
w.waitOnce.Do(func() {
var ok bool
w.cachedError, ok = <-w.c
if !ok {
panic("unexpoected channel close")
}
})
return w.cachedError
}
func (w *ErrorWaiter) RequireSuccess() {
require.NoError(w.t, w.Wait())
}
func (w *ErrorWaiter) RequireError() {
require.Error(w.t, w.Wait())
}
func (w *ErrorWaiter) RequireContains(s string) {
require.ErrorContains(w.t, w.Wait(), s)
}
func (w *ErrorWaiter) RequireIs(want error) {
require.ErrorIs(w.t, w.Wait(), want)
}
func (w *ErrorWaiter) RequireAs(want interface{}) {
require.ErrorAs(w.t, w.Wait(), want)
}
// StartWithWaiter runs the command in a goroutine but returns the error
// instead of asserting it. This is useful for testing error cases.
func StartWithWaiter(t *testing.T, inv *clibase.Invocation) *ErrorWaiter {
t.Helper()
errCh := make(chan error, 1)
var cleaningUp atomic.Bool
var (
ctx = inv.Context()
cancel func()
)
if _, ok := ctx.Deadline(); !ok {
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(testutil.WaitMedium))
} else {
ctx, cancel = context.WithCancel(inv.Context())
}
inv = inv.WithContext(ctx)
go func() {
defer close(errCh)
err := inv.Run()
if cleaningUp.Load() && errors.Is(err, context.DeadlineExceeded) {
// If we're cleaning up, this error is likely related to the
// CLI teardown process. E.g., the server could be slow to shut
// down Postgres.
t.Logf("command %q timed out during test cleanup", inv.Command.FullName())
}
errCh <- err
}()
// Don't exit test routine until server is done.
t.Cleanup(func() {
cancel()
cleaningUp.Store(true)
<-errCh
})
return &ErrorWaiter{c: errCh, t: t}
}
+3 -7
View File
@@ -18,13 +18,9 @@ func TestCli(t *testing.T) {
t.Parallel()
clitest.CreateTemplateVersionSource(t, nil)
client := coderdtest.New(t, nil)
cmd, config := clitest.New(t)
i, config := clitest.New(t)
clitest.SetupConfig(t, client, config)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
_ = cmd.Execute()
}()
pty := ptytest.New(t).Attach(i)
clitest.Start(t, i)
pty.ExpectMatch("coder")
}
+159 -41
View File
@@ -10,16 +10,24 @@ import (
"time"
"github.com/briandowns/spinner"
"github.com/muesli/reflow/indent"
"github.com/muesli/reflow/wordwrap"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
var (
AgentStartError = xerrors.New("agent startup exited with non-zero exit status")
AgentShuttingDown = xerrors.New("agent is shutting down")
)
type AgentOptions struct {
WorkspaceName string
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
FetchInterval time.Duration
WarnInterval time.Duration
NoWait bool // If true, don't wait for the agent to be ready.
}
// Agent displays a spinning indicator that waits for a workspace agent to connect.
@@ -36,48 +44,33 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
return xerrors.Errorf("fetch: %w", err)
}
if agent.Status == codersdk.WorkspaceAgentConnected {
// Fast path if the agent is ready (avoid showing connecting prompt).
// We don't take the fast path for opts.NoWait yet because we want to
// show the message.
if agent.Status == codersdk.WorkspaceAgentConnected &&
(agent.LoginBeforeReady || agent.LifecycleState == codersdk.WorkspaceAgentLifecycleReady) {
return nil
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
spin.Writer = writer
spin.ForceOutput = true
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..."
spin.Start()
defer spin.Stop()
spin.Suffix = waitingMessage(agent, opts).Spin
ctx, cancelFunc := context.WithCancel(ctx)
defer cancelFunc()
stopSpin := make(chan os.Signal, 1)
signal.Notify(stopSpin, os.Interrupt)
defer signal.Stop(stopSpin)
go func() {
select {
case <-ctx.Done():
return
case <-stopSpin:
}
cancelFunc()
signal.Stop(stopSpin)
spin.Stop()
// nolint:revive
os.Exit(1)
}()
var waitMessage string
messageAfter := time.NewTimer(opts.WarnInterval)
defer messageAfter.Stop()
waitMessage := &message{}
showMessage := func() {
resourceMutex.Lock()
defer resourceMutex.Unlock()
m := waitingMessage(agent)
if m == waitMessage {
m := waitingMessage(agent, opts)
if m.Prompt == waitMessage.Prompt {
return
}
moveUp := ""
if waitMessage != "" {
if waitMessage.Prompt != "" {
// If this is an update, move a line up
// to keep it tidy and aligned.
moveUp = "\033[1A"
@@ -86,20 +79,43 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
// Stop the spinner while we write our message.
spin.Stop()
spin.Suffix = waitMessage.Spin
// Clear the line and (if necessary) move up a line to write our message.
_, _ = fmt.Fprintf(writer, "\033[2K%s%s\n\n", moveUp, Styles.Paragraph.Render(Styles.Prompt.String()+waitMessage))
_, _ = fmt.Fprintf(writer, "\033[2K%s\n%s\n", moveUp, waitMessage.Prompt)
select {
case <-ctx.Done():
default:
// Safe to resume operation.
spin.Start()
if spin.Suffix != "" {
spin.Start()
}
}
}
// Fast path for showing the error message even when using no wait,
// we do this just before starting the spinner to avoid needless
// spinning.
if agent.Status == codersdk.WorkspaceAgentConnected &&
!agent.LoginBeforeReady && opts.NoWait {
showMessage()
return nil
}
// Start spinning after fast paths are handled.
if spin.Suffix != "" {
spin.Start()
}
defer spin.Stop()
warnAfter := time.NewTimer(opts.WarnInterval)
defer warnAfter.Stop()
warningShown := make(chan struct{})
go func() {
select {
case <-ctx.Done():
case <-messageAfter.C:
messageAfter.Stop()
close(warningShown)
case <-warnAfter.C:
close(warningShown)
showMessage()
}
}()
@@ -121,6 +137,33 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
resourceMutex.Unlock()
switch agent.Status {
case codersdk.WorkspaceAgentConnected:
// NOTE(mafredri): Once we have access to the workspace agent's
// startup script logs, we can show them here.
// https://github.com/coder/coder/issues/2957
if !agent.LoginBeforeReady && !opts.NoWait {
switch agent.LifecycleState {
case codersdk.WorkspaceAgentLifecycleReady:
return nil
case codersdk.WorkspaceAgentLifecycleStartTimeout:
showMessage()
case codersdk.WorkspaceAgentLifecycleStartError:
showMessage()
return AgentStartError
case codersdk.WorkspaceAgentLifecycleShuttingDown, codersdk.WorkspaceAgentLifecycleShutdownTimeout,
codersdk.WorkspaceAgentLifecycleShutdownError, codersdk.WorkspaceAgentLifecycleOff:
showMessage()
return AgentShuttingDown
default:
select {
case <-warningShown:
showMessage()
default:
// This state is normal, we don't want
// to show a message prematurely.
}
}
continue
}
return nil
case codersdk.WorkspaceAgentTimeout, codersdk.WorkspaceAgentDisconnected:
showMessage()
@@ -128,19 +171,94 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
}
}
func waitingMessage(agent codersdk.WorkspaceAgent) string {
var m string
type message struct {
Spin string
Prompt string
Troubleshoot bool
}
func waitingMessage(agent codersdk.WorkspaceAgent, opts AgentOptions) (m *message) {
m = &message{
Spin: fmt.Sprintf("Waiting for connection from %s...", Styles.Field.Render(agent.Name)),
Prompt: "Don't panic, your workspace is booting up!",
}
defer func() {
if agent.Status == codersdk.WorkspaceAgentConnected && opts.NoWait {
m.Spin = ""
}
if m.Spin != "" {
m.Spin = " " + m.Spin
}
// We don't want to wrap the troubleshooting URL, so we'll handle word
// wrapping ourselves (vs using lipgloss).
w := wordwrap.NewWriter(Styles.Paragraph.GetWidth() - Styles.Paragraph.GetMarginLeft()*2)
w.Breakpoints = []rune{' ', '\n'}
_, _ = fmt.Fprint(w, m.Prompt)
if m.Troubleshoot {
if agent.TroubleshootingURL != "" {
_, _ = fmt.Fprintf(w, " See troubleshooting instructions at:\n%s", agent.TroubleshootingURL)
} else {
_, _ = fmt.Fprint(w, " Wait for it to (re)connect or restart your workspace.")
}
}
_, _ = fmt.Fprint(w, "\n")
// We want to prefix the prompt with a caret, but we want text on the
// following lines to align with the text on the first line (i.e. added
// spacing).
ind := " " + Styles.Prompt.String()
iw := indent.NewWriter(1, func(w io.Writer) {
_, _ = w.Write([]byte(ind))
ind = " " // Set indentation to space after initial prompt.
})
_, _ = fmt.Fprint(iw, w.String())
m.Prompt = iw.String()
}()
switch agent.Status {
case codersdk.WorkspaceAgentTimeout:
m = "The workspace agent is having trouble connecting."
m.Prompt = "The workspace agent is having trouble connecting."
case codersdk.WorkspaceAgentDisconnected:
m = "The workspace agent lost connection!"
m.Prompt = "The workspace agent lost connection!"
case codersdk.WorkspaceAgentConnected:
m.Spin = fmt.Sprintf("Waiting for %s to become ready...", Styles.Field.Render(agent.Name))
m.Prompt = "Don't panic, your workspace agent has connected and the workspace is getting ready!"
if opts.NoWait {
m.Prompt = "Your workspace is still getting ready, it may be in an incomplete state."
}
switch agent.LifecycleState {
case codersdk.WorkspaceAgentLifecycleStartTimeout:
m.Prompt = "The workspace is taking longer than expected to get ready, the agent startup script is still executing."
case codersdk.WorkspaceAgentLifecycleStartError:
m.Spin = ""
m.Prompt = "The workspace ran into a problem while getting ready, the agent startup script exited with non-zero status."
default:
switch agent.LifecycleState {
case codersdk.WorkspaceAgentLifecycleShutdownTimeout:
m.Spin = ""
m.Prompt = "The workspace is shutting down, but is taking longer than expected to shut down and the agent shutdown script is still executing."
m.Troubleshoot = true
case codersdk.WorkspaceAgentLifecycleShutdownError:
m.Spin = ""
m.Prompt = "The workspace ran into a problem while shutting down, the agent shutdown script exited with non-zero status."
m.Troubleshoot = true
case codersdk.WorkspaceAgentLifecycleShuttingDown:
m.Spin = ""
m.Prompt = "The workspace is shutting down."
case codersdk.WorkspaceAgentLifecycleOff:
m.Spin = ""
m.Prompt = "The workspace is not running."
}
// Not a failure state, no troubleshooting necessary.
return m
}
default:
// Not a failure state, no troubleshooting necessary.
return "Don't panic, your workspace is booting up!"
return m
}
if agent.TroubleshootingURL != "" {
return fmt.Sprintf("%s See troubleshooting instructions at: %s", m, agent.TroubleshootingURL)
}
return fmt.Sprintf("%s Wait for it to (re)connect or restart your workspace.", m)
m.Troubleshoot = true
return m
}
+292 -28
View File
@@ -5,10 +5,11 @@ import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
@@ -17,15 +18,20 @@ import (
func TestAgent(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var disconnected atomic.Bool
ptty := ptytest.New(t)
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentDisconnected,
Status: codersdk.WorkspaceAgentDisconnected,
LoginBeforeReady: true,
}
if disconnected.Load() {
agent.Status = codersdk.WorkspaceAgentConnected
@@ -38,41 +44,44 @@ func TestAgent(t *testing.T) {
return err
},
}
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
inv := cmd.Invoke()
ptty.Attach(inv)
done := make(chan struct{})
go func() {
defer close(done)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
ptty.ExpectMatch("lost connection")
ptty.ExpectMatchContext(ctx, "lost connection")
disconnected.Store(true)
<-done
}
func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
func TestAgent_TimeoutWithTroubleshootingURL(t *testing.T) {
t.Parallel()
ctx, _ := testutil.Context(t)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
wantURL := "https://coder.com/troubleshoot"
var connected, timeout atomic.Bool
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentConnecting,
TroubleshootingURL: "https://coder.com/troubleshoot",
TroubleshootingURL: wantURL,
LoginBeforeReady: true,
}
switch {
case !connected.Load() && timeout.Load():
agent.Status = codersdk.WorkspaceAgentTimeout
case connected.Load():
agent.Status = codersdk.WorkspaceAgentConnected
case timeout.Load():
agent.Status = codersdk.WorkspaceAgentTimeout
}
return agent, nil
},
@@ -83,17 +92,272 @@ func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
},
}
ptty := ptytest.New(t)
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
done := make(chan struct{})
inv := cmd.Invoke()
ptty.Attach(inv)
done := make(chan error, 1)
go func() {
defer close(done)
err := cmd.ExecuteContext(ctx)
assert.NoError(t, err)
done <- inv.WithContext(ctx).Run()
}()
ptty.ExpectMatch("Don't panic")
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
timeout.Store(true)
ptty.ExpectMatch(wantURL)
ptty.ExpectMatchContext(ctx, wantURL)
connected.Store(true)
<-done
require.NoError(t, <-done)
}
func TestAgent_StartupTimeout(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
var status, state atomic.String
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentConnecting,
LoginBeforeReady: false,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
TroubleshootingURL: wantURL,
}
if s := status.Load(); s != "" {
agent.Status = codersdk.WorkspaceAgentStatus(s)
}
if s := state.Load(); s != "" {
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
}
return agent, nil
},
FetchInterval: time.Millisecond,
WarnInterval: time.Millisecond,
NoWait: false,
})
return err
},
}
ptty := ptytest.New(t)
inv := cmd.Invoke()
ptty.Attach(inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
setStatus(codersdk.WorkspaceAgentConnecting)
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
setStatus(codersdk.WorkspaceAgentConnected)
setState(codersdk.WorkspaceAgentLifecycleStarting)
ptty.ExpectMatchContext(ctx, "workspace is getting ready")
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
ptty.ExpectMatchContext(ctx, "is taking longer")
ptty.ExpectMatchContext(ctx, wantURL)
setState(codersdk.WorkspaceAgentLifecycleReady)
require.NoError(t, <-done)
}
func TestAgent_StartErrorExit(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
var status, state atomic.String
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentConnecting,
LoginBeforeReady: false,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
TroubleshootingURL: wantURL,
}
if s := status.Load(); s != "" {
agent.Status = codersdk.WorkspaceAgentStatus(s)
}
if s := state.Load(); s != "" {
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
}
return agent, nil
},
FetchInterval: time.Millisecond,
WarnInterval: 60 * time.Second,
NoWait: false,
})
return err
},
}
ptty := ptytest.New(t)
inv := cmd.Invoke()
ptty.Attach(inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
setStatus(codersdk.WorkspaceAgentConnected)
setState(codersdk.WorkspaceAgentLifecycleStarting)
ptty.ExpectMatchContext(ctx, "to become ready...")
setState(codersdk.WorkspaceAgentLifecycleStartError)
ptty.ExpectMatchContext(ctx, "ran into a problem")
err := <-done
require.ErrorIs(t, err, cliui.AgentStartError, "lifecycle start_error should exit with error")
}
func TestAgent_NoWait(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
var status, state atomic.String
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentConnecting,
LoginBeforeReady: false,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
TroubleshootingURL: wantURL,
}
if s := status.Load(); s != "" {
agent.Status = codersdk.WorkspaceAgentStatus(s)
}
if s := state.Load(); s != "" {
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
}
return agent, nil
},
FetchInterval: time.Millisecond,
WarnInterval: time.Second,
NoWait: true,
})
return err
},
}
ptty := ptytest.New(t)
inv := cmd.Invoke()
ptty.Attach(inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
setStatus(codersdk.WorkspaceAgentConnecting)
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
setStatus(codersdk.WorkspaceAgentConnected)
require.NoError(t, <-done, "created - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStarting)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "starting - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "start timeout - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStartError)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "start error - should exit early")
setState(codersdk.WorkspaceAgentLifecycleReady)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "ready - should exit early")
}
func TestAgent_LoginBeforeReadyEnabled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
var status, state atomic.String
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
err := cliui.Agent(inv.Context(), inv.Stdout, cliui.AgentOptions{
WorkspaceName: "example",
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
agent := codersdk.WorkspaceAgent{
Status: codersdk.WorkspaceAgentConnecting,
LoginBeforeReady: true,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
TroubleshootingURL: wantURL,
}
if s := status.Load(); s != "" {
agent.Status = codersdk.WorkspaceAgentStatus(s)
}
if s := state.Load(); s != "" {
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
}
return agent, nil
},
FetchInterval: time.Millisecond,
WarnInterval: time.Second,
NoWait: false,
})
return err
},
}
inv := cmd.Invoke()
ptty := ptytest.New(t)
ptty.Attach(inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
setStatus(codersdk.WorkspaceAgentConnecting)
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
setStatus(codersdk.WorkspaceAgentConnected)
require.NoError(t, <-done, "created - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStarting)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "starting - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "start timeout - should exit early")
setState(codersdk.WorkspaceAgentLifecycleStartError)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "start error - should exit early")
setState(codersdk.WorkspaceAgentLifecycleReady)
go func() { done <- inv.WithContext(ctx).Run() }()
require.NoError(t, <-done, "ready - should exit early")
}
+7 -5
View File
@@ -49,10 +49,12 @@ var Styles = struct {
Keyword: defaultStyles.Keyword,
Paragraph: defaultStyles.Paragraph,
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#4d46b3"}),
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
Prompt: defaultStyles.Prompt.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
FocusedPrompt: defaultStyles.FocusedPrompt.Copy().Foreground(lipgloss.Color("#651fff")),
Fuchsia: defaultStyles.SelectedMenuItem.Copy(),
Logo: defaultStyles.Logo.SetString("Coder"),
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
Wrap: lipgloss.NewStyle().Width(80),
Logo: defaultStyles.Logo.Copy().SetString("Coder"),
Warn: lipgloss.NewStyle().Foreground(
lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"},
),
Wrap: lipgloss.NewStyle().Width(80),
}
+72
View File
@@ -0,0 +1,72 @@
package cliui
import (
"context"
"fmt"
"io"
"time"
"github.com/briandowns/spinner"
"github.com/coder/coder/codersdk"
)
type GitAuthOptions struct {
Fetch func(context.Context) ([]codersdk.TemplateVersionGitAuth, error)
FetchInterval time.Duration
}
func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
if opts.FetchInterval == 0 {
opts.FetchInterval = 500 * time.Millisecond
}
gitAuth, err := opts.Fetch(ctx)
if err != nil {
return err
}
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
spin.Writer = writer
spin.ForceOutput = true
spin.Suffix = " Waiting for Git authentication..."
defer spin.Stop()
ticker := time.NewTicker(opts.FetchInterval)
defer ticker.Stop()
for _, auth := range gitAuth {
if auth.Authenticated {
return nil
}
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL)
ticker.Reset(opts.FetchInterval)
spin.Start()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
gitAuth, err := opts.Fetch(ctx)
if err != nil {
return err
}
var authed bool
for _, a := range gitAuth {
if !a.Authenticated || a.ID != auth.ID {
continue
}
authed = true
break
}
// The user authenticated with the provider!
if authed {
break
}
}
spin.Stop()
_, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty())
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package cliui_test
import (
"context"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func TestGitAuth(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
ptty := ptytest.New(t)
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
var fetched atomic.Bool
return cliui.GitAuth(inv.Context(), inv.Stdout, cliui.GitAuthOptions{
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
defer fetched.Store(true)
return []codersdk.TemplateVersionGitAuth{{
ID: "github",
Type: codersdk.GitProviderGitHub,
Authenticated: fetched.Load(),
AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"),
}}, nil
},
FetchInterval: time.Millisecond,
})
},
}
inv := cmd.Invoke().WithContext(ctx)
ptty.Attach(inv)
done := make(chan struct{})
go func() {
defer close(done)
err := inv.Run()
assert.NoError(t, err)
}()
ptty.ExpectMatchContext(ctx, "You must authenticate with")
ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
<-done
}
+43 -5
View File
@@ -10,17 +10,22 @@ import (
// cliMessage provides a human-readable message for CLI errors and messages.
type cliMessage struct {
Level string
Style lipgloss.Style
Header string
Prefix string
Lines []string
}
// String formats the CLI message for consumption by a human.
func (m cliMessage) String() string {
var str strings.Builder
_, _ = fmt.Fprintf(&str, "%s\r\n",
Styles.Bold.Render(m.Header))
if m.Prefix != "" {
_, _ = str.WriteString(m.Style.Bold(true).Render(m.Prefix))
}
_, _ = str.WriteString(m.Style.Bold(false).Render(m.Header))
_, _ = str.WriteString("\r\n")
for _, line := range m.Lines {
_, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line)
}
@@ -30,9 +35,42 @@ func (m cliMessage) String() string {
// Warn writes a log to the writer provided.
func Warn(wtr io.Writer, header string, lines ...string) {
_, _ = fmt.Fprint(wtr, cliMessage{
Level: "warning",
Style: Styles.Warn,
Style: Styles.Warn.Copy(),
Prefix: "WARN: ",
Header: header,
Lines: lines,
}.String())
}
// Warn writes a formatted log to the writer provided.
func Warnf(wtr io.Writer, fmtStr string, args ...interface{}) {
Warn(wtr, fmt.Sprintf(fmtStr, args...))
}
// Info writes a log to the writer provided.
func Info(wtr io.Writer, header string, lines ...string) {
_, _ = fmt.Fprint(wtr, cliMessage{
Header: header,
Lines: lines,
}.String())
}
// Infof writes a formatted log to the writer provided.
func Infof(wtr io.Writer, fmtStr string, args ...interface{}) {
Info(wtr, fmt.Sprintf(fmtStr, args...))
}
// Error writes a log to the writer provided.
func Error(wtr io.Writer, header string, lines ...string) {
_, _ = fmt.Fprint(wtr, cliMessage{
Style: Styles.Error.Copy(),
Prefix: "ERROR: ",
Header: header,
Lines: lines,
}.String())
}
// Errorf writes a formatted log to the writer provided.
func Errorf(wtr io.Writer, fmtStr string, args ...interface{}) {
Error(wtr, fmt.Sprintf(fmtStr, args...))
}
+173
View File
@@ -0,0 +1,173 @@
package cliui
import (
"context"
"encoding/json"
"reflect"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
)
type OutputFormat interface {
ID() string
AttachOptions(opts *clibase.OptionSet)
Format(ctx context.Context, data any) (string, error)
}
type OutputFormatter struct {
formats []OutputFormat
formatID string
}
// NewOutputFormatter creates a new OutputFormatter with the given formats. The
// first format is the default format. At least two formats must be provided.
func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter {
if len(formats) < 2 {
panic("at least two output formats must be provided")
}
formatIDs := make(map[string]struct{}, len(formats))
for _, format := range formats {
if format.ID() == "" {
panic("output format ID must not be empty")
}
if _, ok := formatIDs[format.ID()]; ok {
panic("duplicate format ID: " + format.ID())
}
formatIDs[format.ID()] = struct{}{}
}
return &OutputFormatter{
formats: formats,
formatID: formats[0].ID(),
}
}
// AttachOptions attaches the --output flag to the given command, and any
// additional flags required by the output formatters.
func (f *OutputFormatter) AttachOptions(opts *clibase.OptionSet) {
for _, format := range f.formats {
format.AttachOptions(opts)
}
formatNames := make([]string, 0, len(f.formats))
for _, format := range f.formats {
formatNames = append(formatNames, format.ID())
}
*opts = append(*opts,
clibase.Option{
Flag: "output",
FlagShorthand: "o",
Default: f.formats[0].ID(),
Value: clibase.StringOf(&f.formatID),
Description: "Output format. Available formats: " + strings.Join(formatNames, ", ") + ".",
},
)
}
// Format formats the given data using the format specified by the --output
// flag. If the flag is not set, the default format is used.
func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) {
for _, format := range f.formats {
if format.ID() == f.formatID {
return format.Format(ctx, data)
}
}
return "", xerrors.Errorf("unknown output format %q", f.formatID)
}
type tableFormat struct {
defaultColumns []string
allColumns []string
sort string
columns []string
}
var _ OutputFormat = &tableFormat{}
// TableFormat creates a table formatter for the given output type. The output
// type should be specified as an empty slice of the desired type.
//
// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"})
//
// defaultColumns is optional and specifies the default columns to display. If
// not specified, all columns are displayed by default.
func TableFormat(out any, defaultColumns []string) OutputFormat {
v := reflect.Indirect(reflect.ValueOf(out))
if v.Kind() != reflect.Slice {
panic("DisplayTable called with a non-slice type")
}
// Get the list of table column headers.
headers, defaultSort, err := typeToTableHeaders(v.Type().Elem())
if err != nil {
panic("parse table headers: " + err.Error())
}
tf := &tableFormat{
defaultColumns: headers,
allColumns: headers,
sort: defaultSort,
}
if len(defaultColumns) > 0 {
tf.defaultColumns = defaultColumns
}
return tf
}
// ID implements OutputFormat.
func (*tableFormat) ID() string {
return "table"
}
// AttachOptions implements OutputFormat.
func (f *tableFormat) AttachOptions(opts *clibase.OptionSet) {
*opts = append(*opts,
clibase.Option{
Flag: "column",
FlagShorthand: "c",
Default: strings.Join(f.defaultColumns, ","),
Value: clibase.StringArrayOf(&f.columns),
Description: "Columns to display in table output. Available columns: " + strings.Join(f.allColumns, ", ") + ".",
},
)
}
// Format implements OutputFormat.
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
return DisplayTable(data, f.sort, f.columns)
}
type jsonFormat struct{}
var _ OutputFormat = jsonFormat{}
// JSONFormat creates a JSON formatter.
func JSONFormat() OutputFormat {
return jsonFormat{}
}
// ID implements OutputFormat.
func (jsonFormat) ID() string {
return "json"
}
// AttachOptions implements OutputFormat.
func (jsonFormat) AttachOptions(_ *clibase.OptionSet) {}
// Format implements OutputFormat.
func (jsonFormat) Format(_ context.Context, data any) (string, error) {
outBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", xerrors.Errorf("marshal output to JSON: %w", err)
}
return string(outBytes), nil
}
+136
View File
@@ -0,0 +1,136 @@
package cliui_test
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
type format struct {
id string
attachOptionsFn func(opts *clibase.OptionSet)
formatFn func(ctx context.Context, data any) (string, error)
}
var _ cliui.OutputFormat = &format{}
func (f *format) ID() string {
return f.id
}
func (f *format) AttachOptions(opts *clibase.OptionSet) {
if f.attachOptionsFn != nil {
f.attachOptionsFn(opts)
}
}
func (f *format) Format(ctx context.Context, data any) (string, error) {
if f.formatFn != nil {
return f.formatFn(ctx, data)
}
return "", nil
}
func Test_OutputFormatter(t *testing.T) {
t.Parallel()
t.Run("RequiresTwoFormatters", func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
cliui.NewOutputFormatter()
})
require.Panics(t, func() {
cliui.NewOutputFormatter(cliui.JSONFormat())
})
})
t.Run("NoMissingFormatID", func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
cliui.NewOutputFormatter(
cliui.JSONFormat(),
&format{id: ""},
)
})
})
t.Run("NoDuplicateFormats", func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
cliui.NewOutputFormatter(
cliui.JSONFormat(),
cliui.JSONFormat(),
)
})
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
var called int64
f := cliui.NewOutputFormatter(
cliui.JSONFormat(),
&format{
id: "foo",
attachOptionsFn: func(opts *clibase.OptionSet) {
opts.Add(clibase.Option{
Name: "foo",
Flag: "foo",
FlagShorthand: "f",
Value: clibase.DiscardValue,
Description: "foo flag 1234",
})
},
formatFn: func(_ context.Context, _ any) (string, error) {
atomic.AddInt64(&called, 1)
return "foo", nil
},
},
)
cmd := &clibase.Cmd{}
f.AttachOptions(&cmd.Options)
fs := cmd.Options.FlagSet()
selected, err := fs.GetString("output")
require.NoError(t, err)
require.Equal(t, "json", selected)
usage := fs.FlagUsages()
require.Contains(t, usage, "Available formats: json, foo")
require.Contains(t, usage, "foo flag 1234")
ctx := context.Background()
data := []string{"hi", "dean", "was", "here"}
out, err := f.Format(ctx, data)
require.NoError(t, err)
var got []string
require.NoError(t, json.Unmarshal([]byte(out), &got))
require.Equal(t, data, got)
require.EqualValues(t, 0, atomic.LoadInt64(&called))
require.NoError(t, fs.Set("output", "foo"))
out, err = f.Format(ctx, data)
require.NoError(t, err)
require.Equal(t, "foo", out)
require.EqualValues(t, 1, atomic.LoadInt64(&called))
require.NoError(t, fs.Set("output", "bar"))
out, err = f.Format(ctx, data)
require.Error(t, err)
require.ErrorContains(t, err, "bar")
require.Equal(t, "", out)
require.EqualValues(t, 1, atomic.LoadInt64(&called))
})
}
+49 -23
View File
@@ -1,19 +1,19 @@
package cliui
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/codersdk"
)
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchema) (string, error) {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render("var."+parameterSchema.Name))
func ParameterSchema(inv *clibase.Invocation, parameterSchema codersdk.ParameterSchema) (string, error) {
_, _ = fmt.Fprintln(inv.Stdout, Styles.Bold.Render("var."+parameterSchema.Name))
if parameterSchema.Description != "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
}
var err error
@@ -27,15 +27,15 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
var value string
if len(options) > 0 {
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
value, err = Select(cmd, SelectOptions{
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
value, err = Select(inv, SelectOptions{
Options: options,
Default: parameterSchema.DefaultSourceValue,
HideSearch: true,
})
if err == nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
_, _ = fmt.Fprintln(inv.Stdout)
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(value))
}
} else {
text := "Enter a value"
@@ -44,7 +44,7 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
}
text += ":"
value, err = Prompt(cmd, PromptOptions{
value, err = Prompt(inv, PromptOptions{
Text: Styles.Bold.Render(text),
})
value = strings.TrimSpace(value)
@@ -61,36 +61,62 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
return value, nil
}
func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render(templateVersionParameter.Name))
if templateVersionParameter.Description != "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.Description, "\n"), "\n "))+"\n")
func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
label := templateVersionParameter.Name
if templateVersionParameter.DisplayName != "" {
label = templateVersionParameter.DisplayName
}
_, _ = fmt.Fprintln(inv.Stdout, Styles.Bold.Render(label))
if templateVersionParameter.DescriptionPlaintext != "" {
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
}
var err error
var value string
if len(templateVersionParameter.Options) > 0 {
if templateVersionParameter.Type == "list(string)" {
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
var options []string
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options)
if err != nil {
return "", err
}
values, err := MultiSelect(inv, options)
if err == nil {
v, err := json.Marshal(&values)
if err != nil {
return "", err
}
_, _ = fmt.Fprintln(inv.Stdout)
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(strings.Join(values, ", ")))
value = string(v)
}
} else if len(templateVersionParameter.Options) > 0 {
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
var richParameterOption *codersdk.TemplateVersionParameterOption
richParameterOption, err = RichSelect(cmd, RichSelectOptions{
richParameterOption, err = RichSelect(inv, RichSelectOptions{
Options: templateVersionParameter.Options,
Default: templateVersionParameter.DefaultValue,
HideSearch: true,
})
if err == nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(richParameterOption.Name))
_, _ = fmt.Fprintln(inv.Stdout)
_, _ = fmt.Fprintln(inv.Stdout, " "+Styles.Prompt.String()+Styles.Field.Render(richParameterOption.Name))
value = richParameterOption.Value
}
} else {
text := "Enter a value"
if templateVersionParameter.DefaultValue != "" {
if !templateVersionParameter.Required {
text += fmt.Sprintf(" (default: %q)", templateVersionParameter.DefaultValue)
}
text += ":"
value, err = Prompt(cmd, PromptOptions{
value, err = Prompt(inv, PromptOptions{
Text: Styles.Bold.Render(text),
Validate: func(value string) error {
return validateRichPrompt(value, templateVersionParameter)
@@ -111,8 +137,8 @@ func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.Templat
}
func validateRichPrompt(value string, p codersdk.TemplateVersionParameter) error {
return codersdk.ValidateWorkspaceBuildParameter(p, codersdk.WorkspaceBuildParameter{
return codersdk.ValidateWorkspaceBuildParameter(p, &codersdk.WorkspaceBuildParameter{
Name: p.Name,
Value: value,
})
}, nil)
}
+34 -17
View File
@@ -11,8 +11,9 @@ import (
"github.com/bgentry/speakeasy"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
)
// PromptOptions supply a set of options to the prompt.
@@ -26,8 +27,16 @@ type PromptOptions struct {
const skipPromptFlag = "yes"
func AllowSkipPrompt(cmd *cobra.Command) {
cmd.Flags().BoolP(skipPromptFlag, "y", false, "Bypass prompts")
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
// prompts.
func SkipPromptOption() clibase.Option {
return clibase.Option{
Flag: skipPromptFlag,
FlagShorthand: "y",
Description: "Bypass prompts.",
// Discard
Value: clibase.BoolOf(new(bool)),
}
}
const (
@@ -36,17 +45,17 @@ const (
)
// Prompt asks the user for input.
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
// If the cmd has a "yes" flag for skipping confirm prompts, honor it.
// If it's not a "Confirm" prompt, then don't skip. As the default value of
// "yes" makes no sense.
if opts.IsConfirm && cmd.Flags().Lookup(skipPromptFlag) != nil {
if skip, _ := cmd.Flags().GetBool(skipPromptFlag); skip {
if opts.IsConfirm && inv.ParsedFlags().Lookup(skipPromptFlag) != nil {
if skip, _ := inv.ParsedFlags().GetBool(skipPromptFlag); skip {
return ConfirmYes, nil
}
}
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
_, _ = fmt.Fprint(inv.Stdout, Styles.FocusedPrompt.String()+opts.Text+" ")
if opts.IsConfirm {
if len(opts.Default) == 0 {
opts.Default = ConfirmYes
@@ -58,19 +67,24 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
} else {
renderedNo = Styles.Bold.Render(ConfirmNo)
}
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
} else if opts.Default != "" {
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+opts.Default+") "))
}
interrupt := make(chan os.Signal, 1)
if inv.Stdin == nil {
panic("inv.Stdin is nil")
}
errCh := make(chan error, 1)
lineCh := make(chan string)
go func() {
var line string
var err error
inFile, isInputFile := cmd.InOrStdin().(*os.File)
inFile, isInputFile := inv.Stdin.(*os.File)
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
// we don't install a signal handler here because speakeasy has its own
line, err = speakeasy.Ask("")
@@ -78,7 +92,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
reader := bufio.NewReader(cmd.InOrStdin())
reader := bufio.NewReader(inv.Stdin)
line, err = reader.ReadString('\n')
// Check if the first line beings with JSON object or array chars.
@@ -96,7 +110,10 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
if line == "" {
line = opts.Default
}
lineCh <- line
select {
case <-inv.Context().Done():
case lineCh <- line:
}
}()
select {
@@ -109,16 +126,16 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
if opts.Validate != nil {
err := opts.Validate(line)
if err != nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
return Prompt(cmd, opts)
_, _ = fmt.Fprintln(inv.Stdout, defaultStyles.Error.Render(err.Error()))
return Prompt(inv, opts)
}
}
return line, nil
case <-cmd.Context().Done():
return "", cmd.Context().Err()
case <-inv.Context().Done():
return "", inv.Context().Err()
case <-interrupt:
// Print a newline so that any further output starts properly on a new line.
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(inv.Stdout)
return "", Canceled
}
}
+24 -18
View File
@@ -8,10 +8,10 @@ import (
"os/exec"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/pty"
"github.com/coder/coder/pty/ptytest"
@@ -77,9 +77,9 @@ func TestPrompt(t *testing.T) {
resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "ShouldNotSeeThis",
IsConfirm: true,
}, func(cmd *cobra.Command) {
cliui.AllowSkipPrompt(cmd)
cmd.SetArgs([]string{"-y"})
}, func(inv *clibase.Invocation) {
inv.Command.Options = append(inv.Command.Options, cliui.SkipPromptOption())
inv.Args = []string{"-y"}
})
assert.NoError(t, err)
doneChan <- resp
@@ -145,23 +145,25 @@ func TestPrompt(t *testing.T) {
})
}
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, cmdOpt func(cmd *cobra.Command)) (string, error) {
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *clibase.Invocation)) (string, error) {
value := ""
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
var err error
value, err = cliui.Prompt(cmd, opts)
value, err = cliui.Prompt(inv, opts)
return err
},
}
inv := cmd.Invoke()
// Optionally modify the cmd
if cmdOpt != nil {
cmdOpt(cmd)
if invOpt != nil {
invOpt(inv)
}
cmd.SetOut(ptty.Output())
cmd.SetErr(ptty.Output())
cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background())
inv.Stdout = ptty.Output()
inv.Stderr = ptty.Output()
inv.Stdin = ptty.Input()
return value, inv.WithContext(context.Background()).Run()
}
func TestPasswordTerminalState(t *testing.T) {
@@ -208,13 +210,17 @@ func TestPasswordTerminalState(t *testing.T) {
// nolint:unused
func passwordHelper() {
cmd := &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
cliui.Prompt(cmd, cliui.PromptOptions{
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cliui.Prompt(inv, cliui.PromptOptions{
Text: "Password:",
Secret: true,
})
return nil
},
}
cmd.ExecuteContext(context.Background())
err := cmd.Invoke().WithOS().Run()
if err != nil {
panic(err)
}
}
+15 -1
View File
@@ -41,6 +41,17 @@ type ProvisionerJobOptions struct {
Silent bool
}
type ProvisionerJobError struct {
Message string
Code codersdk.JobErrorCode
}
var _ error = new(ProvisionerJobError)
func (err *ProvisionerJobError) Error() string {
return err.Message
}
// ProvisionerJob renders a provisioner job with interactive cancellation.
func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOptions) error {
if opts.FetchInterval == 0 {
@@ -181,7 +192,10 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
return nil
case codersdk.ProvisionerJobFailed:
}
err = xerrors.New(job.Error)
err = &ProvisionerJobError{
Message: job.Error,
Code: job.ErrorCode,
}
jobMutex.Unlock()
flushLogBuffer()
return err
+8 -7
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
@@ -125,9 +125,9 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
}
jobLock := sync.Mutex{}
logs := make(chan codersdk.ProvisionerJobLog, 1)
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
return cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
return cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
FetchInterval: time.Millisecond,
Fetch: func() (codersdk.ProvisionerJob, error) {
jobLock.Lock()
@@ -145,13 +145,14 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
})
},
}
inv := cmd.Invoke()
ptty := ptytest.New(t)
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
ptty.Attach(inv)
done := make(chan struct{})
go func() {
defer close(done)
err := cmd.ExecuteContext(context.Background())
err := inv.WithContext(context.Background()).Run()
if err != nil {
assert.ErrorIs(t, err, cliui.Canceled)
}
+4
View File
@@ -26,6 +26,7 @@ func TestWorkspaceResources(t *testing.T) {
Agents: []codersdk.WorkspaceAgent{{
Name: "dev",
Status: codersdk.WorkspaceAgentConnected,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
Architecture: "amd64",
OperatingSystem: "linux",
}},
@@ -60,6 +61,7 @@ func TestWorkspaceResources(t *testing.T) {
Agents: []codersdk.WorkspaceAgent{{
CreatedAt: database.Now().Add(-10 * time.Second),
Status: codersdk.WorkspaceAgentConnecting,
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
Name: "dev",
OperatingSystem: "linux",
Architecture: "amd64",
@@ -70,12 +72,14 @@ func TestWorkspaceResources(t *testing.T) {
Name: "dev",
Agents: []codersdk.WorkspaceAgent{{
Status: codersdk.WorkspaceAgentConnected,
LifecycleState: codersdk.WorkspaceAgentLifecycleReady,
Name: "go",
Architecture: "amd64",
OperatingSystem: "linux",
}, {
DisconnectedAt: &disconnected,
Status: codersdk.WorkspaceAgentDisconnected,
LifecycleState: codersdk.WorkspaceAgentLifecycleReady,
Name: "postgres",
Architecture: "amd64",
OperatingSystem: "linux",
+45 -7
View File
@@ -8,9 +8,9 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/codersdk"
)
@@ -35,6 +35,21 @@ func init() {
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end }}`
survey.MultiSelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
{{- color "reset"}}
{{- " "}}{{- .CurrentOpt.Value}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- if not .ShowAnswer }}
{{- "\n"}}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`
}
type SelectOptions struct {
@@ -53,7 +68,7 @@ type RichSelectOptions struct {
}
// RichSelect displays a list of user options including name and description.
func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
func RichSelect(inv *clibase.Invocation, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
opts := make([]string, len(richOptions.Options))
for i, option := range richOptions.Options {
line := option.Name
@@ -63,7 +78,7 @@ func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.Te
opts[i] = line
}
selected, err := Select(cmd, SelectOptions{
selected, err := Select(inv, SelectOptions{
Options: opts,
Default: richOptions.Default,
Size: richOptions.Size,
@@ -82,7 +97,7 @@ func RichSelect(cmd *cobra.Command, richOptions RichSelectOptions) (*codersdk.Te
}
// Select displays a list of user options.
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
func Select(inv *clibase.Invocation, opts SelectOptions) (string, error) {
// The survey library used *always* fails when testing on Windows,
// as it requires a live TTY (can't be a conpty). We should fork
// this library to add a dummy fallback, that simply reads/writes
@@ -108,16 +123,39 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
is.Help.Text = ""
}
}), survey.WithStdio(fileReadWriter{
Reader: cmd.InOrStdin(),
Reader: inv.Stdin,
}, fileReadWriter{
Writer: cmd.OutOrStdout(),
}, cmd.OutOrStdout()))
Writer: inv.Stdout,
}, inv.Stdout))
if errors.Is(err, terminal.InterruptErr) {
return value, Canceled
}
return value, err
}
func MultiSelect(inv *clibase.Invocation, items []string) ([]string, error) {
// Similar hack is applied to Select()
if flag.Lookup("test.v") != nil {
return items, nil
}
prompt := &survey.MultiSelect{
Options: items,
Default: items,
}
var values []string
err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{
Reader: inv.Stdin,
}, fileReadWriter{
Writer: inv.Stdout,
}, inv.Stdout))
if errors.Is(err, terminal.InterruptErr) {
return nil, Canceled
}
return values, err
}
type fileReadWriter struct {
io.Reader
io.Writer
+48 -16
View File
@@ -1,13 +1,12 @@
package cliui_test
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
@@ -32,16 +31,16 @@ func TestSelect(t *testing.T) {
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
value := ""
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
var err error
value, err = cliui.Select(cmd, opts)
value, err = cliui.Select(inv, opts)
return err
},
}
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background())
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
func TestRichSelect(t *testing.T) {
@@ -56,11 +55,11 @@ func TestRichSelect(t *testing.T) {
{
Name: "A-Name",
Value: "A-Value",
Description: "A-Description",
Description: "A-Description.",
}, {
Name: "B-Name",
Value: "B-Value",
Description: "B-Description",
Description: "B-Description.",
},
},
})
@@ -73,16 +72,49 @@ func TestRichSelect(t *testing.T) {
func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, error) {
value := ""
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
richOption, err := cliui.RichSelect(cmd, opts)
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
richOption, err := cliui.RichSelect(inv, opts)
if err == nil {
value = richOption.Value
}
return err
},
}
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background())
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
func TestMultiSelect(t *testing.T) {
t.Parallel()
t.Run("MultiSelect", func(t *testing.T) {
items := []string{"aaa", "bbb", "ccc"}
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan []string)
go func() {
resp, err := newMultiSelect(ptty, items)
assert.NoError(t, err)
msgChan <- resp
}()
require.Equal(t, items, <-msgChan)
})
}
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
var values []string
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
selectedItems, err := cliui.MultiSelect(inv, items)
if err == nil {
values = selectedItems
}
return err
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return values, inv.Run()
}
+44 -39
View File
@@ -22,10 +22,10 @@ func Table() table.Writer {
return tableWriter
}
// FilterTableColumns returns configurations to hide columns
// filterTableColumns returns configurations to hide columns
// that are not provided in the array. If the array is empty,
// no filtering will occur!
func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
if len(columns) == 0 {
return nil
}
@@ -51,6 +51,9 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
// of structs. At least one field in the struct must have a `table:""` tag
// containing the name of the column in the outputted table.
//
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
// tag will be used to sort. An error will be returned if no field has this tag.
//
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
// malformed or a field is marked as recursive but does not contain a struct or
@@ -67,13 +70,16 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
}
// Get the list of table column headers.
headersRaw, err := typeToTableHeaders(v.Type().Elem())
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem())
if err != nil {
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
}
if len(headersRaw) == 0 {
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
}
if sort == "" {
sort = defaultSort
}
headers := make(table.Row, len(headersRaw))
for i, header := range headersRaw {
headers[i] = header
@@ -101,7 +107,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
h, ok := headersMap[column]
if !ok {
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, column, strings.Join(headersRaw, `", "`))
}
// Autocorrect
@@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
// Setup the table formatter.
tw := Table()
tw.AppendHeader(headers)
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
tw.SetColumnConfigs(filterTableColumns(headers, filterColumns))
if sort != "" {
tw.SortBy([]table.SortBy{{
Name: sort,
@@ -182,29 +188,32 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
// returned. If the table tag is malformed, an error is returned.
//
// The returned name is transformed from "snake_case" to "normal text".
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) {
tags, err := structtag.Parse(string(field.Tag))
if err != nil {
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
}
tag, err := tags.Get("table")
if err != nil || tag.Name == "-" {
// tags.Get only returns an error if the tag is not found.
return "", false, nil
return "", false, false, nil
}
recursive := false
defaultSortOpt := false
recursiveOpt := false
for _, opt := range tag.Options {
if opt == "recursive" {
recursive = true
continue
switch opt {
case "default_sort":
defaultSortOpt = true
case "recursive":
recursiveOpt = true
default:
return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
}
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
}
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil
}
func isStructOrStructPointer(t reflect.Type) bool {
@@ -214,34 +223,41 @@ func isStructOrStructPointer(t reflect.Type) bool {
// typeToTableHeaders converts a type to a slice of column names. If the given
// type is invalid (not a struct or a pointer to a struct, has invalid table
// tags, etc.), an error is returned.
func typeToTableHeaders(t reflect.Type) ([]string, error) {
func typeToTableHeaders(t reflect.Type) ([]string, string, error) {
if !isStructOrStructPointer(t) {
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
return nil, "", xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
headers := []string{}
defaultSortName := ""
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name, recursive, err := parseTableStructTag(field)
name, defaultSort, recursive, err := parseTableStructTag(field)
if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
}
if name == "" {
continue
}
if defaultSort {
if defaultSortName != "" {
return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String())
}
defaultSortName = name
}
fieldType := field.Type
if recursive {
if !isStructOrStructPointer(fieldType) {
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
return nil, "", xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
}
childNames, err := typeToTableHeaders(fieldType)
childNames, _, err := typeToTableHeaders(fieldType)
if err != nil {
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
}
for _, childName := range childNames {
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
@@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) {
headers = append(headers, name)
}
return headers, nil
if defaultSortName == "" {
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
}
return headers, defaultSortName, nil
}
// valueToTableMap converts a struct to a map of column name to value. If the
@@ -276,7 +296,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
fieldVal := val.Field(i)
name, recursive, err := parseTableStructTag(field)
name, _, recursive, err := parseTableStructTag(field)
if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
}
@@ -309,18 +329,3 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
return row, nil
}
// TableHeaders returns the table header names of all
// fields in tSlice. tSlice must be a slice of some type.
func TableHeaders(tSlice any) ([]string, error) {
v := reflect.Indirect(reflect.ValueOf(tSlice))
rawHeaders, err := typeToTableHeaders(v.Type().Elem())
if err != nil {
return nil, xerrors.Errorf("type to table headers: %w", err)
}
out := make([]string, 0, len(rawHeaders))
for _, hdr := range rawHeaders {
out = append(out, strings.Replace(hdr, " ", "_", -1))
}
return out, nil
}
+32 -52
View File
@@ -24,7 +24,7 @@ func (s stringWrapper) String() string {
}
type tableTest1 struct {
Name string `table:"name"`
Name string `table:"name,default_sort"`
NotIncluded string // no table tag
Age int `table:"age"`
Roles []string `table:"roles"`
@@ -39,21 +39,45 @@ type tableTest1 struct {
}
type tableTest2 struct {
Name stringWrapper `table:"name"`
Name stringWrapper `table:"name,default_sort"`
Age int `table:"age"`
NotIncluded string `table:"-"`
}
type tableTest3 struct {
NotIncluded string // no table tag
Sub tableTest2 `table:"inner,recursive"`
Sub tableTest2 `table:"inner,recursive,default_sort"`
}
func Test_DisplayTable(t *testing.T) {
t.Parallel()
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC)
// Not sorted by name or age to test sorting.
in := []tableTest1{
{
Name: "bar",
Age: 20,
Roles: []string{"a"},
Sub1: tableTest2{
Name: stringWrapper{str: "bar1"},
Age: 21,
},
Sub2: nil,
Sub3: tableTest3{
Sub: tableTest2{
Name: stringWrapper{str: "bar3"},
Age: 23,
},
},
Sub4: tableTest2{
Name: stringWrapper{str: "bar4"},
Age: 24,
},
Time: someTime,
TimePtr: nil,
},
{
Name: "foo",
Age: 10,
@@ -79,28 +103,6 @@ func Test_DisplayTable(t *testing.T) {
Time: someTime,
TimePtr: &someTime,
},
{
Name: "bar",
Age: 20,
Roles: []string{"a"},
Sub1: tableTest2{
Name: stringWrapper{str: "bar1"},
Age: 21,
},
Sub2: nil,
Sub3: tableTest3{
Sub: tableTest2{
Name: stringWrapper{str: "bar3"},
Age: 23,
},
},
Sub4: tableTest2{
Name: stringWrapper{str: "bar4"},
Age: 24,
},
Time: someTime,
TimePtr: nil,
},
{
Name: "baz",
Age: 30,
@@ -132,9 +134,9 @@ func Test_DisplayTable(t *testing.T) {
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
`
// Test with non-pointer values.
@@ -154,17 +156,17 @@ baz 30 [] baz1 31 <nil> <nil> baz3
compareTables(t, expected, out)
})
t.Run("Sort", func(t *testing.T) {
t.Run("CustomSort", func(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
`
out, err := cliui.DisplayTable(in, "name", nil)
out, err := cliui.DisplayTable(in, "age", nil)
log.Println("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
@@ -175,9 +177,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
expected := `
NAME SUB 1 NAME SUB 3 INNER NAME TIME
foo foo1 foo3 2022-08-02T15:49:10Z
bar bar1 bar3 2022-08-02T15:49:10Z
baz baz1 baz3 2022-08-02T15:49:10Z
foo foo1 foo3 2022-08-02T15:49:10Z
`
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
@@ -327,28 +329,6 @@ baz baz1 baz3 2022-08-02T15:49:10Z
})
}
func Test_TableHeaders(t *testing.T) {
t.Parallel()
s := []tableTest1{}
expectedFields := []string{
"name",
"age",
"roles",
"sub_1_name",
"sub_1_age",
"sub_2_name",
"sub_2_age",
"sub_3_inner_name",
"sub_3_inner_age",
"sub_4",
"time",
"time_ptr",
}
headers, err := cliui.TableHeaders(s)
require.NoError(t, err)
require.EqualValues(t, expectedFields, headers)
}
// compareTables normalizes the incoming table lines
func compareTables(t *testing.T, expected, out string) {
t.Helper()
+35 -6
View File
@@ -4,6 +4,9 @@ import (
"io"
"os"
"path/filepath"
"github.com/kirsle/configdir"
"golang.org/x/xerrors"
)
const (
@@ -13,58 +16,80 @@ const (
// Root represents the configuration directory.
type Root string
// mustNotBeEmpty prevents us from accidentally writing configuration to the
// current directory. This is primarily valuable in development, where we may
// accidentally use an empty root.
func (r Root) mustNotEmpty() {
if r == "" {
panic("config root must not be empty")
}
}
func (r Root) Session() File {
r.mustNotEmpty()
return File(filepath.Join(string(r), "session"))
}
// ReplicaID is a unique identifier for the Coder server.
func (r Root) ReplicaID() File {
r.mustNotEmpty()
return File(filepath.Join(string(r), "replica_id"))
}
func (r Root) URL() File {
r.mustNotEmpty()
return File(filepath.Join(string(r), "url"))
}
func (r Root) Organization() File {
r.mustNotEmpty()
return File(filepath.Join(string(r), "organization"))
}
func (r Root) DotfilesURL() File {
r.mustNotEmpty()
return File(filepath.Join(string(r), "dotfilesurl"))
}
func (r Root) PostgresPath() string {
r.mustNotEmpty()
return filepath.Join(string(r), "postgres")
}
func (r Root) PostgresPassword() File {
r.mustNotEmpty()
return File(filepath.Join(r.PostgresPath(), "password"))
}
func (r Root) PostgresPort() File {
r.mustNotEmpty()
return File(filepath.Join(r.PostgresPath(), "port"))
}
func (r Root) DeploymentConfigPath() string {
return filepath.Join(string(r), "server.yaml")
}
// File provides convenience methods for interacting with *os.File.
type File string
// Delete deletes the file.
func (f File) Delete() error {
if f == "" {
return xerrors.Errorf("empty file path")
}
return os.Remove(string(f))
}
// Write writes the string to the file.
func (f File) Write(s string) error {
return write(string(f), 0600, []byte(s))
if f == "" {
return xerrors.Errorf("empty file path")
}
return write(string(f), 0o600, []byte(s))
}
// Read reads the file to a string.
func (f File) Read() (string, error) {
if f == "" {
return "", xerrors.Errorf("empty file path")
}
byt, err := read(string(f))
return string(byt), err
}
@@ -72,7 +97,7 @@ func (f File) Read() (string, error) {
// open opens a file in the configuration directory,
// creating all intermediate directories.
func open(path string, flag int, mode os.FileMode) (*os.File, error) {
err := os.MkdirAll(filepath.Dir(path), 0750)
err := os.MkdirAll(filepath.Dir(path), 0o750)
if err != nil {
return nil, err
}
@@ -98,3 +123,7 @@ func read(path string) ([]byte, error) {
defer fi.Close()
return io.ReadAll(fi)
}
func DefaultDir() string {
return configdir.LocalConfig("coderv2")
}
+225 -67
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
@@ -17,12 +18,11 @@ import (
"github.com/cli/safeexec"
"github.com/pkg/diff"
"github.com/pkg/diff/write"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
@@ -48,6 +48,52 @@ type sshConfigOptions struct {
sshOptions []string
}
// addOptions expects options in the form of "option=value" or "option value".
// It will override any existing option with the same key to prevent duplicates.
// Invalid options will return an error.
func (o *sshConfigOptions) addOptions(options ...string) error {
for _, option := range options {
err := o.addOption(option)
if err != nil {
return err
}
}
return nil
}
func (o *sshConfigOptions) addOption(option string) error {
key, value, err := codersdk.ParseSSHConfigOption(option)
if err != nil {
return err
}
for i, existing := range o.sshOptions {
// Override existing option if they share the same key.
// This is case-insensitive. Parsing each time might be a little slow,
// but it is ok.
existingKey, _, err := codersdk.ParseSSHConfigOption(existing)
if err != nil {
// Don't mess with original values if there is an error.
// This could have come from the user's manual edits.
continue
}
if strings.EqualFold(existingKey, key) {
if value == "" {
// Delete existing option.
o.sshOptions = append(o.sshOptions[:i], o.sshOptions[i+1:]...)
} else {
// Override existing option.
o.sshOptions[i] = option
}
return nil
}
}
// Only append the option if it is not empty.
if value != "" {
o.sshOptions = append(o.sshOptions, option)
}
return nil
}
func (o sshConfigOptions) equal(other sshConfigOptions) bool {
// Compare without side-effects or regard to order.
opt1 := slices.Clone(o.sshOptions)
@@ -132,19 +178,21 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r
}
}
func configSSH() *cobra.Command {
func (r *RootCmd) configSSH() *clibase.Cmd {
var (
sshConfigFile string
sshConfigOpts sshConfigOptions
usePreviousOpts bool
dryRun bool
skipProxyCommand bool
userHostPrefix string
)
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "config-ssh",
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
Example: formatExamples(
Long: formatExamples(
example{
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
Command: "coder config-ssh -o ForwardAgent=yes",
@@ -154,20 +202,18 @@ func configSSH() *cobra.Command {
Command: "coder config-ssh --dry-run",
},
),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := CreateClient(cmd)
if err != nil {
return err
}
Middleware: clibase.Chain(
clibase.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(inv.Context(), client)
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(cmd.Context(), client)
out := cmd.OutOrStdout()
out := inv.Stdout
if dryRun {
// Print everything except diff to stderr so
// that it's possible to capture the diff.
out = cmd.OutOrStderr()
out = inv.Stderr
}
coderBinary, err := currentBinPath(out)
if err != nil {
@@ -178,7 +224,7 @@ func configSSH() *cobra.Command {
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
}
root := createConfig(cmd)
root := r.createConfig()
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
if err != nil {
return xerrors.Errorf("escape global config for ssh failed: %w", err)
@@ -206,7 +252,11 @@ func configSSH() *cobra.Command {
// Parse the previous configuration only if config-ssh
// has been run previously.
var lastConfig *sshConfigOptions
if section, ok := sshConfigGetCoderSection(configRaw); ok {
section, ok, err := sshConfigGetCoderSection(configRaw)
if err != nil {
return err
}
if ok {
c := sshConfigParseLastOptions(bytes.NewReader(section))
lastConfig = &c
}
@@ -216,6 +266,13 @@ func configSSH() *cobra.Command {
if usePreviousOpts && lastConfig != nil {
sshConfigOpts = *lastConfig
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
for _, v := range sshConfigOpts.sshOptions {
// If the user passes an invalid option, we should catch
// this early.
if _, _, err := codersdk.ParseSSHConfigOption(v); err != nil {
return xerrors.Errorf("invalid option from flag: %w", err)
}
}
newOpts := sshConfigOpts.asList()
newOptsMsg := "\n\n New options: none"
if len(newOpts) > 0 {
@@ -227,7 +284,7 @@ func configSSH() *cobra.Command {
oldOptsMsg = fmt.Sprintf("\n\n Previous options:\n * %s", strings.Join(oldOpts, "\n * "))
}
line, err := cliui.Prompt(cmd, cliui.PromptOptions{
line, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("New options differ from previous options:%s%s\n\n Use new options?", newOptsMsg, oldOptsMsg),
IsConfirm: true,
})
@@ -241,7 +298,7 @@ func configSSH() *cobra.Command {
changes = append(changes, "Use new SSH options")
}
// Only print when prompts are shown.
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
_, _ = fmt.Fprint(out, "\n")
}
}
@@ -249,7 +306,10 @@ func configSSH() *cobra.Command {
configModified := configRaw
buf := &bytes.Buffer{}
before, after := sshConfigSplitOnCoderSection(configModified)
before, _, after, err := sshConfigSplitOnCoderSection(configModified)
if err != nil {
return err
}
// Write the first half of the users config file to buf.
_, _ = buf.Write(before)
@@ -262,6 +322,25 @@ func configSSH() *cobra.Command {
if err != nil {
return xerrors.Errorf("fetch workspace configs failed: %w", err)
}
coderdConfig, err := client.SSHConfiguration(inv.Context())
if err != nil {
// If the error is 404, this deployment does not support
// this endpoint yet. Do not error, just assume defaults.
// TODO: Remove this in 2 months (May 31, 2023). Just return the error
// and remove this 404 check.
var sdkErr *codersdk.Error
if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) {
return xerrors.Errorf("fetch coderd config failed: %w", err)
}
coderdConfig.HostnamePrefix = "coder."
}
if userHostPrefix != "" {
// Override with user flag.
coderdConfig.HostnamePrefix = userHostPrefix
}
// Ensure stable sorting of output.
slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool {
return a.Name < b.Name
@@ -269,35 +348,59 @@ func configSSH() *cobra.Command {
for _, wc := range workspaceConfigs {
sort.Strings(wc.Hosts)
// Write agent configuration.
for _, hostname := range wc.Hosts {
configOptions := []string{
"Host coder." + hostname,
}
for _, option := range sshConfigOpts.sshOptions {
configOptions = append(configOptions, "\t"+option)
}
configOptions = append(configOptions,
"\tHostName coder."+hostname,
"\tConnectTimeout=0",
"\tStrictHostKeyChecking=no",
for _, workspaceHostname := range wc.Hosts {
sshHostname := fmt.Sprintf("%s%s", coderdConfig.HostnamePrefix, workspaceHostname)
defaultOptions := []string{
"HostName " + sshHostname,
"ConnectTimeout=0",
"StrictHostKeyChecking=no",
// Without this, the "REMOTE HOST IDENTITY CHANGED"
// message will appear.
"\tUserKnownHostsFile=/dev/null",
"UserKnownHostsFile=/dev/null",
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
// message from appearing on every SSH. This happens because we ignore the known hosts.
"\tLogLevel ERROR",
)
if !skipProxyCommand {
configOptions = append(
configOptions,
fmt.Sprintf(
"\tProxyCommand %s --global-config %s ssh --stdio %s",
escapedCoderBinary, escapedGlobalConfig, hostname,
),
)
"LogLevel ERROR",
}
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
if !skipProxyCommand {
defaultOptions = append(defaultOptions, fmt.Sprintf(
"ProxyCommand %s --global-config %s ssh --stdio %s",
escapedCoderBinary, escapedGlobalConfig, workspaceHostname,
))
}
var configOptions sshConfigOptions
// Add standard options.
err := configOptions.addOptions(defaultOptions...)
if err != nil {
return err
}
// Override with deployment options
for k, v := range coderdConfig.SSHConfigOptions {
opt := fmt.Sprintf("%s %s", k, v)
err := configOptions.addOptions(opt)
if err != nil {
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
}
}
// Override with flag options
for _, opt := range sshConfigOpts.sshOptions {
err := configOptions.addOptions(opt)
if err != nil {
return xerrors.Errorf("add flag config option %q: %w", opt, err)
}
}
hostBlock := []string{
"Host " + sshHostname,
}
// Prefix with '\t'
for _, v := range configOptions.sshOptions {
hostBlock = append(hostBlock, "\t"+v)
}
_, _ = buf.WriteString(strings.Join(hostBlock, "\n"))
_ = buf.WriteByte('\n')
}
}
@@ -320,21 +423,21 @@ func configSSH() *cobra.Command {
if dryRun {
_, _ = fmt.Fprintf(out, "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n", strings.Join(changes, "\n * "))
color := isTTYOut(cmd)
color := isTTYOut(inv)
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
if err != nil {
return xerrors.Errorf("diff failed: %w", err)
}
if len(diff) > 0 {
// Write diff to stdout.
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s", diff)
_, _ = fmt.Fprintf(inv.Stdout, "%s", diff)
}
return nil
}
if len(changes) > 0 {
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
IsConfirm: true,
})
@@ -342,7 +445,7 @@ func configSSH() *cobra.Command {
return nil
}
// Only print when prompts are shown.
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
_, _ = fmt.Fprint(out, "\n")
}
}
@@ -352,24 +455,62 @@ func configSSH() *cobra.Command {
if err != nil {
return xerrors.Errorf("write ssh config failed: %w", err)
}
_, _ = fmt.Fprintf(out, "Updated %q\n", sshConfigFile)
}
if len(workspaceConfigs) > 0 {
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name)
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, workspaceConfigs[0].Name)
} else {
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
}
return nil
},
}
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
cmd.Flags().StringArrayVarP(&sshConfigOpts.sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Perform a trial run with no changes made, showing a diff at the end.")
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
_ = cmd.Flags().MarkHidden("skip-proxy-command")
cliflag.BoolVarP(cmd.Flags(), &usePreviousOpts, "use-previous-options", "", "CODER_SSH_USE_PREVIOUS_OPTIONS", false, "Specifies whether or not to keep options from previous run of config-ssh.")
cliui.AllowSkipPrompt(cmd)
cmd.Options = clibase.OptionSet{
{
Flag: "ssh-config-file",
Env: "CODER_SSH_CONFIG_FILE",
Default: sshDefaultConfigFileName,
Description: "Specifies the path to an SSH config.",
Value: clibase.StringOf(&sshConfigFile),
},
{
Flag: "ssh-option",
FlagShorthand: "o",
Env: "CODER_SSH_CONFIG_OPTS",
Description: "Specifies additional SSH options to embed in each host stanza.",
Value: clibase.StringArrayOf(&sshConfigOpts.sshOptions),
},
{
Flag: "dry-run",
FlagShorthand: "n",
Env: "CODER_SSH_DRY_RUN",
Description: "Perform a trial run with no changes made, showing a diff at the end.",
Value: clibase.BoolOf(&dryRun),
},
{
Flag: "skip-proxy-command",
Env: "CODER_SSH_SKIP_PROXY_COMMAND",
Description: "Specifies whether the ProxyCommand option should be skipped. Useful for testing.",
Value: clibase.BoolOf(&skipProxyCommand),
Hidden: true,
},
{
Flag: "use-previous-options",
Env: "CODER_SSH_USE_PREVIOUS_OPTIONS",
Description: "Specifies whether or not to keep options from previous run of config-ssh.",
Value: clibase.BoolOf(&usePreviousOpts),
},
{
Flag: "ssh-host-prefix",
Env: "",
Description: "Override the default host prefix.",
Value: clibase.StringOf(&userHostPrefix),
},
cliui.SkipPromptOption(),
}
return cmd
}
@@ -418,22 +559,39 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
return o
}
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool) {
startIndex := bytes.Index(data, []byte(sshStartToken))
endIndex := bytes.Index(data, []byte(sshEndToken))
if startIndex != -1 && endIndex != -1 {
return data[startIndex : endIndex+len(sshEndToken)], true
// sshConfigGetCoderSection is a helper function that only returns the coder
// section of the SSH config and a boolean if it exists.
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool, err error) {
_, section, _, err = sshConfigSplitOnCoderSection(data)
if err != nil {
return nil, false, err
}
return nil, false
return section, len(section) > 0, nil
}
// sshConfigSplitOnCoderSection splits the SSH config into two sections,
// before contains the lines before sshStartToken and after contains the
// lines after sshEndToken.
func sshConfigSplitOnCoderSection(data []byte) (before, after []byte) {
// sshConfigSplitOnCoderSection splits the SSH config into 3 sections.
// All lines before sshStartToken, the coder section, and all lines after
// sshEndToken.
func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []byte, err error) {
startCount := bytes.Count(data, []byte(sshStartToken))
endCount := bytes.Count(data, []byte(sshEndToken))
if startCount > 1 || endCount > 1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has multiple coder sections, please remove all but one")
}
startIndex := bytes.Index(data, []byte(sshStartToken))
endIndex := bytes.Index(data, []byte(sshEndToken))
if startIndex == -1 && endIndex != -1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has end header, but missing start header")
}
if startIndex != -1 && endIndex == -1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has start header, but missing end header")
}
if startIndex != -1 && endIndex != -1 {
if startIndex > endIndex {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has coder section, but it is malformed and the END header is before the START header")
}
// We use -1 and +1 here to also include the preceding
// and trailing newline, where applicable.
start := startIndex
@@ -444,10 +602,10 @@ func sshConfigSplitOnCoderSection(data []byte) (before, after []byte) {
if end < len(data) {
end++
}
return data[:start], data[end:]
return data[:start], data[start:end], data[end:], nil
}
return data, nil
return data, nil, nil, nil
}
// writeWithTempFileAndMove writes to a temporary file in the same
+197
View File
@@ -5,12 +5,132 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func Test_sshConfigSplitOnCoderSection(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Input string
Before string
Section string
After string
Err bool
}{
{
Name: "Empty",
Input: "",
Before: "",
Section: "",
After: "",
Err: false,
},
{
Name: "JustSection",
Input: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
Before: "",
Section: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
After: "",
Err: false,
},
{
Name: "NoSection",
Input: strings.Join([]string{"# Some content"}, "\n"),
Before: "# Some content",
Section: "",
After: "",
Err: false,
},
{
Name: "Normal",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
"# Content after the section",
}, "\n"),
Before: "# Content before the section",
Section: strings.Join([]string{"", sshStartToken, sshEndToken, ""}, "\n"),
After: "# Content after the section",
Err: false,
},
{
Name: "OutOfOrder",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
sshStartToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "MissingStart",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "MissingEnd",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "ExtraStart",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
sshStartToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "ExtraEnd",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
before, section, after, err := sshConfigSplitOnCoderSection([]byte(tc.Input))
if tc.Err {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Before, string(before), "before")
require.Equal(t, tc.Section, string(section), "section")
require.Equal(t, tc.After, string(after), "after")
})
}
}
// This test tries to mimic the behavior of OpenSSH
// when executing e.g. a ProxyCommand.
func Test_sshConfigExecEscape(t *testing.T) {
@@ -60,3 +180,80 @@ func Test_sshConfigExecEscape(t *testing.T) {
})
}
}
func Test_sshConfigOptions_addOption(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Start []string
Add []string
Expect []string
ExpectError bool
}{
{
Name: "Empty",
},
{
Name: "AddOne",
Add: []string{"foo bar"},
Expect: []string{
"foo bar",
},
},
{
Name: "Replace",
Start: []string{
"foo bar",
},
Add: []string{"Foo baz"},
Expect: []string{
"Foo baz",
},
},
{
Name: "AddAndReplace",
Start: []string{
"a b",
"foo bar",
"buzz bazz",
},
Add: []string{
"b c",
"A hello",
"hello world",
},
Expect: []string{
"foo bar",
"buzz bazz",
"b c",
"A hello",
"hello world",
},
},
{
Name: "Error",
Add: []string{"novalue"},
ExpectError: true,
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
o := sshConfigOptions{
sshOptions: tt.Start,
}
err := o.addOptions(tt.Add...)
if tt.ExpectError {
require.Error(t, err)
return
}
require.NoError(t, err)
sort.Strings(tt.Expect)
sort.Strings(o.sshOptions)
require.Equal(t, tt.Expect, o.sshOptions)
})
}
}
+70 -45
View File
@@ -25,6 +25,7 @@ import (
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
@@ -63,7 +64,20 @@ func sshConfigFileRead(t *testing.T, name string) string {
func TestConfigSSH(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
const hostname = "test-coder."
const expectedKey = "ConnectionAttempts"
const removeKey = "ConnectionTimeout"
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ConfigSSH: codersdk.SSHConfigResponse{
HostnamePrefix: hostname,
SSHConfigOptions: map[string]string{
// Something we can test for
expectedKey: "3",
removeKey: "",
},
},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -82,29 +96,13 @@ func TestConfigSSH(t *testing.T) {
},
},
}},
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
agentCloser := agent.New(agent.Options{
Client: agentClient,
@@ -153,21 +151,17 @@ func TestConfigSSH(t *testing.T) {
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
require.True(t, valid)
cmd, root := clitest.New(t, "config-ssh",
inv, root := clitest.New(t, "config-ssh",
"--ssh-option", "HostName "+tcpAddr.IP.String(),
"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
"--ssh-config-file", sshConfigFile,
"--skip-proxy-command")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
assert.NoError(t, err)
}()
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
waiter := clitest.StartWithWaiter(t, inv)
matches := []struct {
match, write string
@@ -179,15 +173,20 @@ func TestConfigSSH(t *testing.T) {
pty.WriteLine(m.write)
}
<-doneChan
waiter.RequireSuccess()
fileContents, err := os.ReadFile(sshConfigFile)
require.NoError(t, err, "read ssh config file")
require.Contains(t, string(fileContents), expectedKey, "ssh config file contains expected key")
require.NotContains(t, string(fileContents), removeKey, "ssh config file should not have removed key")
home := filepath.Dir(filepath.Dir(sshConfigFile))
// #nosec
sshCmd := exec.Command("ssh", "-F", sshConfigFile, "coder."+workspace.Name, "echo", "test")
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+workspace.Name, "echo", "test")
pty = ptytest.New(t)
// Set HOME because coder config is included from ~/.ssh/coder.
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
sshCmd.Stderr = pty.Output()
inv.Stderr = pty.Output()
data, err := sshCmd.Output()
require.NoError(t, err)
require.Equal(t, "test", strings.TrimSpace(string(data)))
@@ -529,6 +528,36 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
"--yes",
},
},
{
name: "Start/End out of order",
matches: []match{
// {match: "Continue?", write: "yes"},
},
writeConfig: writeConfig{
ssh: strings.Join([]string{
"# Content before coder block",
headerEnd,
headerStart,
"# Content after coder block",
}, "\n"),
},
wantErr: true,
},
{
name: "Multiple sections",
matches: []match{
// {match: "Continue?", write: "yes"},
},
writeConfig: writeConfig{
ssh: strings.Join([]string{
headerStart,
headerEnd,
headerStart,
headerEnd,
}, "\n"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
@@ -556,14 +585,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
"--ssh-config-file", sshConfigName,
}
args = append(args, tt.args...)
cmd, root := clitest.New(t, args...)
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
done := tGo(t, func() {
err := cmd.Execute()
err := inv.Run()
if !tt.wantErr {
assert.NoError(t, err)
} else {
@@ -673,17 +702,13 @@ func TestConfigSSH_Hostnames(t *testing.T) {
sshConfigFile := sshConfigFileName(t)
cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
assert.NoError(t, err)
}()
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
clitest.Start(t, inv)
matches := []struct {
match, write string
@@ -695,7 +720,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
pty.WriteLine(m.write)
}
<-doneChan
pty.ExpectMatch("Updated")
var expectedHosts []string
for _, hostnamePattern := range tt.expected {
+112 -56
View File
@@ -1,21 +1,21 @@
package cli
import (
"context"
"fmt"
"io"
"time"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)
func create() *cobra.Command {
func (r *RootCmd) create() *clibase.Cmd {
var (
parameterFile string
richParameterFile string
@@ -24,30 +24,27 @@ func create() *cobra.Command {
stopAfter time.Duration
workspaceName string
)
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "create [name]",
Short: "Create a workspace",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
Middleware: clibase.Chain(r.InitClient(client)),
Handler: func(inv *clibase.Invocation) error {
organization, err := CurrentOrganization(inv, client)
if err != nil {
return err
}
organization, err := CurrentOrganization(cmd, client)
if err != nil {
return err
}
if len(args) >= 1 {
workspaceName = args[0]
if len(inv.Args) >= 1 {
workspaceName = inv.Args[0]
}
if workspaceName == "" {
workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Specify a name for your workspace:",
Validate: func(workspaceName string) error {
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
if err == nil {
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
}
@@ -59,16 +56,16 @@ func create() *cobra.Command {
}
}
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
if err == nil {
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
}
var template codersdk.Template
if templateName == "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
templates, err := client.TemplatesByOrganization(cmd.Context(), organization.ID)
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
if err != nil {
return err
}
@@ -97,7 +94,7 @@ func create() *cobra.Command {
}
// Move the cursor up a single line for nicer display!
option, err := cliui.Select(cmd, cliui.SelectOptions{
option, err := cliui.Select(inv, cliui.SelectOptions{
Options: templateNames,
HideSearch: true,
})
@@ -107,7 +104,7 @@ func create() *cobra.Command {
template = templateByName[option]
} else {
template, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}
@@ -122,7 +119,7 @@ func create() *cobra.Command {
schedSpec = ptr.Ref(sched.String())
}
buildParams, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Template: template,
ExistingParams: []codersdk.Parameter{},
ParameterFile: parameterFile,
@@ -130,10 +127,10 @@ func create() *cobra.Command {
NewWorkspaceName: workspaceName,
})
if err != nil {
return err
return xerrors.Errorf("prepare build: %w", err)
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm create?",
IsConfirm: true,
})
@@ -141,34 +138,69 @@ func create() *cobra.Command {
return err
}
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
var ttlMillis *int64
if stopAfter > 0 {
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
} else if template.MaxTTLMillis > 0 {
ttlMillis = &template.MaxTTLMillis
}
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: workspaceName,
AutostartSchedule: schedSpec,
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
TTLMillis: ttlMillis,
ParameterValues: buildParams.parameters,
RichParameterValues: buildParams.richParameters,
})
if err != nil {
return err
return xerrors.Errorf("create workspace: %w", err)
}
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID)
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
if err != nil {
return err
return xerrors.Errorf("watch build: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been created at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
return nil
},
}
cmd.Options = append(cmd.Options,
clibase.Option{
Flag: "template",
FlagShorthand: "t",
Env: "CODER_TEMPLATE_NAME",
Description: "Specify a template name.",
Value: clibase.StringOf(&templateName),
},
clibase.Option{
Flag: "parameter-file",
Env: "CODER_PARAMETER_FILE",
Description: "Specify a file path with parameter values.",
Value: clibase.StringOf(&parameterFile),
},
clibase.Option{
Flag: "rich-parameter-file",
Env: "CODER_RICH_PARAMETER_FILE",
Description: "Specify a file path with values for rich parameters defined in the template.",
Value: clibase.StringOf(&richParameterFile),
},
clibase.Option{
Flag: "start-at",
Env: "CODER_WORKSPACE_START_AT",
Description: "Specify the workspace autostart schedule. Check coder schedule start --help for the syntax.",
Value: clibase.StringOf(&startAt),
},
clibase.Option{
Flag: "stop-after",
Env: "CODER_WORKSPACE_STOP_AFTER",
Description: "Specify a duration after which the workspace should shut down (e.g. 8h).",
Value: clibase.DurationOf(&stopAfter),
},
cliui.SkipPromptOption(),
)
cliui.AllowSkipPrompt(cmd)
cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
cliflag.StringVarP(cmd.Flags(), &parameterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
cliflag.StringVarP(cmd.Flags(), &richParameterFile, "rich-parameter-file", "", "CODER_RICH_PARAMETER_FILE", "", "Specify a file path with values for rich parameters defined in the template.")
cliflag.StringVarP(cmd.Flags(), &startAt, "start-at", "", "CODER_WORKSPACE_START_AT", "", "Specify the workspace autostart schedule. Check `coder schedule start --help` for the syntax.")
cliflag.DurationVarP(cmd.Flags(), &stopAfter, "stop-after", "", "CODER_WORKSPACE_STOP_AFTER", 8*time.Hour, "Specify a duration after which the workspace should shut down (e.g. 8h).")
return cmd
}
@@ -192,8 +224,23 @@ type buildParameters struct {
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
// Any missing params will be prompted to the user. It supports legacy and rich parameters.
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) {
ctx := cmd.Context()
func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) {
ctx := inv.Context()
var useRichParameters bool
if len(args.ExistingRichParams) > 0 && len(args.RichParameterFile) > 0 {
useRichParameters = true
}
var useLegacyParameters bool
if len(args.ExistingParams) > 0 || len(args.ParameterFile) > 0 {
useLegacyParameters = true
}
if useRichParameters && useLegacyParameters {
return nil, xerrors.Errorf("Rich parameters can't be used together with legacy parameters.")
}
templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID)
if err != nil {
return nil, err
@@ -210,7 +257,7 @@ func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWo
useParamFile := false
if args.ParameterFile != "" {
useParamFile = true
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
parameterMapFromFile, err = createParameterMapFromFile(args.ParameterFile)
if err != nil {
return nil, err
@@ -224,7 +271,7 @@ PromptParamLoop:
continue
}
if !disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
disclaimerPrinted = true
}
@@ -239,7 +286,7 @@ PromptParamLoop:
}
}
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
parameterValue, err := getParameterValueFromMapOrInput(inv, parameterMapFromFile, parameterSchema)
if err != nil {
return nil, err
}
@@ -253,11 +300,11 @@ PromptParamLoop:
}
if disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(inv.Stdout)
}
// Rich parameters
templateVersionParameters, err := client.TemplateVersionRichParameters(cmd.Context(), templateVersion.ID)
templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
if err != nil {
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
}
@@ -266,7 +313,7 @@ PromptParamLoop:
useParamFile = false
if args.RichParameterFile != "" {
useParamFile = true
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n")
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n")
parameterMapFromFile, err = createParameterMapFromFile(args.RichParameterFile)
if err != nil {
return nil, err
@@ -277,7 +324,7 @@ PromptParamLoop:
PromptRichParamLoop:
for _, templateVersionParameter := range templateVersionParameters {
if !disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
disclaimerPrinted = true
}
@@ -293,11 +340,11 @@ PromptRichParamLoop:
}
if args.UpdateWorkspace && !templateVersionParameter.Mutable {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name)))
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name)))
continue
}
parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(cmd, parameterMapFromFile, templateVersionParameter)
parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(inv, parameterMapFromFile, templateVersionParameter)
if err != nil {
return nil, err
}
@@ -306,11 +353,20 @@ PromptRichParamLoop:
}
if disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(inv.Stdout)
}
err = cliui.GitAuth(ctx, inv.Stdout, cliui.GitAuthOptions{
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
return client.TemplateVersionGitAuth(ctx, templateVersion.ID)
},
})
if err != nil {
return nil, xerrors.Errorf("template version git auth: %w", err)
}
// Run a dry-run with the given parameters to check correctness
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
WorkspaceName: args.NewWorkspaceName,
ParameterValues: legacyParameters,
RichParameterValues: richParameters,
@@ -318,16 +374,16 @@ PromptRichParamLoop:
if err != nil {
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
Fetch: func() (codersdk.ProvisionerJob, error) {
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
},
Cancel: func() error {
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, 0)
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
},
// Don't show log output for the dry-run unless there's an error.
Silent: true,
@@ -338,19 +394,19 @@ PromptRichParamLoop:
return nil, xerrors.Errorf("dry-run workspace: %w", err)
}
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
if err != nil {
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
}
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
WorkspaceName: args.NewWorkspaceName,
// Since agents haven't connected yet, hiding this makes more sense.
HideAgentState: true,
Title: "Workspace Preview",
})
if err != nil {
return nil, err
return nil, xerrors.Errorf("get resources: %w", err)
}
return &buildParameters{
+213 -63
View File
@@ -3,7 +3,9 @@ package cli_test
import (
"context"
"fmt"
"net/http"
"os"
"regexp"
"testing"
"time"
@@ -12,6 +14,7 @@ import (
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
@@ -39,15 +42,13 @@ func TestCreate(t *testing.T) {
"--start-at", "9:30AM Mon-Fri US/Central",
"--stop-after", "8h",
}
cmd, root := clitest.New(t, args...)
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
matches := []struct {
@@ -78,6 +79,51 @@ func TestCreate(t *testing.T) {
}
})
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: provisionCompleteWithAgent,
ProvisionPlan: provisionCompleteWithAgent,
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours
ctr.DefaultTTLMillis = &defaultTTLMillis
})
args := []string{
"create",
"my-workspace",
"--template", template.Name,
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
waiter := clitest.StartWithWaiter(t, inv)
matches := []struct {
match string
write string
}{
{match: "compute.main"},
{match: "smith (linux, i386)"},
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
}
}
waiter.RequireSuccess()
ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err, "expected workspace to be created")
assert.Equal(t, ws.TemplateName, template.Name)
assert.Equal(t, *ws.TTLMillis, template.DefaultTTLMillis)
})
t.Run("CreateFromListWithSkip", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
@@ -85,14 +131,14 @@ func TestCreate(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "my-workspace", "-y")
inv, root := clitest.New(t, "create", "my-workspace", "-y")
member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
member, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
clitest.SetupConfig(t, member, root)
cmdCtx, done := context.WithTimeout(context.Background(), testutil.WaitLong)
go func() {
defer done()
err := cmd.ExecuteContext(cmdCtx)
err := inv.WithContext(cmdCtx).Run()
assert.NoError(t, err)
}()
// No pty interaction needed since we use the -y skip prompt flag
@@ -107,15 +153,13 @@ func TestCreate(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "")
inv, root := clitest.New(t, "create", "")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
matches := []string{
@@ -130,7 +174,7 @@ func TestCreate(t *testing.T) {
}
<-doneChan
ws, err := client.WorkspaceByOwnerAndName(cmd.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
ws, err := client.WorkspaceByOwnerAndName(inv.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
if assert.NoError(t, err, "expected workspace to be created") {
assert.Equal(t, ws.TemplateName, template.Name)
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
@@ -151,15 +195,13 @@ func TestCreate(t *testing.T) {
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "")
inv, root := clitest.New(t, "create", "")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -196,15 +238,13 @@ func TestCreate(t *testing.T) {
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, _ = parameterFile.WriteString("region: \"bingo\"\nusername: \"boingo\"")
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
inv, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -241,15 +281,13 @@ func TestCreate(t *testing.T) {
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, _ = parameterFile.WriteString("username: \"boingo\"")
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
inv, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
matches := []struct {
@@ -309,13 +347,11 @@ func TestCreate(t *testing.T) {
require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status, "job is not failed")
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name())
inv, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name(), "-y")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
ptytest.New(t).Attach(inv)
err = cmd.Execute()
err = inv.Run()
require.Error(t, err)
require.ErrorContains(t, err, "dry-run workspace")
})
@@ -330,6 +366,7 @@ func TestCreateWithRichParameters(t *testing.T) {
firstParameterValue = "1"
secondParameterName = "second_parameter"
secondParameterDisplayName = "Second Parameter"
secondParameterDescription = "This is second parameter"
secondParameterValue = "2"
@@ -346,12 +383,13 @@ func TestCreateWithRichParameters(t *testing.T) {
Complete: &proto.Provision_Complete{
Parameters: []*proto.RichParameter{
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
{Name: secondParameterName, Description: secondParameterDescription, Mutable: true},
{Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true},
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
},
},
},
}},
},
},
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{},
@@ -369,20 +407,19 @@ func TestCreateWithRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
matches := []string{
firstParameterDescription, firstParameterValue,
secondParameterDisplayName, "",
secondParameterDescription, secondParameterValue,
immutableParameterDescription, immutableParameterValue,
"Confirm create?", "yes",
@@ -391,7 +428,10 @@ func TestCreateWithRichParameters(t *testing.T) {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
if value != "" {
pty.WriteLine(value)
}
}
<-doneChan
})
@@ -413,16 +453,14 @@ func TestCreateWithRichParameters(t *testing.T) {
firstParameterName + ": " + firstParameterValue + "\n" +
secondParameterName + ": " + secondParameterValue + "\n" +
immutableParameterName + ": " + immutableParameterValue)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -446,6 +484,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
stringParameterName = "string_parameter"
stringParameterValue = "abc"
listOfStringsParameterName = "list_of_strings_parameter"
numberParameterName = "number_parameter"
numberParameterValue = "7"
@@ -461,6 +501,10 @@ func TestCreateValidateRichParameters(t *testing.T) {
{Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"},
}
listOfStringsRichParameters := []*proto.RichParameter{
{Name: listOfStringsParameterName, Type: "list(string)", Mutable: true, DefaultValue: `["aaa","bbb","ccc"]`},
}
boolRichParameters := []*proto.RichParameter{
{Name: boolParameterName, Type: "bool", Mutable: true},
}
@@ -475,7 +519,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
Parameters: richParameters,
},
},
}},
},
},
ProvisionApply: []*proto.Provision_Response{
{
Type: &proto.Provision_Response_Complete{
@@ -496,15 +541,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -533,15 +576,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -573,15 +614,13 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
@@ -599,6 +638,117 @@ func TestCreateValidateRichParameters(t *testing.T) {
}
<-doneChan
})
t.Run("ValidateListOfStrings", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
clitest.Start(t, inv)
matches := []string{
listOfStringsParameterName, "",
"aaa, bbb, ccc", "",
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
if value != "" {
pty.WriteLine(value)
}
}
})
t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, _ = parameterFile.WriteString(listOfStringsParameterName + `:
- ddd
- eee
- fff`)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
clitest.Start(t, inv)
matches := []string{
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
if value != "" {
pty.WriteLine(value)
}
}
})
}
func TestCreateWithGitAuth(t *testing.T) {
t.Parallel()
echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Provision_Response{
{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
GitAuthProviders: []string{"github"},
},
},
},
},
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{},
},
}},
}
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
clitest.Start(t, inv)
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
}
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
+23 -29
View File
@@ -4,23 +4,25 @@ import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
// nolint
func deleteWorkspace() *cobra.Command {
func (r *RootCmd) deleteWorkspace() *clibase.Cmd {
var orphan bool
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "delete <workspace>",
Short: "Delete a workspace",
Aliases: []string{"rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
_, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm delete workspace?",
IsConfirm: true,
Default: cliui.ConfirmNo,
@@ -29,25 +31,13 @@ func deleteWorkspace() *cobra.Command {
return err
}
client, err := CreateClient(cmd)
if err != nil {
return err
}
workspace, err := namedWorkspace(cmd, client, args[0])
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return err
}
var state []byte
if orphan {
cliui.Warn(
cmd.ErrOrStderr(),
"Orphaning workspace requires template edit permission",
)
}
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
ProvisionerState: state,
Orphan: orphan,
@@ -56,19 +46,23 @@ func deleteWorkspace() *cobra.Command {
return err
}
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID)
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID)
if err != nil {
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been deleted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been deleted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
return nil
},
}
cmd.Flags().BoolVar(&orphan, "orphan", false,
`Delete a workspace without deleting its resources. This can delete a
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
)
cliui.AllowSkipPrompt(cmd)
cmd.Options = clibase.OptionSet{
{
Flag: "orphan",
Description: "Delete a workspace without deleting its resources. This can delete a workspace in a broken state, but may also lead to unaccounted cloud resources.",
Value: clibase.BoolOf(&orphan),
},
cliui.SkipPromptOption(),
}
return cmd
}
+16 -22
View File
@@ -25,21 +25,19 @@ func TestDelete(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "delete", workspace.Name, "-y")
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
// When running with the race detector on, we sometimes get an EOF.
if err != nil {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("Cleaning Up")
pty.ExpectMatch("workspace has been deleted")
<-doneChan
})
@@ -52,23 +50,21 @@ func TestDelete(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
// When running with the race detector on, we sometimes get an EOF.
if err != nil {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("Cleaning Up")
pty.ExpectMatch("workspace has been deleted")
<-doneChan
})
@@ -77,7 +73,7 @@ func TestDelete(t *testing.T) {
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
adminUser := coderdtest.CreateFirstUser(t, adminClient)
orgID := adminUser.OrganizationID
client := coderdtest.CreateAnotherUser(t, adminClient, orgID)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID)
user, err := client.User(context.Background(), codersdk.Me)
require.NoError(t, err)
@@ -87,22 +83,20 @@ func TestDelete(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
clitest.SetupConfig(t, adminClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
// When running with the race detector on, we sometimes get an EOF.
if err != nil {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("Cleaning Up")
pty.ExpectMatch("workspace has been deleted")
<-doneChan
workspace, err = client.Workspace(context.Background(), workspace.ID)
@@ -112,12 +106,12 @@ func TestDelete(t *testing.T) {
t.Run("InvalidWorkspaceIdentifier", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
cmd, root := clitest.New(t, "delete", "a/b/c", "-y")
inv, root := clitest.New(t, "delete", "a/b/c", "-y")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := cmd.Execute()
err := inv.Run()
assert.ErrorContains(t, err, "invalid workspace name: \"a/b/c\"")
}()
<-doneChan
-830
View File
@@ -1,830 +0,0 @@
package deployment
import (
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"golang.org/x/xerrors"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/codersdk"
)
func newConfig() *codersdk.DeploymentConfig {
return &codersdk.DeploymentConfig{
AccessURL: &codersdk.DeploymentConfigField[string]{
Name: "Access URL",
Usage: "External URL to access your deployment. This must be accessible by all provisioned workspaces.",
Flag: "access-url",
},
WildcardAccessURL: &codersdk.DeploymentConfigField[string]{
Name: "Wildcard Access URL",
Usage: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
Flag: "wildcard-access-url",
},
// DEPRECATED: Use HTTPAddress or TLS.Address instead.
Address: &codersdk.DeploymentConfigField[string]{
Name: "Address",
Usage: "Bind address of the server.",
Flag: "address",
Shorthand: "a",
// Deprecated, so we don't have a default. If set, it will overwrite
// HTTPAddress and TLS.Address and print a warning.
Hidden: true,
Default: "",
},
HTTPAddress: &codersdk.DeploymentConfigField[string]{
Name: "Address",
Usage: "HTTP bind address of the server. Unset to disable the HTTP endpoint.",
Flag: "http-address",
Default: "127.0.0.1:3000",
},
AutobuildPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Autobuild Poll Interval",
Usage: "Interval to poll for scheduled workspace builds.",
Flag: "autobuild-poll-interval",
Hidden: true,
Default: time.Minute,
},
DERP: &codersdk.DERP{
Server: &codersdk.DERPServerConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "DERP Server Enable",
Usage: "Whether to enable or disable the embedded DERP relay server.",
Flag: "derp-server-enable",
Default: true,
},
RegionID: &codersdk.DeploymentConfigField[int]{
Name: "DERP Server Region ID",
Usage: "Region ID to use for the embedded DERP server.",
Flag: "derp-server-region-id",
Default: 999,
},
RegionCode: &codersdk.DeploymentConfigField[string]{
Name: "DERP Server Region Code",
Usage: "Region code to use for the embedded DERP server.",
Flag: "derp-server-region-code",
Default: "coder",
},
RegionName: &codersdk.DeploymentConfigField[string]{
Name: "DERP Server Region Name",
Usage: "Region name that for the embedded DERP server.",
Flag: "derp-server-region-name",
Default: "Coder Embedded Relay",
},
STUNAddresses: &codersdk.DeploymentConfigField[[]string]{
Name: "DERP Server STUN Addresses",
Usage: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.",
Flag: "derp-server-stun-addresses",
Default: []string{"stun.l.google.com:19302"},
},
RelayURL: &codersdk.DeploymentConfigField[string]{
Name: "DERP Server Relay URL",
Usage: "An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability.",
Flag: "derp-server-relay-url",
Enterprise: true,
},
},
Config: &codersdk.DERPConfig{
URL: &codersdk.DeploymentConfigField[string]{
Name: "DERP Config URL",
Usage: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/",
Flag: "derp-config-url",
},
Path: &codersdk.DeploymentConfigField[string]{
Name: "DERP Config Path",
Usage: "Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/",
Flag: "derp-config-path",
},
},
},
GitAuth: &codersdk.DeploymentConfigField[[]codersdk.GitAuthConfig]{
Name: "Git Auth",
Usage: "Automatically authenticate Git inside workspaces.",
Flag: "gitauth",
Default: []codersdk.GitAuthConfig{},
},
Prometheus: &codersdk.PrometheusConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "Prometheus Enable",
Usage: "Serve prometheus metrics on the address defined by prometheus address.",
Flag: "prometheus-enable",
},
Address: &codersdk.DeploymentConfigField[string]{
Name: "Prometheus Address",
Usage: "The bind address to serve prometheus metrics.",
Flag: "prometheus-address",
Default: "127.0.0.1:2112",
},
},
Pprof: &codersdk.PprofConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "Pprof Enable",
Usage: "Serve pprof metrics on the address defined by pprof address.",
Flag: "pprof-enable",
},
Address: &codersdk.DeploymentConfigField[string]{
Name: "Pprof Address",
Usage: "The bind address to serve pprof.",
Flag: "pprof-address",
Default: "127.0.0.1:6060",
},
},
ProxyTrustedHeaders: &codersdk.DeploymentConfigField[[]string]{
Name: "Proxy Trusted Headers",
Flag: "proxy-trusted-headers",
Usage: "Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client-Ip, X-Forwarded-For",
},
ProxyTrustedOrigins: &codersdk.DeploymentConfigField[[]string]{
Name: "Proxy Trusted Origins",
Flag: "proxy-trusted-origins",
Usage: "Origin addresses to respect \"proxy-trusted-headers\". e.g. 192.168.1.0/24",
},
CacheDirectory: &codersdk.DeploymentConfigField[string]{
Name: "Cache Directory",
Usage: "The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.",
Flag: "cache-dir",
Default: DefaultCacheDir(),
},
InMemoryDatabase: &codersdk.DeploymentConfigField[bool]{
Name: "In Memory Database",
Usage: "Controls whether data will be stored in an in-memory database.",
Flag: "in-memory",
Hidden: true,
},
PostgresURL: &codersdk.DeploymentConfigField[string]{
Name: "Postgres Connection URL",
Usage: "URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\".",
Flag: "postgres-url",
Secret: true,
},
OAuth2: &codersdk.OAuth2Config{
Github: &codersdk.OAuth2GithubConfig{
ClientID: &codersdk.DeploymentConfigField[string]{
Name: "OAuth2 GitHub Client ID",
Usage: "Client ID for Login with GitHub.",
Flag: "oauth2-github-client-id",
},
ClientSecret: &codersdk.DeploymentConfigField[string]{
Name: "OAuth2 GitHub Client Secret",
Usage: "Client secret for Login with GitHub.",
Flag: "oauth2-github-client-secret",
Secret: true,
},
AllowedOrgs: &codersdk.DeploymentConfigField[[]string]{
Name: "OAuth2 GitHub Allowed Orgs",
Usage: "Organizations the user must be a member of to Login with GitHub.",
Flag: "oauth2-github-allowed-orgs",
},
AllowedTeams: &codersdk.DeploymentConfigField[[]string]{
Name: "OAuth2 GitHub Allowed Teams",
Usage: "Teams inside organizations the user must be a member of to Login with GitHub. Structured as: <organization-name>/<team-slug>.",
Flag: "oauth2-github-allowed-teams",
},
AllowSignups: &codersdk.DeploymentConfigField[bool]{
Name: "OAuth2 GitHub Allow Signups",
Usage: "Whether new users can sign up with GitHub.",
Flag: "oauth2-github-allow-signups",
},
AllowEveryone: &codersdk.DeploymentConfigField[bool]{
Name: "OAuth2 GitHub Allow Everyone",
Usage: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
Flag: "oauth2-github-allow-everyone",
},
EnterpriseBaseURL: &codersdk.DeploymentConfigField[string]{
Name: "OAuth2 GitHub Enterprise Base URL",
Usage: "Base URL of a GitHub Enterprise deployment to use for Login with GitHub.",
Flag: "oauth2-github-enterprise-base-url",
},
},
},
OIDC: &codersdk.OIDCConfig{
AllowSignups: &codersdk.DeploymentConfigField[bool]{
Name: "OIDC Allow Signups",
Usage: "Whether new users can sign up with OIDC.",
Flag: "oidc-allow-signups",
Default: true,
},
ClientID: &codersdk.DeploymentConfigField[string]{
Name: "OIDC Client ID",
Usage: "Client ID to use for Login with OIDC.",
Flag: "oidc-client-id",
},
ClientSecret: &codersdk.DeploymentConfigField[string]{
Name: "OIDC Client Secret",
Usage: "Client secret to use for Login with OIDC.",
Flag: "oidc-client-secret",
Secret: true,
},
EmailDomain: &codersdk.DeploymentConfigField[[]string]{
Name: "OIDC Email Domain",
Usage: "Email domains that clients logging in with OIDC must match.",
Flag: "oidc-email-domain",
},
IssuerURL: &codersdk.DeploymentConfigField[string]{
Name: "OIDC Issuer URL",
Usage: "Issuer URL to use for Login with OIDC.",
Flag: "oidc-issuer-url",
},
Scopes: &codersdk.DeploymentConfigField[[]string]{
Name: "OIDC Scopes",
Usage: "Scopes to grant when authenticating with OIDC.",
Flag: "oidc-scopes",
Default: []string{oidc.ScopeOpenID, "profile", "email"},
},
IgnoreEmailVerified: &codersdk.DeploymentConfigField[bool]{
Name: "OIDC Ignore Email Verified",
Usage: "Ignore the email_verified claim from the upstream provider.",
Flag: "oidc-ignore-email-verified",
Default: false,
},
UsernameField: &codersdk.DeploymentConfigField[string]{
Name: "OIDC Username Field",
Usage: "OIDC claim field to use as the username.",
Flag: "oidc-username-field",
Default: "preferred_username",
},
},
Telemetry: &codersdk.TelemetryConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "Telemetry Enable",
Usage: "Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.",
Flag: "telemetry",
Default: flag.Lookup("test.v") == nil,
},
Trace: &codersdk.DeploymentConfigField[bool]{
Name: "Telemetry Trace",
Usage: "Whether Opentelemetry traces are sent to Coder. Coder collects anonymized application tracing to help improve our product. Disabling telemetry also disables this option.",
Flag: "telemetry-trace",
Default: flag.Lookup("test.v") == nil,
},
URL: &codersdk.DeploymentConfigField[string]{
Name: "Telemetry URL",
Usage: "URL to send telemetry.",
Flag: "telemetry-url",
Hidden: true,
Default: "https://telemetry.coder.com",
},
},
TLS: &codersdk.TLSConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "TLS Enable",
Usage: "Whether TLS will be enabled.",
Flag: "tls-enable",
},
Address: &codersdk.DeploymentConfigField[string]{
Name: "TLS Address",
Usage: "HTTPS bind address of the server.",
Flag: "tls-address",
Default: "127.0.0.1:3443",
},
RedirectHTTP: &codersdk.DeploymentConfigField[bool]{
Name: "Redirect HTTP to HTTPS",
Usage: "Whether HTTP requests will be redirected to the access URL (if it's a https URL and TLS is enabled). Requests to local IP addresses are never redirected regardless of this setting.",
Flag: "tls-redirect-http-to-https",
Default: true,
},
CertFiles: &codersdk.DeploymentConfigField[[]string]{
Name: "TLS Certificate Files",
Usage: "Path to each certificate for TLS. It requires a PEM-encoded file. To configure the listener to use a CA certificate, concatenate the primary certificate and the CA certificate together. The primary certificate should appear first in the combined file.",
Flag: "tls-cert-file",
},
ClientCAFile: &codersdk.DeploymentConfigField[string]{
Name: "TLS Client CA Files",
Usage: "PEM-encoded Certificate Authority file used for checking the authenticity of client",
Flag: "tls-client-ca-file",
},
ClientAuth: &codersdk.DeploymentConfigField[string]{
Name: "TLS Client Auth",
Usage: "Policy the server will follow for TLS Client Authentication. Accepted values are \"none\", \"request\", \"require-any\", \"verify-if-given\", or \"require-and-verify\".",
Flag: "tls-client-auth",
Default: "none",
},
KeyFiles: &codersdk.DeploymentConfigField[[]string]{
Name: "TLS Key Files",
Usage: "Paths to the private keys for each of the certificates. It requires a PEM-encoded file.",
Flag: "tls-key-file",
},
MinVersion: &codersdk.DeploymentConfigField[string]{
Name: "TLS Minimum Version",
Usage: "Minimum supported version of TLS. Accepted values are \"tls10\", \"tls11\", \"tls12\" or \"tls13\"",
Flag: "tls-min-version",
Default: "tls12",
},
ClientCertFile: &codersdk.DeploymentConfigField[string]{
Name: "TLS Client Cert File",
Usage: "Path to certificate for client TLS authentication. It requires a PEM-encoded file.",
Flag: "tls-client-cert-file",
},
ClientKeyFile: &codersdk.DeploymentConfigField[string]{
Name: "TLS Client Key File",
Usage: "Path to key for client TLS authentication. It requires a PEM-encoded file.",
Flag: "tls-client-key-file",
},
},
Trace: &codersdk.TraceConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "Trace Enable",
Usage: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md",
Flag: "trace",
},
HoneycombAPIKey: &codersdk.DeploymentConfigField[string]{
Name: "Trace Honeycomb API Key",
Usage: "Enables trace exporting to Honeycomb.io using the provided API Key.",
Flag: "trace-honeycomb-api-key",
Secret: true,
},
CaptureLogs: &codersdk.DeploymentConfigField[bool]{
Name: "Capture Logs in Traces",
Usage: "Enables capturing of logs as events in traces. This is useful for debugging, but may result in a very large amount of events being sent to the tracing backend which may incur significant costs. If the verbose flag was supplied, debug-level logs will be included.",
Flag: "trace-logs",
},
},
SecureAuthCookie: &codersdk.DeploymentConfigField[bool]{
Name: "Secure Auth Cookie",
Usage: "Controls if the 'Secure' property is set on browser session cookies.",
Flag: "secure-auth-cookie",
},
SSHKeygenAlgorithm: &codersdk.DeploymentConfigField[string]{
Name: "SSH Keygen Algorithm",
Usage: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",
Flag: "ssh-keygen-algorithm",
Default: "ed25519",
},
MetricsCacheRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Metrics Cache Refresh Interval",
Usage: "How frequently metrics are refreshed",
Flag: "metrics-cache-refresh-interval",
Hidden: true,
Default: time.Hour,
},
AgentStatRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Agent Stat Refresh Interval",
Usage: "How frequently agent stats are recorded",
Flag: "agent-stats-refresh-interval",
Hidden: true,
Default: 10 * time.Minute,
},
AgentFallbackTroubleshootingURL: &codersdk.DeploymentConfigField[string]{
Name: "Agent Fallback Troubleshooting URL",
Usage: "URL to use for agent troubleshooting when not set in the template",
Flag: "agent-fallback-troubleshooting-url",
Hidden: true,
Default: "https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates",
},
AuditLogging: &codersdk.DeploymentConfigField[bool]{
Name: "Audit Logging",
Usage: "Specifies whether audit logging is enabled.",
Flag: "audit-logging",
Default: true,
Enterprise: true,
},
BrowserOnly: &codersdk.DeploymentConfigField[bool]{
Name: "Browser Only",
Usage: "Whether Coder only allows connections to workspaces via the browser.",
Flag: "browser-only",
Enterprise: true,
},
SCIMAPIKey: &codersdk.DeploymentConfigField[string]{
Name: "SCIM API Key",
Usage: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
Flag: "scim-auth-header",
Enterprise: true,
Secret: true,
},
Provisioner: &codersdk.ProvisionerConfig{
Daemons: &codersdk.DeploymentConfigField[int]{
Name: "Provisioner Daemons",
Usage: "Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this.",
Flag: "provisioner-daemons",
Default: 3,
},
DaemonPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Poll Interval",
Usage: "Time to wait before polling for a new job.",
Flag: "provisioner-daemon-poll-interval",
Default: time.Second,
},
DaemonPollJitter: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Poll Jitter",
Usage: "Random jitter added to the poll interval.",
Flag: "provisioner-daemon-poll-jitter",
Default: 100 * time.Millisecond,
},
ForceCancelInterval: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Force Cancel Interval",
Usage: "Time to force cancel provisioning tasks that are stuck.",
Flag: "provisioner-force-cancel-interval",
Default: 10 * time.Minute,
},
},
RateLimit: &codersdk.RateLimitConfig{
DisableAll: &codersdk.DeploymentConfigField[bool]{
Name: "Disable All Rate Limits",
Usage: "Disables all rate limits. This is not recommended in production.",
Flag: "dangerous-disable-rate-limits",
Default: false,
},
API: &codersdk.DeploymentConfigField[int]{
Name: "API Rate Limit",
Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints have separate strict rate limits regardless of this value to prevent denial-of-service or brute force attacks.",
// Change the env from the auto-generated CODER_RATE_LIMIT_API to the
// old value to avoid breaking existing deployments.
EnvOverride: "CODER_API_RATE_LIMIT",
Flag: "api-rate-limit",
Default: 512,
},
},
// DEPRECATED: use Experiments instead.
Experimental: &codersdk.DeploymentConfigField[bool]{
Name: "Experimental",
Usage: "Enable experimental features. Experimental features are not ready for production.",
Flag: "experimental",
Default: false,
Hidden: true,
},
Experiments: &codersdk.DeploymentConfigField[[]string]{
Name: "Experiments",
Usage: "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.",
Flag: "experiments",
Default: []string{},
},
UpdateCheck: &codersdk.DeploymentConfigField[bool]{
Name: "Update Check",
Usage: "Periodically check for new releases of Coder and inform the owner. The check is performed once per day.",
Flag: "update-check",
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
},
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Max Token Lifetime",
Usage: "The maximum lifetime duration for any user creating a token.",
Flag: "max-token-lifetime",
Default: 24 * 30 * time.Hour,
},
Swagger: &codersdk.SwaggerConfig{
Enable: &codersdk.DeploymentConfigField[bool]{
Name: "Enable swagger endpoint",
Usage: "Expose the swagger endpoint via /swagger.",
Flag: "swagger-enable",
Default: false,
},
},
Logging: &codersdk.LoggingConfig{
Human: &codersdk.DeploymentConfigField[string]{
Name: "Human Log Location",
Usage: "Output human-readable logs to a given file.",
Flag: "log-human",
Default: "/dev/stderr",
},
JSON: &codersdk.DeploymentConfigField[string]{
Name: "JSON Log Location",
Usage: "Output JSON logs to a given file.",
Flag: "log-json",
Default: "",
},
Stackdriver: &codersdk.DeploymentConfigField[string]{
Name: "Stackdriver Log Location",
Usage: "Output Stackdriver compatible logs to a given file.",
Flag: "log-stackdriver",
Default: "",
},
},
Dangerous: &codersdk.DangerousConfig{
AllowPathAppSharing: &codersdk.DeploymentConfigField[bool]{
Name: "DANGEROUS: Allow Path App Sharing",
Usage: "Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.",
Flag: "dangerous-allow-path-app-sharing",
Default: false,
},
AllowPathAppSiteOwnerAccess: &codersdk.DeploymentConfigField[bool]{
Name: "DANGEROUS: Allow Site Owners to Access Path Apps",
Usage: "Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.",
Flag: "dangerous-allow-path-app-site-owner-access",
Default: false,
},
},
DisablePathApps: &codersdk.DeploymentConfigField[bool]{
Name: "Disable Path Apps",
Usage: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.",
Flag: "disable-path-apps",
Default: false,
},
}
}
//nolint:revive
func Config(flagset *pflag.FlagSet, vip *viper.Viper) (*codersdk.DeploymentConfig, error) {
dc := newConfig()
flg, err := flagset.GetString(config.FlagName)
if err != nil {
return nil, xerrors.Errorf("get global config from flag: %w", err)
}
vip.SetEnvPrefix("coder")
if flg != "" {
vip.SetConfigFile(flg + "/server.yaml")
err = vip.ReadInConfig()
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
return dc, xerrors.Errorf("reading deployment config: %w", err)
}
}
setConfig("", vip, &dc)
return dc, nil
}
func setConfig(prefix string, vip *viper.Viper, target interface{}) {
val := reflect.Indirect(reflect.ValueOf(target))
typ := val.Type()
if typ.Kind() != reflect.Struct {
val = val.Elem()
typ = val.Type()
}
// Ensure that we only bind env variables to proper fields,
// otherwise Viper will get confused if the parent struct is
// assigned a value.
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
value := val.FieldByName("Value").Interface()
env, ok := val.FieldByName("EnvOverride").Interface().(string)
if !ok {
panic("DeploymentConfigField[].EnvOverride must be a string")
}
if env == "" {
env = formatEnv(prefix)
}
switch value.(type) {
case string:
vip.MustBindEnv(prefix, env)
val.FieldByName("Value").SetString(vip.GetString(prefix))
case bool:
vip.MustBindEnv(prefix, env)
val.FieldByName("Value").SetBool(vip.GetBool(prefix))
case int:
vip.MustBindEnv(prefix, env)
val.FieldByName("Value").SetInt(int64(vip.GetInt(prefix)))
case time.Duration:
vip.MustBindEnv(prefix, env)
val.FieldByName("Value").SetInt(int64(vip.GetDuration(prefix)))
case []string:
vip.MustBindEnv(prefix, env)
// As of October 21st, 2022 we supported delimiting a string
// with a comma, but Viper only supports with a space. This
// is a small hack around it!
rawSlice := reflect.ValueOf(vip.GetStringSlice(prefix)).Interface()
stringSlice, ok := rawSlice.([]string)
if !ok {
panic(fmt.Sprintf("string slice is of type %T", rawSlice))
}
value := make([]string, 0, len(stringSlice))
for _, entry := range stringSlice {
value = append(value, strings.Split(entry, ",")...)
}
val.FieldByName("Value").Set(reflect.ValueOf(value))
case []codersdk.GitAuthConfig:
// Do not bind to CODER_GITAUTH, instead bind to CODER_GITAUTH_0_*, etc.
values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value)
val.FieldByName("Value").Set(reflect.ValueOf(values))
default:
panic(fmt.Sprintf("unsupported type %T", value))
}
return
}
for i := 0; i < typ.NumField(); i++ {
fv := val.Field(i)
ft := fv.Type()
tag := typ.Field(i).Tag.Get("json")
var key string
if prefix == "" {
key = tag
} else {
key = fmt.Sprintf("%s.%s", prefix, tag)
}
switch ft.Kind() {
case reflect.Ptr:
setConfig(key, vip, fv.Interface())
case reflect.Slice:
for j := 0; j < fv.Len(); j++ {
key := fmt.Sprintf("%s.%d", key, j)
setConfig(key, vip, fv.Index(j).Interface())
}
default:
panic(fmt.Sprintf("unsupported type %T", ft))
}
}
}
// readSliceFromViper reads a typed mapping from the key provided.
// This enables environment variables like CODER_GITAUTH_<index>_CLIENT_ID.
func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T {
elementType := reflect.TypeOf(value).Elem()
returnValues := make([]T, 0)
for entry := 0; true; entry++ {
// Only create an instance when the entry exists in viper...
// otherwise we risk
var instance *reflect.Value
for i := 0; i < elementType.NumField(); i++ {
fve := elementType.Field(i)
prop := fve.Tag.Get("json")
// For fields that are omitted in JSON, we use a YAML tag.
if prop == "-" {
prop = fve.Tag.Get("yaml")
}
configKey := fmt.Sprintf("%s.%d.%s", key, entry, prop)
// Ensure the env entry for this key is registered
// before checking value.
//
// We don't support DeploymentConfigField[].EnvOverride for array flags so
// this is fine to just use `formatEnv` here.
vip.MustBindEnv(configKey, formatEnv(configKey))
value := vip.Get(configKey)
if value == nil {
continue
}
if instance == nil {
newType := reflect.Indirect(reflect.New(elementType))
instance = &newType
}
switch v := instance.Field(i).Type().String(); v {
case "[]string":
value = vip.GetStringSlice(configKey)
case "bool":
value = vip.GetBool(configKey)
default:
}
instance.Field(i).Set(reflect.ValueOf(value))
}
if instance == nil {
break
}
value, ok := instance.Interface().(T)
if !ok {
continue
}
returnValues = append(returnValues, value)
}
return returnValues
}
func NewViper() *viper.Viper {
dc := newConfig()
vip := viper.New()
vip.SetEnvPrefix("coder")
vip.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
setViperDefaults("", vip, dc)
return vip
}
func setViperDefaults(prefix string, vip *viper.Viper, target interface{}) {
val := reflect.ValueOf(target).Elem()
val = reflect.Indirect(val)
typ := val.Type()
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
value := val.FieldByName("Default").Interface()
vip.SetDefault(prefix, value)
return
}
for i := 0; i < typ.NumField(); i++ {
fv := val.Field(i)
ft := fv.Type()
tag := typ.Field(i).Tag.Get("json")
var key string
if prefix == "" {
key = tag
} else {
key = fmt.Sprintf("%s.%s", prefix, tag)
}
switch ft.Kind() {
case reflect.Ptr:
setViperDefaults(key, vip, fv.Interface())
case reflect.Slice:
// we currently don't support default values on structured slices
continue
default:
panic(fmt.Sprintf("unsupported type %T", ft))
}
}
}
//nolint:revive
func AttachFlags(flagset *pflag.FlagSet, vip *viper.Viper, enterprise bool) {
setFlags("", flagset, vip, newConfig(), enterprise)
}
//nolint:revive
func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target interface{}, enterprise bool) {
val := reflect.Indirect(reflect.ValueOf(target))
typ := val.Type()
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
isEnt := val.FieldByName("Enterprise").Bool()
if enterprise != isEnt {
return
}
flg := val.FieldByName("Flag").String()
if flg == "" {
return
}
env, ok := val.FieldByName("EnvOverride").Interface().(string)
if !ok {
panic("DeploymentConfigField[].EnvOverride must be a string")
}
if env == "" {
env = formatEnv(prefix)
}
usage := val.FieldByName("Usage").String()
usage = fmt.Sprintf("%s\n%s", usage, cliui.Styles.Placeholder.Render("Consumes $"+env))
shorthand := val.FieldByName("Shorthand").String()
hidden := val.FieldByName("Hidden").Bool()
value := val.FieldByName("Default").Interface()
// Allow currently set environment variables
// to override default values in help output.
vip.MustBindEnv(prefix, env)
switch value.(type) {
case string:
_ = flagset.StringP(flg, shorthand, vip.GetString(prefix), usage)
case bool:
_ = flagset.BoolP(flg, shorthand, vip.GetBool(prefix), usage)
case int:
_ = flagset.IntP(flg, shorthand, vip.GetInt(prefix), usage)
case time.Duration:
_ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage)
case []string:
_ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage)
case []codersdk.GitAuthConfig:
// Ignore this one!
default:
panic(fmt.Sprintf("unsupported type %T", typ))
}
_ = vip.BindPFlag(prefix, flagset.Lookup(flg))
if hidden {
_ = flagset.MarkHidden(flg)
}
return
}
for i := 0; i < typ.NumField(); i++ {
fv := val.Field(i)
ft := fv.Type()
tag := typ.Field(i).Tag.Get("json")
var key string
if prefix == "" {
key = tag
} else {
key = fmt.Sprintf("%s.%s", prefix, tag)
}
switch ft.Kind() {
case reflect.Ptr:
setFlags(key, flagset, vip, fv.Interface(), enterprise)
case reflect.Slice:
for j := 0; j < fv.Len(); j++ {
key := fmt.Sprintf("%s.%d", key, j)
setFlags(key, flagset, vip, fv.Index(j).Interface(), enterprise)
}
default:
panic(fmt.Sprintf("unsupported type %T", ft))
}
}
}
func formatEnv(key string) string {
return "CODER_" + strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(key))
}
func DefaultCacheDir() string {
defaultCacheDir, err := os.UserCacheDir()
if err != nil {
defaultCacheDir = os.TempDir()
}
if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" {
// For compatibility with systemd.
defaultCacheDir = dir
}
return filepath.Join(defaultCacheDir, "coder")
}
-264
View File
@@ -1,264 +0,0 @@
package deployment_test
import (
"testing"
"time"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/cli/deployment"
"github.com/coder/coder/codersdk"
)
// nolint:paralleltest
func TestConfig(t *testing.T) {
viper := deployment.NewViper()
flagSet := pflag.NewFlagSet("", pflag.ContinueOnError)
flagSet.String(config.FlagName, "", "")
deployment.AttachFlags(flagSet, viper, true)
for _, tc := range []struct {
Name string
Env map[string]string
Valid func(config *codersdk.DeploymentConfig)
}{{
Name: "Deployment",
Env: map[string]string{
"CODER_ADDRESS": "0.0.0.0:8443",
"CODER_ACCESS_URL": "https://dev.coder.com",
"CODER_PG_CONNECTION_URL": "some-url",
"CODER_PPROF_ADDRESS": "something",
"CODER_PPROF_ENABLE": "true",
"CODER_PROMETHEUS_ADDRESS": "hello-world",
"CODER_PROMETHEUS_ENABLE": "true",
"CODER_PROVISIONER_DAEMONS": "5",
"CODER_PROVISIONER_DAEMON_POLL_INTERVAL": "5s",
"CODER_PROVISIONER_DAEMON_POLL_JITTER": "1s",
"CODER_SECURE_AUTH_COOKIE": "true",
"CODER_SSH_KEYGEN_ALGORITHM": "potato",
"CODER_TELEMETRY": "false",
"CODER_TELEMETRY_TRACE": "false",
"CODER_WILDCARD_ACCESS_URL": "something-wildcard.com",
"CODER_UPDATE_CHECK": "false",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.Address.Value, "0.0.0.0:8443")
require.Equal(t, config.AccessURL.Value, "https://dev.coder.com")
require.Equal(t, config.PostgresURL.Value, "some-url")
require.Equal(t, config.Pprof.Address.Value, "something")
require.Equal(t, config.Pprof.Enable.Value, true)
require.Equal(t, config.Prometheus.Address.Value, "hello-world")
require.Equal(t, config.Prometheus.Enable.Value, true)
require.Equal(t, config.Provisioner.Daemons.Value, 5)
require.Equal(t, config.Provisioner.DaemonPollInterval.Value, 5*time.Second)
require.Equal(t, config.Provisioner.DaemonPollJitter.Value, 1*time.Second)
require.Equal(t, config.SecureAuthCookie.Value, true)
require.Equal(t, config.SSHKeygenAlgorithm.Value, "potato")
require.Equal(t, config.Telemetry.Enable.Value, false)
require.Equal(t, config.Telemetry.Trace.Value, false)
require.Equal(t, config.WildcardAccessURL.Value, "something-wildcard.com")
require.Equal(t, config.UpdateCheck.Value, false)
},
}, {
Name: "DERP",
Env: map[string]string{
"CODER_DERP_CONFIG_PATH": "/example/path",
"CODER_DERP_CONFIG_URL": "https://google.com",
"CODER_DERP_SERVER_ENABLE": "false",
"CODER_DERP_SERVER_REGION_CODE": "something",
"CODER_DERP_SERVER_REGION_ID": "123",
"CODER_DERP_SERVER_REGION_NAME": "Code-Land",
"CODER_DERP_SERVER_RELAY_URL": "1.1.1.1",
"CODER_DERP_SERVER_STUN_ADDRESSES": "google.org",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.DERP.Config.Path.Value, "/example/path")
require.Equal(t, config.DERP.Config.URL.Value, "https://google.com")
require.Equal(t, config.DERP.Server.Enable.Value, false)
require.Equal(t, config.DERP.Server.RegionCode.Value, "something")
require.Equal(t, config.DERP.Server.RegionID.Value, 123)
require.Equal(t, config.DERP.Server.RegionName.Value, "Code-Land")
require.Equal(t, config.DERP.Server.RelayURL.Value, "1.1.1.1")
require.Equal(t, config.DERP.Server.STUNAddresses.Value, []string{"google.org"})
},
}, {
Name: "Enterprise",
Env: map[string]string{
"CODER_AUDIT_LOGGING": "false",
"CODER_BROWSER_ONLY": "true",
"CODER_SCIM_API_KEY": "some-key",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.AuditLogging.Value, false)
require.Equal(t, config.BrowserOnly.Value, true)
require.Equal(t, config.SCIMAPIKey.Value, "some-key")
},
}, {
Name: "TLS",
Env: map[string]string{
"CODER_TLS_CERT_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
"CODER_TLS_KEY_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
"CODER_TLS_CLIENT_AUTH": "/some/path",
"CODER_TLS_CLIENT_CA_FILE": "/some/path",
"CODER_TLS_ENABLE": "true",
"CODER_TLS_MIN_VERSION": "tls10",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Len(t, config.TLS.CertFiles.Value, 2)
require.Equal(t, config.TLS.CertFiles.Value[0], "/etc/acme-sh/dev.coder.com")
require.Equal(t, config.TLS.CertFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
require.Len(t, config.TLS.KeyFiles.Value, 2)
require.Equal(t, config.TLS.KeyFiles.Value[0], "/etc/acme-sh/dev.coder.com")
require.Equal(t, config.TLS.KeyFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
require.Equal(t, config.TLS.ClientAuth.Value, "/some/path")
require.Equal(t, config.TLS.ClientCAFile.Value, "/some/path")
require.Equal(t, config.TLS.Enable.Value, true)
require.Equal(t, config.TLS.MinVersion.Value, "tls10")
},
}, {
Name: "Trace",
Env: map[string]string{
"CODER_TRACE_ENABLE": "true",
"CODER_TRACE_HONEYCOMB_API_KEY": "my-honeycomb-key",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.Trace.Enable.Value, true)
require.Equal(t, config.Trace.HoneycombAPIKey.Value, "my-honeycomb-key")
},
}, {
Name: "OIDC_Defaults",
Env: map[string]string{},
Valid: func(config *codersdk.DeploymentConfig) {
require.Empty(t, config.OIDC.IssuerURL.Value)
require.Empty(t, config.OIDC.EmailDomain.Value)
require.Empty(t, config.OIDC.ClientID.Value)
require.Empty(t, config.OIDC.ClientSecret.Value)
require.True(t, config.OIDC.AllowSignups.Value)
require.ElementsMatch(t, config.OIDC.Scopes.Value, []string{"openid", "email", "profile"})
require.False(t, config.OIDC.IgnoreEmailVerified.Value)
},
}, {
Name: "OIDC",
Env: map[string]string{
"CODER_OIDC_ISSUER_URL": "https://accounts.google.com",
"CODER_OIDC_EMAIL_DOMAIN": "coder.com",
"CODER_OIDC_CLIENT_ID": "client",
"CODER_OIDC_CLIENT_SECRET": "secret",
"CODER_OIDC_ALLOW_SIGNUPS": "false",
"CODER_OIDC_SCOPES": "something,here",
"CODER_OIDC_IGNORE_EMAIL_VERIFIED": "true",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.OIDC.IssuerURL.Value, "https://accounts.google.com")
require.Equal(t, config.OIDC.EmailDomain.Value, []string{"coder.com"})
require.Equal(t, config.OIDC.ClientID.Value, "client")
require.Equal(t, config.OIDC.ClientSecret.Value, "secret")
require.False(t, config.OIDC.AllowSignups.Value)
require.Equal(t, config.OIDC.Scopes.Value, []string{"something", "here"})
require.True(t, config.OIDC.IgnoreEmailVerified.Value)
},
}, {
Name: "GitHub",
Env: map[string]string{
"CODER_OAUTH2_GITHUB_CLIENT_ID": "client",
"CODER_OAUTH2_GITHUB_CLIENT_SECRET": "secret",
"CODER_OAUTH2_GITHUB_ALLOWED_ORGS": "coder",
"CODER_OAUTH2_GITHUB_ALLOWED_TEAMS": "coder",
"CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS": "true",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.OAuth2.Github.ClientID.Value, "client")
require.Equal(t, config.OAuth2.Github.ClientSecret.Value, "secret")
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedOrgs.Value)
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedTeams.Value)
require.Equal(t, config.OAuth2.Github.AllowSignups.Value, true)
},
}, {
Name: "GitAuth",
Env: map[string]string{
"CODER_GITAUTH_0_ID": "hello",
"CODER_GITAUTH_0_TYPE": "github",
"CODER_GITAUTH_0_CLIENT_ID": "client",
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
"CODER_GITAUTH_0_REGEX": "github.com",
"CODER_GITAUTH_0_SCOPES": "read write",
"CODER_GITAUTH_0_NO_REFRESH": "true",
"CODER_GITAUTH_1_ID": "another",
"CODER_GITAUTH_1_TYPE": "gitlab",
"CODER_GITAUTH_1_CLIENT_ID": "client-2",
"CODER_GITAUTH_1_CLIENT_SECRET": "secret-2",
"CODER_GITAUTH_1_AUTH_URL": "https://auth-2.com",
"CODER_GITAUTH_1_TOKEN_URL": "https://token-2.com",
"CODER_GITAUTH_1_REGEX": "gitlab.com",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Len(t, config.GitAuth.Value, 2)
require.Equal(t, []codersdk.GitAuthConfig{{
ID: "hello",
Type: "github",
ClientID: "client",
ClientSecret: "secret",
AuthURL: "https://auth.com",
TokenURL: "https://token.com",
ValidateURL: "https://validate.com",
Regex: "github.com",
Scopes: []string{"read", "write"},
NoRefresh: true,
}, {
ID: "another",
Type: "gitlab",
ClientID: "client-2",
ClientSecret: "secret-2",
AuthURL: "https://auth-2.com",
TokenURL: "https://token-2.com",
Regex: "gitlab.com",
}}, config.GitAuth.Value)
},
}, {
Name: "Wrong env must not break default values",
Env: map[string]string{
"CODER_PROMETHEUS_ENABLE": "true",
"CODER_PROMETHEUS": "true", // Wrong env name, must not break prom addr.
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.Prometheus.Enable.Value, true)
require.Equal(t, config.Prometheus.Address.Value, config.Prometheus.Address.Default)
},
}, {
Name: "Experiments - no features",
Env: map[string]string{
"CODER_EXPERIMENTS": "",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Empty(t, config.Experiments.Value)
},
}, {
Name: "Experiments - multiple features",
Env: map[string]string{
"CODER_EXPERIMENTS": "foo,bar",
},
Valid: func(config *codersdk.DeploymentConfig) {
expected := []string{"foo", "bar"}
require.ElementsMatch(t, expected, config.Experiments.Value)
},
}} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Helper()
for key, value := range tc.Env {
t.Setenv(key, value)
}
config, err := deployment.Config(flagSet, viper)
require.NoError(t, err)
tc.Valid(config)
})
}
}
+48 -39
View File
@@ -10,30 +10,29 @@ import (
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
func dotfiles() *cobra.Command {
func (r *RootCmd) dotfiles() *clibase.Cmd {
var symlinkDir string
cmd := &cobra.Command{
Use: "dotfiles [git_repo_url]",
Args: cobra.ExactArgs(1),
Short: "Checkout and install a dotfiles repository from a Git URL",
Example: formatExamples(
cmd := &clibase.Cmd{
Use: "dotfiles <git_repo_url>",
Middleware: clibase.RequireNArgs(1),
Short: "Personalize your workspace by applying a canonical dotfiles repository",
Long: formatExamples(
example{
Description: "Check out and install a dotfiles repository without prompts",
Command: "coder dotfiles --yes git@github.com:example/dotfiles.git",
},
),
RunE: func(cmd *cobra.Command, args []string) error {
Handler: func(inv *clibase.Invocation) error {
var (
dotfilesRepoDir = "dotfiles"
gitRepo = args[0]
cfg = createConfig(cmd)
gitRepo = inv.Args[0]
cfg = r.createConfig()
cfgDir = string(cfg)
dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir)
// This follows the same pattern outlined by others in the market:
@@ -50,7 +49,11 @@ func dotfiles() *cobra.Command {
}
)
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Checking if dotfiles repository already exists...\n")
if cfg == "" {
return xerrors.Errorf("no config directory")
}
_, _ = fmt.Fprint(inv.Stdout, "Checking if dotfiles repository already exists...\n")
dotfilesExists, err := dirExists(dotfilesDir)
if err != nil {
return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err)
@@ -65,7 +68,7 @@ func dotfiles() *cobra.Command {
// if the git url has changed we create a backup and clone fresh
if gitRepo != du {
backupDir := fmt.Sprintf("%s_backup_%s", dotfilesDir, time.Now().Format(time.RFC3339))
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("The dotfiles URL has changed from %q to %q.\n Coder will backup the existing repo to %s.\n\n Continue?", du, gitRepo, backupDir),
IsConfirm: true,
})
@@ -77,7 +80,7 @@ func dotfiles() *cobra.Command {
if err != nil {
return xerrors.Errorf("renaming dir %s: %w", dotfilesDir, err)
}
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Done backup up dotfiles.\n")
_, _ = fmt.Fprint(inv.Stdout, "Done backup up dotfiles.\n")
dotfilesExists = false
moved = true
}
@@ -89,20 +92,20 @@ func dotfiles() *cobra.Command {
promptText string
)
if dotfilesExists {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Found dotfiles repository at %s\n", dotfilesDir)
_, _ = fmt.Fprintf(inv.Stdout, "Found dotfiles repository at %s\n", dotfilesDir)
gitCmdDir = dotfilesDir
subcommands = []string{"pull", "--ff-only"}
promptText = fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?", gitRepo, dotfilesDir)
} else {
if !moved {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Did not find dotfiles repository at %s\n", dotfilesDir)
_, _ = fmt.Fprintf(inv.Stdout, "Did not find dotfiles repository at %s\n", dotfilesDir)
}
gitCmdDir = cfgDir
subcommands = []string{"clone", args[0], dotfilesRepoDir}
subcommands = []string{"clone", inv.Args[0], dotfilesRepoDir}
promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir)
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: promptText,
IsConfirm: true,
})
@@ -111,9 +114,9 @@ func dotfiles() *cobra.Command {
}
// ensure command dir exists
err = os.MkdirAll(gitCmdDir, 0750)
err = os.MkdirAll(gitCmdDir, 0o750)
if err != nil {
return xerrors.Errorf("ensuring dir at %s: %w", gitCmdDir, err)
return xerrors.Errorf("ensuring dir at %q: %w", gitCmdDir, err)
}
// check if git ssh command already exists so we can just wrap it
@@ -123,18 +126,18 @@ func dotfiles() *cobra.Command {
}
// clone or pull repo
c := exec.CommandContext(cmd.Context(), "git", subcommands...)
c := exec.CommandContext(inv.Context(), "git", subcommands...)
c.Dir = gitCmdDir
c.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
c.Stdout = cmd.OutOrStdout()
c.Stderr = cmd.ErrOrStderr()
c.Env = append(inv.Environ.ToOS(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
c.Stdout = inv.Stdout
c.Stderr = inv.Stderr
err = c.Run()
if err != nil {
if !dotfilesExists {
return err
}
// if the repo exists we soft fail the update operation and try to continue
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Failed to update repo, continuing..."))
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Error.Render("Failed to update repo, continuing..."))
}
// save git repo url so we can detect changes next time
@@ -158,7 +161,7 @@ func dotfiles() *cobra.Command {
script := findScript(installScriptSet, files)
if script != "" {
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script),
IsConfirm: true,
})
@@ -166,29 +169,29 @@ func dotfiles() *cobra.Command {
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Running %s...\n", script)
_, _ = fmt.Fprintf(inv.Stdout, "Running %s...\n", script)
// it is safe to use a variable command here because it's from
// a filtered list of pre-approved install scripts
// nolint:gosec
scriptCmd := exec.CommandContext(cmd.Context(), filepath.Join(dotfilesDir, script))
scriptCmd := exec.CommandContext(inv.Context(), filepath.Join(dotfilesDir, script))
scriptCmd.Dir = dotfilesDir
scriptCmd.Stdout = cmd.OutOrStdout()
scriptCmd.Stderr = cmd.ErrOrStderr()
scriptCmd.Stdout = inv.Stdout
scriptCmd.Stderr = inv.Stderr
err = scriptCmd.Run()
if err != nil {
return xerrors.Errorf("running %s: %w", script, err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
_, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.")
return nil
}
if len(dotfiles) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.")
_, _ = fmt.Fprintln(inv.Stdout, "No install scripts or dotfiles found, nothing to do.")
return nil
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "No install scripts found, symlinking dotfiles to home directory.\n\n Continue?",
IsConfirm: true,
})
@@ -206,7 +209,7 @@ func dotfiles() *cobra.Command {
for _, df := range dotfiles {
from := filepath.Join(dotfilesDir, df)
to := filepath.Join(symlinkDir, df)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Symlinking %s to %s...\n", from, to)
_, _ = fmt.Fprintf(inv.Stdout, "Symlinking %s to %s...\n", from, to)
isRegular, err := isRegular(to)
if err != nil {
@@ -215,7 +218,7 @@ func dotfiles() *cobra.Command {
// move conflicting non-symlink files to file.ext.bak
if isRegular {
backup := fmt.Sprintf("%s.bak", to)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Moving %s to %s...\n", to, backup)
_, _ = fmt.Fprintf(inv.Stdout, "Moving %s to %s...\n", to, backup)
err = os.Rename(to, backup)
if err != nil {
return xerrors.Errorf("renaming dir %s: %w", to, err)
@@ -228,13 +231,19 @@ func dotfiles() *cobra.Command {
}
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
_, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.")
return nil
},
}
cliui.AllowSkipPrompt(cmd)
cliflag.StringVarP(cmd.Flags(), &symlinkDir, "symlink-dir", "", "CODER_SYMLINK_DIR", "", "Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.")
cmd.Options = clibase.OptionSet{
{
Flag: "symlink-dir",
Env: "CODER_SYMLINK_DIR",
Description: "Specifies the directory for the dotfiles symlink destinations. If empty, will use $HOME.",
Value: clibase.StringOf(&symlinkDir),
},
cliui.SkipPromptOption(),
}
return cmd
}
+18 -14
View File
@@ -15,19 +15,21 @@ import (
"github.com/coder/coder/cryptorand"
)
// nolint:paralleltest
func TestDotfiles(t *testing.T) {
t.Parallel()
t.Run("MissingArg", func(t *testing.T) {
cmd, _ := clitest.New(t, "dotfiles")
err := cmd.Execute()
t.Parallel()
inv, _ := clitest.New(t, "dotfiles")
err := inv.Run()
require.Error(t, err)
})
t.Run("NoInstallScript", func(t *testing.T) {
t.Parallel()
_, root := clitest.New(t)
testRepo := testGitRepo(t, root)
// nolint:gosec
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
require.NoError(t, err)
c := exec.Command("git", "add", ".bashrc")
@@ -40,8 +42,8 @@ func TestDotfiles(t *testing.T) {
out, err := c.CombinedOutput()
require.NoError(t, err, string(out))
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = cmd.Execute()
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = inv.Run()
require.NoError(t, err)
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
@@ -49,6 +51,7 @@ func TestDotfiles(t *testing.T) {
require.Equal(t, string(b), "wow")
})
t.Run("InstallScript", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("install scripts on windows require sh and aren't very practical")
}
@@ -56,7 +59,7 @@ func TestDotfiles(t *testing.T) {
testRepo := testGitRepo(t, root)
// nolint:gosec
err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0750)
err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0o750)
require.NoError(t, err)
c := exec.Command("git", "add", "install.sh")
@@ -69,8 +72,8 @@ func TestDotfiles(t *testing.T) {
err = c.Run()
require.NoError(t, err)
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = cmd.Execute()
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = inv.Run()
require.NoError(t, err)
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
@@ -78,16 +81,17 @@ func TestDotfiles(t *testing.T) {
require.Equal(t, string(b), "wow\n")
})
t.Run("SymlinkBackup", func(t *testing.T) {
t.Parallel()
_, root := clitest.New(t)
testRepo := testGitRepo(t, root)
// nolint:gosec
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
require.NoError(t, err)
// add a conflicting file at destination
// nolint:gosec
err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0750)
err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0o750)
require.NoError(t, err)
c := exec.Command("git", "add", ".bashrc")
@@ -100,8 +104,8 @@ func TestDotfiles(t *testing.T) {
out, err := c.CombinedOutput()
require.NoError(t, err, string(out))
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = cmd.Execute()
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
err = inv.Run()
require.NoError(t, err)
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
@@ -119,7 +123,7 @@ func testGitRepo(t *testing.T, root config.Root) string {
r, err := cryptorand.String(8)
require.NoError(t, err)
dir := filepath.Join(string(root), fmt.Sprintf("test-repo-%s", r))
err = os.MkdirAll(dir, 0750)
err = os.MkdirAll(dir, 0o750)
require.NoError(t, err)
c := exec.Command("git", "init")
+17 -18
View File
@@ -7,9 +7,9 @@ import (
"os/signal"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/codersdk"
@@ -18,63 +18,62 @@ import (
// gitAskpass is used by the Coder agent to automatically authenticate
// with Git providers based on a hostname.
func gitAskpass() *cobra.Command {
return &cobra.Command{
func (r *RootCmd) gitAskpass() *clibase.Cmd {
return &clibase.Cmd{
Use: "gitaskpass",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
defer stop()
user, host, err := gitauth.ParseAskpass(args[0])
user, host, err := gitauth.ParseAskpass(inv.Args[0])
if err != nil {
return xerrors.Errorf("parse host: %w", err)
}
client, err := createAgentClient(cmd)
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
token, err := client.WorkspaceAgentGitAuth(ctx, host, false)
token, err := client.GitAuth(ctx, host, false)
if err != nil {
var apiError *codersdk.Error
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
// This prevents the "Run 'coder --help' for usage"
// message from occurring.
cmd.Printf("%s\n", apiError.Message)
cliui.Errorf(inv.Stderr, "%s\n", apiError.Message)
return cliui.Canceled
}
return xerrors.Errorf("get git token: %w", err)
}
if token.URL != "" {
if err := openURL(cmd, token.URL); err == nil {
cmd.Printf("Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
if err := openURL(inv, token.URL); err == nil {
cliui.Infof(inv.Stdout, "Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
} else {
cmd.Printf("Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
cliui.Infof(inv.Stdout, "Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
}
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
token, err = client.WorkspaceAgentGitAuth(ctx, host, true)
token, err = client.GitAuth(ctx, host, true)
if err != nil {
continue
}
cmd.Printf("You've been authenticated with Git!\n")
cliui.Infof(inv.Stdout, "You've been authenticated with Git!\n")
break
}
}
if token.Password != "" {
if user == "" {
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
_, _ = fmt.Fprintln(inv.Stdout, token.Username)
} else {
fmt.Fprintln(cmd.OutOrStdout(), token.Password)
_, _ = fmt.Fprintln(inv.Stdout, token.Password)
}
} else {
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
_, _ = fmt.Fprintln(inv.Stdout, token.Username)
}
return nil
+25 -20
View File
@@ -14,37 +14,39 @@ import (
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/pty/ptytest"
)
// nolint:paralleltest
func TestGitAskpass(t *testing.T) {
t.Setenv("GIT_PREFIX", "/")
t.Parallel()
t.Run("UsernameAndPassword", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{
Username: "something",
Password: "bananas",
})
}))
t.Cleanup(srv.Close)
url := srv.URL
cmd, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':")
inv, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':")
inv.Environ.Set("GIT_PREFIX", "/")
pty := ptytest.New(t)
cmd.SetOutput(pty.Output())
err := cmd.Execute()
require.NoError(t, err)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatch("something")
cmd, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':")
inv, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':")
inv.Environ.Set("GIT_PREFIX", "/")
pty = ptytest.New(t)
cmd.SetOutput(pty.Output())
err = cmd.Execute()
require.NoError(t, err)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatch("bananas")
})
t.Run("NoHost", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusNotFound, codersdk.Response{
Message: "Nope!",
@@ -52,17 +54,19 @@ func TestGitAskpass(t *testing.T) {
}))
t.Cleanup(srv.Close)
url := srv.URL
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
inv, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
inv.Environ.Set("GIT_PREFIX", "/")
pty := ptytest.New(t)
cmd.SetOutput(pty.Output())
err := cmd.Execute()
inv.Stderr = pty.Output()
err := inv.Run()
require.ErrorIs(t, err, cliui.Canceled)
pty.ExpectMatch("Nope!")
})
t.Run("Poll", func(t *testing.T) {
resp := atomic.Pointer[codersdk.WorkspaceAgentGitAuthResponse]{}
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
t.Parallel()
resp := atomic.Pointer[agentsdk.GitAuthResponse]{}
resp.Store(&agentsdk.GitAuthResponse{
URL: "https://something.org",
})
poll := make(chan struct{}, 10)
@@ -80,15 +84,16 @@ func TestGitAskpass(t *testing.T) {
t.Cleanup(srv.Close)
url := srv.URL
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
inv, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
inv.Environ.Set("GIT_PREFIX", "/")
pty := ptytest.New(t)
cmd.SetOutput(pty.Output())
inv.Stdout = pty.Output()
go func() {
err := cmd.Execute()
err := inv.Run()
assert.NoError(t, err)
}()
<-poll
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
resp.Store(&agentsdk.GitAuthResponse{
Username: "username",
Password: "password",
})
+18 -17
View File
@@ -12,19 +12,19 @@ import (
"path/filepath"
"strings"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
func gitssh() *cobra.Command {
cmd := &cobra.Command{
func (r *RootCmd) gitssh() *clibase.Cmd {
cmd := &clibase.Cmd{
Use: "gitssh",
Hidden: true,
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
env := os.Environ()
// Catch interrupt signals to ensure the temporary private
@@ -33,16 +33,16 @@ func gitssh() *cobra.Command {
defer stop()
// Early check so errors are reported immediately.
identityFiles, err := parseIdentityFilesForHost(ctx, args, env)
identityFiles, err := parseIdentityFilesForHost(ctx, inv.Args, env)
if err != nil {
return err
}
client, err := createAgentClient(cmd)
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
key, err := client.AgentGitSSHKey(ctx)
key, err := client.GitSSHKey(ctx)
if err != nil {
return xerrors.Errorf("get agent git ssh token: %w", err)
}
@@ -78,24 +78,25 @@ func gitssh() *cobra.Command {
identityArgs = append(identityArgs, "-i", id)
}
args := inv.Args
args = append(identityArgs, args...)
c := exec.CommandContext(ctx, "ssh", args...)
c.Env = append(c.Env, env...)
c.Stderr = cmd.ErrOrStderr()
c.Stdout = cmd.OutOrStdout()
c.Stdin = cmd.InOrStdin()
c.Stderr = inv.Stderr
c.Stdout = inv.Stdout
c.Stdin = inv.Stdin
err = c.Run()
if err != nil {
exitErr := &exec.ExitError{}
if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(),
_, _ = fmt.Fprintln(inv.Stderr,
"\n"+cliui.Styles.Wrap.Render("Coder authenticates with "+cliui.Styles.Field.Render("git")+
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Add to GitHub and GitLab:")
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
_, _ = fmt.Fprintln(inv.Stderr)
return err
}
return xerrors.Errorf("run ssh command: %w", err)
+16 -34
View File
@@ -24,7 +24,6 @@ import (
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
@@ -48,23 +47,9 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
// setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
Agents: []*proto.Agent{{
Auth: &proto.Agent_Token{
Token: agentToken,
},
}},
}},
},
},
}},
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -72,15 +57,12 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// start workspace agent
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
agentClient := client
clitest.SetupConfig(t, agentClient, root)
errC := make(chan error, 1)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
t.Cleanup(func() { require.NoError(t, <-errC) })
clitest.Start(t, inv)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
return agentClient, agentToken, pubkey
}
@@ -156,7 +138,7 @@ func TestGitSSH(t *testing.T) {
}, pubkey)
// set to agent config dir
cmd, _ := clitest.New(t,
inv, _ := clitest.New(t,
"gitssh",
"--agent-url", client.URL.String(),
"--agent-token", token,
@@ -166,7 +148,7 @@ func TestGitSSH(t *testing.T) {
"-o", "IdentitiesOnly=yes",
"127.0.0.1",
)
err := cmd.ExecuteContext(ctx)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.EqualValues(t, 1, inc)
@@ -228,10 +210,10 @@ func TestGitSSH(t *testing.T) {
"mytest",
}
// Test authentication via local private key.
cmd, _ := clitest.New(t, cmdArgs...)
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
err = cmd.ExecuteContext(ctx)
inv, _ := clitest.New(t, cmdArgs...)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
select {
case key := <-authkey:
@@ -245,10 +227,10 @@ func TestGitSSH(t *testing.T) {
require.NoError(t, err)
// With the local file deleted, the coder key should be used.
cmd, _ = clitest.New(t, cmdArgs...)
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
err = cmd.ExecuteContext(ctx)
inv, _ = clitest.New(t, cmdArgs...)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
select {
case key := <-authkey:
+295
View File
@@ -0,0 +1,295 @@
package cli
import (
"bufio"
"bytes"
_ "embed"
"fmt"
"io"
"regexp"
"sort"
"strings"
"text/tabwriter"
"text/template"
"unicode"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
//go:embed help.tpl
var helpTemplateRaw string
type optionGroup struct {
Name string
Description string
Options clibase.OptionSet
}
func ttyWidth() int {
width, _, err := terminal.GetSize(0)
if err != nil {
return 80
}
return width
}
// wrapTTY wraps a string to the width of the terminal, or 80 no terminal
// is detected.
func wrapTTY(s string) string {
return wordwrap.WrapString(s, uint(ttyWidth()))
}
var usageTemplate = template.Must(
template.New("usage").Funcs(
template.FuncMap{
"wrapTTY": func(s string) string {
return wrapTTY(s)
},
"trimNewline": func(s string) string {
return strings.TrimSuffix(s, "\n")
},
"typeHelper": func(opt *clibase.Option) string {
switch v := opt.Value.(type) {
case *clibase.Enum:
return strings.Join(v.Choices, "|")
default:
return v.Type()
}
},
"joinStrings": func(s []string) string {
return strings.Join(s, ", ")
},
"indent": func(body string, spaces int) string {
twidth := ttyWidth()
spacing := strings.Repeat(" ", spaces)
body = wordwrap.WrapString(body, uint(twidth-len(spacing)))
var sb strings.Builder
for _, line := range strings.Split(body, "\n") {
// Remove existing indent, if any.
line = strings.TrimSpace(line)
// Use spaces so we can easily calculate wrapping.
_, _ = sb.WriteString(spacing)
_, _ = sb.WriteString(line)
_, _ = sb.WriteString("\n")
}
return sb.String()
},
"formatSubcommand": func(cmd *clibase.Cmd) string {
// Minimize padding by finding the longest neighboring name.
maxNameLength := len(cmd.Name())
if parent := cmd.Parent; parent != nil {
for _, c := range parent.Children {
if len(c.Name()) > maxNameLength {
maxNameLength = len(c.Name())
}
}
}
var sb strings.Builder
_, _ = fmt.Fprintf(
&sb, "%s%s%s",
strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4),
)
// This is the point at which indentation begins if there's a
// next line.
descStart := sb.Len()
twidth := ttyWidth()
for i, line := range strings.Split(
wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n",
) {
if i > 0 {
_, _ = sb.WriteString(strings.Repeat(" ", descStart))
}
_, _ = sb.WriteString(line)
_, _ = sb.WriteString("\n")
}
return sb.String()
},
"envName": func(opt clibase.Option) string {
if opt.Env == "" {
return ""
}
return opt.Env
},
"flagName": func(opt clibase.Option) string {
return opt.Flag
},
"prettyHeader": func(s string) string {
return cliui.Styles.Bold.Render(s)
},
"isEnterprise": func(opt clibase.Option) bool {
return opt.Annotations.IsSet("enterprise")
},
"isDeprecated": func(opt clibase.Option) bool {
return len(opt.UseInstead) > 0
},
"formatLong": func(long string) string {
// We intentionally don't wrap here because it would misformat
// examples, where the new line would start without the prior
// line's indentation.
return strings.TrimSpace(long)
},
"formatGroupDescription": func(s string) string {
s = strings.ReplaceAll(s, "\n", "")
s = s + "\n"
s = wrapTTY(s)
return s
},
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
return !c.Hidden
})
},
"optionGroups": func(cmd *clibase.Cmd) []optionGroup {
groups := []optionGroup{{
// Default group.
Name: "",
Description: "",
}}
enterpriseGroup := optionGroup{
Name: "Enterprise",
Description: `These options are only available in the Enterprise Edition.`,
}
// Sort options lexicographically.
sort.Slice(cmd.Options, func(i, j int) bool {
return cmd.Options[i].Name < cmd.Options[j].Name
})
optionLoop:
for _, opt := range cmd.Options {
if opt.Hidden {
continue
}
// Enterprise options are always grouped separately.
if opt.Annotations.IsSet("enterprise") {
enterpriseGroup.Options = append(enterpriseGroup.Options, opt)
continue
}
if len(opt.Group.Ancestry()) == 0 {
// Just add option to default group.
groups[0].Options = append(groups[0].Options, opt)
continue
}
groupName := opt.Group.FullName()
for i, foundGroup := range groups {
if foundGroup.Name != groupName {
continue
}
groups[i].Options = append(groups[i].Options, opt)
continue optionLoop
}
groups = append(groups, optionGroup{
Name: groupName,
Description: opt.Group.Description,
Options: clibase.OptionSet{opt},
})
}
sort.Slice(groups, func(i, j int) bool {
// Sort groups lexicographically.
return groups[i].Name < groups[j].Name
})
// Always show enterprise group last.
groups = append(groups, enterpriseGroup)
return filterSlice(groups, func(g optionGroup) bool {
return len(g.Options) > 0
})
},
},
).Parse(helpTemplateRaw),
)
func filterSlice[T any](s []T, f func(T) bool) []T {
var r []T
for _, v := range s {
if f(v) {
r = append(r, v)
}
}
return r
}
// newLineLimiter makes working with Go templates more bearable. Without this,
// modifying the template is a slow toil of counting newlines and constantly
// checking that a change to one command's help doesn't clobber break another.
type newlineLimiter struct {
w io.Writer
limit int
newLineCounter int
}
func (lm *newlineLimiter) Write(p []byte) (int, error) {
rd := bytes.NewReader(p)
for r, n, _ := rd.ReadRune(); n > 0; r, n, _ = rd.ReadRune() {
switch {
case r == '\r':
// Carriage returns can sneak into `help.tpl` when `git clone`
// is configured to automatically convert line endings.
continue
case r == '\n':
lm.newLineCounter++
if lm.newLineCounter > lm.limit {
continue
}
case !unicode.IsSpace(r):
lm.newLineCounter = 0
}
_, err := lm.w.Write([]byte(string(r)))
if err != nil {
return 0, err
}
}
return len(p), nil
}
var usageWantsArgRe = regexp.MustCompile(`<.*>`)
// helpFn returns a function that generates usage (help)
// output for a given command.
func helpFn() clibase.HandlerFunc {
return func(inv *clibase.Invocation) error {
// We use stdout for help and not stderr since there's no straightforward
// way to distinguish between a user error and a help request.
//
// We buffer writes to stdout because the newlineLimiter writes one
// rune at a time.
outBuf := bufio.NewWriter(inv.Stdout)
out := newlineLimiter{w: outBuf, limit: 2}
tabwriter := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0)
err := usageTemplate.Execute(tabwriter, inv.Command)
if err != nil {
return xerrors.Errorf("execute template: %w", err)
}
err = tabwriter.Flush()
if err != nil {
return err
}
err = outBuf.Flush()
if err != nil {
return err
}
if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) {
_, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unknown subcommand %q\n", inv.Args[0])
}
return nil
}
}
+55
View File
@@ -0,0 +1,55 @@
{{- /* Heavily inspired by the Go toolchain formatting. */ -}}
Usage: {{.FullUsage}}
{{ with .Short }}
{{- wrapTTY . }}
{{"\n"}}
{{- end}}
{{ with .Aliases }}
{{ "\n" }}
{{ "Aliases:"}} {{ joinStrings .}}
{{ "\n" }}
{{- end }}
{{- with .Long}}
{{- formatLong . }}
{{ "\n" }}
{{- end }}
{{ with visibleChildren . }}
{{- range $index, $child := . }}
{{- if eq $index 0 }}
{{ prettyHeader "Subcommands"}}
{{- end }}
{{- "\n" }}
{{- formatSubcommand . | trimNewline }}
{{- end }}
{{- "\n" }}
{{- end }}
{{- range $index, $group := optionGroups . }}
{{ with $group.Name }} {{- print $group.Name " Options" | prettyHeader }} {{ else -}} {{ prettyHeader "Options"}}{{- end -}}
{{- with $group.Description }}
{{ formatGroupDescription . }}
{{- else }}
{{- end }}
{{- range $index, $option := $group.Options }}
{{- if not (eq $option.FlagShorthand "") }}{{- print "\n -" $option.FlagShorthand ", " -}}
{{- else }}{{- print "\n " -}}
{{- end }}
{{- with flagName $option }}--{{ . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }}
{{- with envName $option }}, ${{ . }}{{ end }}
{{- with $option.Default }} (default: {{ . }}){{ end }}
{{- with $option.Description }}
{{- $desc := $option.Description }}
{{ indent $desc 10 }}
{{- if isDeprecated $option }} DEPRECATED {{ end }}
{{- end -}}
{{- end }}
{{- end }}
---
{{- if .Parent }}
Run `coder --help` for a list of global options.
{{- else }}
Report bugs and request features at https://github.com/coder/coder/issues/new
{{- end }}
+58 -53
View File
@@ -2,26 +2,32 @@ package cli
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/autobuild/schedule"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)
// workspaceListRow is the type provided to the OutputFormatter. This is a bit
// dodgy but it's the only way to do complex display code for one format vs. the
// other.
type workspaceListRow struct {
Workspace string `table:"workspace"`
Template string `table:"template"`
Status string `table:"status"`
LastBuilt string `table:"last built"`
Outdated bool `table:"outdated"`
StartsAt string `table:"starts at"`
StopsAfter string `table:"stops after"`
// For JSON format:
codersdk.Workspace `table:"-"`
// For table format:
WorkspaceName string `json:"-" table:"workspace,default_sort"`
Template string `json:"-" table:"template"`
Status string `json:"-" table:"status"`
LastBuilt string `json:"-" table:"last built"`
Outdated bool `json:"-" table:"outdated"`
StartsAt string `json:"-" table:"starts at"`
StopsAfter string `json:"-" table:"stops after"`
}
func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow {
@@ -47,37 +53,39 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders
user := usersByID[workspace.OwnerID]
return workspaceListRow{
Workspace: user.Username + "/" + workspace.Name,
Template: workspace.TemplateName,
Status: status,
LastBuilt: durationDisplay(lastBuilt),
Outdated: workspace.Outdated,
StartsAt: autostartDisplay,
StopsAfter: autostopDisplay,
Workspace: workspace,
WorkspaceName: user.Username + "/" + workspace.Name,
Template: workspace.TemplateName,
Status: status,
LastBuilt: durationDisplay(lastBuilt),
Outdated: workspace.Outdated,
StartsAt: autostartDisplay,
StopsAfter: autostopDisplay,
}
}
func list() *cobra.Command {
func (r *RootCmd) list() *clibase.Cmd {
var (
all bool
columns []string
defaultQuery = "owner:me"
searchQuery string
me bool
displayWorkspaces []workspaceListRow
formatter = cliui.NewOutputFormatter(
cliui.TableFormat([]workspaceListRow{}, nil),
cliui.JSONFormat(),
)
)
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "list",
Short: "List workspaces",
Aliases: []string{"ls"},
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
if err != nil {
return err
}
Middleware: clibase.Chain(
clibase.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
filter := codersdk.WorkspaceFilter{
FilterQuery: searchQuery,
}
@@ -85,27 +93,19 @@ func list() *cobra.Command {
filter.FilterQuery = ""
}
if me {
myUser, err := client.User(cmd.Context(), codersdk.Me)
if err != nil {
return err
}
filter.Owner = myUser.Username
}
res, err := client.Workspaces(cmd.Context(), filter)
res, err := client.Workspaces(inv.Context(), filter)
if err != nil {
return err
}
if len(res.Workspaces) == 0 {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), " "+cliui.Styles.Code.Render("coder create <name>"))
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
_, _ = fmt.Fprintln(inv.Stderr)
_, _ = fmt.Fprintln(inv.Stderr, " "+cliui.Styles.Code.Render("coder create <name>"))
_, _ = fmt.Fprintln(inv.Stderr)
return nil
}
userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
userRes, err := client.Users(inv.Context(), codersdk.UsersRequest{})
if err != nil {
return err
}
@@ -121,26 +121,31 @@ func list() *cobra.Command {
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
}
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
out, err := formatter.Format(inv.Context(), displayWorkspaces)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
cmd.Options = clibase.OptionSet{
{
Flag: "all",
FlagShorthand: "a",
Description: "Specifies whether all workspaces will be listed or not.",
availColumns, err := cliui.TableHeaders(displayWorkspaces)
if err != nil {
panic(err)
Value: clibase.BoolOf(&all),
},
{
Flag: "search",
Description: "Search for a workspace with a query.",
Default: defaultQuery,
Value: clibase.StringOf(&searchQuery),
},
}
columnString := strings.Join(availColumns[:], ", ")
cmd.Flags().BoolVarP(&all, "all", "a", false,
"Specifies whether all workspaces will be listed or not.")
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
fmt.Sprintf("Specify a column to filter in the table. Available columns are: %v", columnString))
cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.")
formatter.AttachOptions(&cmd.Options)
return cmd
}
+33 -5
View File
@@ -1,13 +1,17 @@
package cli_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
@@ -23,17 +27,15 @@ func TestList(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "ls")
inv, root := clitest.New(t, "ls")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
pty := ptytest.New(t).Attach(inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
done := make(chan any)
go func() {
errC := cmd.ExecuteContext(ctx)
errC := inv.WithContext(ctx).Run()
assert.NoError(t, errC)
close(done)
}()
@@ -42,4 +44,30 @@ func TestList(t *testing.T) {
cancelFunc()
<-done
})
t.Run("JSON", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "list", "--output=json")
clitest.SetupConfig(t, client, root)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var templates []codersdk.Workspace
require.NoError(t, json.Unmarshal(out.Bytes(), &templates))
require.Len(t, templates, 1)
})
}
+76 -55
View File
@@ -14,11 +14,11 @@ import (
"github.com/go-playground/validator/v10"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/codersdk"
)
@@ -37,7 +37,7 @@ func init() {
browser.Stdout = io.Discard
}
func login() *cobra.Command {
func (r *RootCmd) login() *clibase.Cmd {
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
var (
@@ -46,20 +46,16 @@ func login() *cobra.Command {
password string
trial bool
)
cmd := &cobra.Command{
Use: "login <url>",
Short: "Authenticate with Coder deployment",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cmd := &clibase.Cmd{
Use: "login <url>",
Short: "Authenticate with Coder deployment",
Middleware: clibase.RequireRangeArgs(0, 1),
Handler: func(inv *clibase.Invocation) error {
rawURL := ""
if len(args) == 0 {
var err error
rawURL, err = cmd.Flags().GetString(varURL)
if err != nil {
return xerrors.Errorf("get global url flag")
}
if len(inv.Args) == 0 {
rawURL = r.clientURL.String()
} else {
rawURL = args[0]
rawURL = inv.Args[0]
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
@@ -78,7 +74,7 @@ func login() *cobra.Command {
serverURL.Scheme = "https"
}
client, err := createUnauthenticatedClient(cmd, serverURL)
client, err := r.createUnauthenticatedClient(serverURL)
if err != nil {
return err
}
@@ -86,25 +82,25 @@ func login() *cobra.Command {
// Try to check the version of the server prior to logging in.
// It may be useful to warn the user if they are trying to login
// on a very old client.
err = checkVersions(cmd, client)
err = r.checkVersions(inv, client)
if err != nil {
// Checking versions isn't a fatal error so we print a warning
// and proceed.
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Warn.Render(err.Error()))
_, _ = fmt.Fprintln(inv.Stderr, cliui.Styles.Warn.Render(err.Error()))
}
hasInitialUser, err := client.HasFirstUser(cmd.Context())
hasInitialUser, err := client.HasFirstUser(inv.Context())
if err != nil {
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
}
if !hasInitialUser {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Your Coder deployment hasn't been set up!\n")
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
if username == "" {
if !isTTY(cmd) {
if !isTTY(inv) {
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
}
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
_, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Would you like to create the first user?",
Default: cliui.ConfirmYes,
IsConfirm: true,
@@ -119,7 +115,7 @@ func login() *cobra.Command {
if err != nil {
return xerrors.Errorf("get current user: %w", err)
}
username, err = cliui.Prompt(cmd, cliui.PromptOptions{
username, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "What " + cliui.Styles.Field.Render("username") + " would you like?",
Default: currentUser.Username,
})
@@ -132,7 +128,7 @@ func login() *cobra.Command {
}
if email == "" {
email, err = cliui.Prompt(cmd, cliui.PromptOptions{
email, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "What's your " + cliui.Styles.Field.Render("email") + "?",
Validate: func(s string) error {
err := validator.New().Var(s, "email")
@@ -151,17 +147,20 @@ func login() *cobra.Command {
var matching bool
for !matching {
password, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
Secret: true,
Validate: cliui.ValidateNotEmpty,
password, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
Secret: true,
Validate: func(s string) error {
return userpassword.Validate(s)
},
})
if err != nil {
return xerrors.Errorf("specify password prompt: %w", err)
}
confirm, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
Secret: true,
confirm, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
Secret: true,
Validate: cliui.ValidateNotEmpty,
})
if err != nil {
return xerrors.Errorf("confirm password prompt: %w", err)
@@ -169,13 +168,13 @@ func login() *cobra.Command {
matching = confirm == password
if !matching {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Passwords do not match"))
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Error.Render("Passwords do not match"))
}
}
}
if !cmd.Flags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
v, _ := cliui.Prompt(cmd, cliui.PromptOptions{
if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
v, _ := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Start a 30-day trial of Enterprise?",
IsConfirm: true,
Default: "yes",
@@ -183,7 +182,7 @@ func login() *cobra.Command {
trial = v == "yes" || v == "y"
}
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
_, err = client.CreateFirstUser(inv.Context(), codersdk.CreateFirstUserRequest{
Email: email,
Username: username,
Password: password,
@@ -192,7 +191,7 @@ func login() *cobra.Command {
if err != nil {
return xerrors.Errorf("create initial user: %w", err)
}
resp, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
resp, err := client.LoginWithPassword(inv.Context(), codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
@@ -201,7 +200,7 @@ func login() *cobra.Command {
}
sessionToken := resp.SessionToken
config := createConfig(cmd)
config := r.createConfig()
err = config.Session().Write(sessionToken)
if err != nil {
return xerrors.Errorf("write session token: %w", err)
@@ -211,32 +210,32 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
_, _ = fmt.Fprintf(inv.Stdout,
cliui.Styles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.Styles.Keyword.Render(username)))+"\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
_, _ = fmt.Fprintf(inv.Stdout,
cliui.Styles.Paragraph.Render("Get started by creating a template: "+cliui.Styles.Code.Render("coder templates init"))+"\n")
return nil
}
sessionToken, _ := cmd.Flags().GetString(varToken)
sessionToken, _ := inv.ParsedFlags().GetString(varToken)
if sessionToken == "" {
authURL := *serverURL
// Don't use filepath.Join, we don't want to use the os separator
// for a url.
authURL.Path = path.Join(serverURL.Path, "/cli-auth")
if err := openURL(cmd, authURL.String()); err != nil {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
if err := openURL(inv, authURL.String()); err != nil {
_, _ = fmt.Fprintf(inv.Stdout, "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
_, _ = fmt.Fprintf(inv.Stdout, "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
}
sessionToken, err = cliui.Prompt(cmd, cliui.PromptOptions{
sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Paste your token here:",
Secret: true,
Validate: func(token string) error {
client.SetSessionToken(token)
_, err := client.User(cmd.Context(), codersdk.Me)
_, err := client.User(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.New("That's not a valid token!")
}
@@ -250,12 +249,12 @@ func login() *cobra.Command {
// Login to get user data - verify it is OK before persisting
client.SetSessionToken(sessionToken)
resp, err := client.User(cmd.Context(), codersdk.Me)
resp, err := client.User(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("get user: %w", err)
}
config := createConfig(cmd)
config := r.createConfig()
err = config.Session().Write(sessionToken)
if err != nil {
return xerrors.Errorf("write session token: %w", err)
@@ -265,14 +264,36 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
return nil
},
}
cliflag.StringVarP(cmd.Flags(), &email, "first-user-email", "", "CODER_FIRST_USER_EMAIL", "", "Specifies an email address to use if creating the first user for the deployment.")
cliflag.StringVarP(cmd.Flags(), &username, "first-user-username", "", "CODER_FIRST_USER_USERNAME", "", "Specifies a username to use if creating the first user for the deployment.")
cliflag.StringVarP(cmd.Flags(), &password, "first-user-password", "", "CODER_FIRST_USER_PASSWORD", "", "Specifies a password to use if creating the first user for the deployment.")
cliflag.BoolVarP(cmd.Flags(), &trial, "first-user-trial", "", firstUserTrialEnv, false, "Specifies whether a trial license should be provisioned for the Coder deployment or not.")
cmd.Options = clibase.OptionSet{
{
Flag: "first-user-email",
Env: "CODER_FIRST_USER_EMAIL",
Description: "Specifies an email address to use if creating the first user for the deployment.",
Value: clibase.StringOf(&email),
},
{
Flag: "first-user-username",
Env: "CODER_FIRST_USER_USERNAME",
Description: "Specifies a username to use if creating the first user for the deployment.",
Value: clibase.StringOf(&username),
},
{
Flag: "first-user-password",
Env: "CODER_FIRST_USER_PASSWORD",
Description: "Specifies a password to use if creating the first user for the deployment.",
Value: clibase.StringOf(&password),
},
{
Flag: "first-user-trial",
Env: firstUserTrialEnv,
Description: "Specifies whether a trial license should be provisioned for the Coder deployment or not.",
Value: clibase.BoolOf(&trial),
},
}
return cmd
}
@@ -289,8 +310,8 @@ func isWSL() (bool, error) {
}
// openURL opens the provided URL via user's default browser
func openURL(cmd *cobra.Command, urlToOpen string) error {
noOpen, err := cmd.Flags().GetBool(varNoOpen)
func openURL(inv *clibase.Invocation, urlToOpen string) error {
noOpen, err := inv.ParsedFlags().GetBool(varNoOpen)
if err != nil {
panic(err)
}
@@ -310,7 +331,7 @@ func openURL(cmd *cobra.Command, urlToOpen string) error {
browserEnv := os.Getenv("BROWSER")
if browserEnv != "" {
browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen)
cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh)
cmd := exec.CommandContext(inv.Context(), "sh", "-c", browserSh)
out, err := cmd.CombinedOutput()
if err != nil {
return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err)
+26 -43
View File
@@ -20,7 +20,7 @@ func TestLogin(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
root, _ := clitest.New(t, "login", client.URL.String())
err := root.Execute()
err := root.Run()
require.Error(t, err)
})
@@ -28,7 +28,7 @@ func TestLogin(t *testing.T) {
t.Parallel()
badLoginURL := "https://fcca2077f06e68aaf9"
root, _ := clitest.New(t, "login", badLoginURL)
err := root.Execute()
err := root.Run()
errMsg := fmt.Sprintf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser?", badLoginURL)
require.ErrorContains(t, err, errMsg)
})
@@ -41,12 +41,10 @@ func TestLogin(t *testing.T) {
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.Execute()
err := root.Run()
assert.NoError(t, err)
}()
@@ -54,8 +52,8 @@ func TestLogin(t *testing.T) {
"first user?", "yes",
"username", "testuser",
"email", "user@coder.com",
"password", "password",
"password", "password", // Confirm.
"password", "SomeSecurePassword!",
"password", "SomeSecurePassword!", // Confirm.
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
@@ -74,23 +72,17 @@ func TestLogin(t *testing.T) {
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := root.Execute()
assert.NoError(t, err)
}()
inv, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
pty := ptytest.New(t).Attach(inv)
clitest.Start(t, inv)
matches := []string{
"first user?", "yes",
"username", "testuser",
"email", "user@coder.com",
"password", "password",
"password", "password", // Confirm.
"password", "SomeSecurePassword!",
"password", "SomeSecurePassword!", // Confirm.
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
@@ -100,20 +92,17 @@ func TestLogin(t *testing.T) {
pty.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})
t.Run("InitialUserFlags", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "SomeSecurePassword!", "--first-user-trial")
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.Execute()
err := root.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch("Welcome to Coder")
@@ -130,12 +119,10 @@ func TestLogin(t *testing.T) {
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.ExecuteContext(ctx)
err := root.WithContext(ctx).Run()
assert.NoError(t, err)
}()
@@ -143,8 +130,8 @@ func TestLogin(t *testing.T) {
"first user?", "yes",
"username", "testuser",
"email", "user@coder.com",
"password", "mypass",
"password", "wrongpass", // Confirm.
"password", "MyFirstSecurePassword!",
"password", "MyNonMatchingSecurePassword!", // Confirm.
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
@@ -157,9 +144,9 @@ func TestLogin(t *testing.T) {
pty.ExpectMatch("Passwords do not match")
pty.ExpectMatch("Enter a " + cliui.Styles.Field.Render("password"))
pty.WriteLine("pass")
pty.WriteLine("SomeSecurePassword!")
pty.ExpectMatch("Confirm")
pty.WriteLine("pass")
pty.WriteLine("SomeSecurePassword!")
pty.ExpectMatch("trial")
pty.WriteLine("yes")
pty.ExpectMatch("Welcome to Coder")
@@ -173,12 +160,10 @@ func TestLogin(t *testing.T) {
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.Execute()
err := root.Run()
assert.NoError(t, err)
}()
@@ -197,12 +182,10 @@ func TestLogin(t *testing.T) {
defer cancelFunc()
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
pty := ptytest.New(t).Attach(root)
go func() {
defer close(doneChan)
err := root.ExecuteContext(ctx)
err := root.WithContext(ctx).Run()
// An error is expected in this case, since the login wasn't successful:
assert.Error(t, err)
}()
@@ -219,7 +202,7 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
err := root.Execute()
err := root.Run()
require.NoError(t, err)
sessionFile, err := cfg.Session().Read()
require.NoError(t, err)
+15 -15
View File
@@ -5,27 +5,28 @@ import (
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func logout() *cobra.Command {
cmd := &cobra.Command{
func (r *RootCmd) logout() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "logout",
Short: "Unauthenticate your local session",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
if err != nil {
return err
}
Middleware: clibase.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
var errors []error
config := createConfig(cmd)
config := r.createConfig()
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
var err error
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Are you sure you want to log out?",
IsConfirm: true,
Default: cliui.ConfirmYes,
@@ -34,7 +35,7 @@ func logout() *cobra.Command {
return err
}
err = client.Logout(cmd.Context())
err = client.Logout(inv.Context())
if err != nil {
errors = append(errors, xerrors.Errorf("logout api: %w", err))
}
@@ -67,11 +68,10 @@ func logout() *cobra.Command {
errorString := strings.TrimRight(errorStringBuilder.String(), "\n")
return xerrors.New("Failed to log out.\n" + errorString)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
_, _ = fmt.Fprintf(inv.Stdout, Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
return nil
},
}
cliui.AllowSkipPrompt(cmd)
cmd.Options = append(cmd.Options, cliui.SkipPromptOption())
return cmd
}
+33 -37
View File
@@ -1,9 +1,7 @@
package cli_test
import (
"fmt"
"os"
"regexp"
"runtime"
"testing"
@@ -30,12 +28,12 @@ func TestLogout(t *testing.T) {
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
logout.SetIn(pty.Input())
logout.SetOut(pty.Output())
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err := logout.Execute()
err := logout.Run()
assert.NoError(t, err)
assert.NoFileExists(t, string(config.URL()))
assert.NoFileExists(t, string(config.Session()))
@@ -58,12 +56,12 @@ func TestLogout(t *testing.T) {
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config), "-y")
logout.SetIn(pty.Input())
logout.SetOut(pty.Output())
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err := logout.Execute()
err := logout.Run()
assert.NoError(t, err)
assert.NoFileExists(t, string(config.URL()))
assert.NoFileExists(t, string(config.Session()))
@@ -88,13 +86,13 @@ func TestLogout(t *testing.T) {
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
logout.SetIn(pty.Input())
logout.SetOut(pty.Output())
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err := logout.Execute()
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
err := logout.Run()
assert.ErrorContains(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
}()
<-logoutChan
@@ -115,13 +113,13 @@ func TestLogout(t *testing.T) {
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
logout.SetIn(pty.Input())
logout.SetOut(pty.Output())
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err = logout.Execute()
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
err = logout.Run()
assert.ErrorContains(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
}()
<-logoutChan
@@ -149,7 +147,7 @@ func TestLogout(t *testing.T) {
require.NoError(t, err)
} else {
// Changing the permissions to throw error during deletion.
err = os.Chmod(string(config), 0500)
err = os.Chmod(string(config), 0o500)
require.NoError(t, err)
}
defer func() {
@@ -166,29 +164,27 @@ func TestLogout(t *testing.T) {
}
}()
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
logout.SetIn(pty.Input())
logout.SetOut(pty.Output())
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err := logout.Execute()
assert.NotNil(t, err)
var errorMessage string
if runtime.GOOS == "windows" {
errorMessage = "The process cannot access the file because it is being used by another process."
} else {
errorMessage = "permission denied"
}
errRegex := regexp.MustCompile(fmt.Sprintf("Failed to log out.\n\tremove URL file: .+: %s\n\tremove session file: .+: %s", errorMessage, errorMessage))
assert.Regexp(t, errRegex, err.Error())
pty.ExpectMatch("Are you sure you want to log out?")
pty.WriteLine("yes")
}()
err = logout.Run()
require.Error(t, err)
pty.ExpectMatch("Are you sure you want to log out?")
pty.WriteLine("yes")
<-logoutChan
t.Logf("err: %v", err)
var wantError string
if runtime.GOOS == "windows" {
wantError = "The process cannot access the file because it is being used by another process."
} else {
wantError = "permission denied"
}
require.ErrorContains(t, err, wantError)
})
}
@@ -200,11 +196,11 @@ func login(t *testing.T, pty *ptytest.PTY) config.Root {
doneChan := make(chan struct{})
root, cfg := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
root.SetIn(pty.Input())
root.SetOut(pty.Output())
root.Stdin = pty.Input()
root.Stdout = pty.Output()
go func() {
defer close(doneChan)
err := root.Execute()
err := root.Run()
assert.NoError(t, err)
}()
+26 -12
View File
@@ -1,12 +1,14 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
@@ -15,20 +17,32 @@ import (
// Throws an error if the file name is empty.
func createParameterMapFromFile(parameterFile string) (map[string]string, error) {
if parameterFile != "" {
parameterMap := make(map[string]string)
parameterFileContents, err := os.ReadFile(parameterFile)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(parameterFileContents, &parameterMap)
mapStringInterface := make(map[string]interface{})
err = yaml.Unmarshal(parameterFileContents, &mapStringInterface)
if err != nil {
return nil, err
}
parameterMap := map[string]string{}
for k, v := range mapStringInterface {
switch val := v.(type) {
case string, bool, int:
parameterMap[k] = fmt.Sprintf("%v", val)
case []interface{}:
b, err := json.Marshal(&val)
if err != nil {
return nil, err
}
parameterMap[k] = string(b)
default:
return nil, xerrors.Errorf("invalid parameter type: %T", v)
}
}
return parameterMap, nil
}
@@ -37,20 +51,20 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error)
// Returns a parameter value from a given map, if the map does not exist or does not contain the item, it takes input from the user.
// Throws an error if there are any errors with the users input.
func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
func getParameterValueFromMapOrInput(inv *clibase.Invocation, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
var parameterValue string
var err error
if parameterMap != nil {
var ok bool
parameterValue, ok = parameterMap[parameterSchema.Name]
if !ok {
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
parameterValue, err = cliui.ParameterSchema(inv, parameterSchema)
if err != nil {
return "", err
}
}
} else {
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
parameterValue, err = cliui.ParameterSchema(inv, parameterSchema)
if err != nil {
return "", err
}
@@ -58,20 +72,20 @@ func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string
return parameterValue, nil
}
func getWorkspaceBuildParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, templateVersionParameter codersdk.TemplateVersionParameter) (*codersdk.WorkspaceBuildParameter, error) {
func getWorkspaceBuildParameterValueFromMapOrInput(inv *clibase.Invocation, parameterMap map[string]string, templateVersionParameter codersdk.TemplateVersionParameter) (*codersdk.WorkspaceBuildParameter, error) {
var parameterValue string
var err error
if parameterMap != nil {
var ok bool
parameterValue, ok = parameterMap[templateVersionParameter.Name]
if !ok {
parameterValue, err = cliui.RichParameter(cmd, templateVersionParameter)
parameterValue, err = cliui.RichParameter(inv, templateVersionParameter)
if err != nil {
return nil, err
}
}
} else {
parameterValue, err = cliui.RichParameter(cmd, templateVersionParameter)
parameterValue, err = cliui.RichParameter(inv, templateVersionParameter)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestCreateParameterMapFromFile(t *testing.T) {
parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name())
assert.Nil(t, parameterMapFromFile)
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string")
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}")
removeTmpDirUntilSuccess(t, tempDir)
})
+6 -9
View File
@@ -1,13 +1,13 @@
package cli
import (
"github.com/spf13/cobra"
"github.com/coder/coder/cli/clibase"
)
func parameters() *cobra.Command {
cmd := &cobra.Command{
func (r *RootCmd) parameters() *clibase.Cmd {
cmd := &clibase.Cmd{
Short: "List parameters for a given scope",
Example: formatExamples(
Long: formatExamples(
example{
Command: "coder parameters list workspace my-workspace",
},
@@ -20,12 +20,9 @@ func parameters() *cobra.Command {
// constructing curl requests.
Hidden: true,
Aliases: []string{"params"},
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
Children: []*clibase.Cmd{
r.parameterList(),
},
}
cmd.AddCommand(
parameterList(),
)
return cmd
}
+25 -23
View File
@@ -4,30 +4,32 @@ import (
"fmt"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func parameterList() *cobra.Command {
var (
columns []string
func (r *RootCmd) parameterList() *clibase.Cmd {
formatter := cliui.NewOutputFormatter(
cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}),
cliui.JSONFormat(),
)
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "list",
Aliases: []string{"ls"},
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
scope, name := args[0], args[1]
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
scope, name := inv.Args[0], inv.Args[1]
client, err := CreateClient(cmd)
if err != nil {
return err
}
organization, err := CurrentOrganization(cmd, client)
organization, err := CurrentOrganization(inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
@@ -35,13 +37,13 @@ func parameterList() *cobra.Command {
var scopeID uuid.UUID
switch codersdk.ParameterScope(scope) {
case codersdk.ParameterWorkspace:
workspace, err := namedWorkspace(cmd, client, name)
workspace, err := namedWorkspace(inv.Context(), client, name)
if err != nil {
return err
}
scopeID = workspace.ID
case codersdk.ParameterTemplate:
template, err := client.TemplateByName(cmd.Context(), organization.ID, name)
template, err := client.TemplateByName(inv.Context(), organization.ID, name)
if err != nil {
return xerrors.Errorf("get workspace template: %w", err)
}
@@ -55,7 +57,7 @@ func parameterList() *cobra.Command {
// Could be a template_version id or a job id. Check for the
// version id.
tv, err := client.TemplateVersion(cmd.Context(), scopeID)
tv, err := client.TemplateVersion(inv.Context(), scopeID)
if err == nil {
scopeID = tv.Job.ID
}
@@ -66,21 +68,21 @@ func parameterList() *cobra.Command {
})
}
params, err := client.Parameters(cmd.Context(), codersdk.ParameterScope(scope), scopeID)
params, err := client.Parameters(inv.Context(), codersdk.ParameterScope(scope), scopeID)
if err != nil {
return xerrors.Errorf("fetch params: %w", err)
}
out, err := cliui.DisplayTable(params, "name", columns)
out, err := formatter.Format(inv.Context(), params)
if err != nil {
return xerrors.Errorf("render table: %w", err)
return xerrors.Errorf("render output: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
"Specify a column to filter in the table.")
formatter.AttachOptions(&cmd.Options)
return cmd
}
+158
View File
@@ -0,0 +1,158 @@
package cli
import (
"context"
"fmt"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func (r *RootCmd) ping() *clibase.Cmd {
var (
pingNum int64
pingTimeout time.Duration
pingWait time.Duration
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "ping <workspace>",
Short: "Ping a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
workspaceName := inv.Args[0]
_, workspaceAgent, err := getWorkspaceAndAgent(
ctx, inv, client,
codersdk.Me, workspaceName,
)
if err != nil {
return err
}
var logger slog.Logger
if r.verbose {
logger = slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
}
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{Logger: logger})
if err != nil {
return err
}
defer conn.Close()
derpMap := conn.DERPMap()
_ = derpMap
n := 0
didP2p := false
start := time.Now()
for {
if n > 0 {
time.Sleep(time.Second)
}
n++
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
dur, p2p, pong, err := conn.Ping(ctx)
cancel()
if err != nil {
if xerrors.Is(err, context.DeadlineExceeded) {
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q timed out \n", workspaceName)
if n == int(pingNum) {
return nil
}
continue
}
if xerrors.Is(err, context.Canceled) {
return nil
}
if err.Error() == "no matching peer" {
continue
}
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q failed %s\n", workspaceName, err.Error())
if n == int(pingNum) {
return nil
}
continue
}
dur = dur.Round(time.Millisecond)
var via string
if p2p {
if !didP2p {
_, _ = fmt.Fprintln(inv.Stdout, "p2p connection established in",
cliui.Styles.DateTimeStamp.Render(time.Since(start).Round(time.Millisecond).String()),
)
}
didP2p = true
via = fmt.Sprintf("%s via %s",
cliui.Styles.Fuchsia.Render("p2p"),
cliui.Styles.Code.Render(pong.Endpoint),
)
} else {
derpName := "unknown"
derpRegion, ok := derpMap.Regions[pong.DERPRegionID]
if ok {
derpName = derpRegion.RegionName
}
via = fmt.Sprintf("%s via %s",
cliui.Styles.Fuchsia.Render("proxied"),
cliui.Styles.Code.Render(fmt.Sprintf("DERP(%s)", derpName)),
)
}
_, _ = fmt.Fprintf(inv.Stdout, "pong from %s %s in %s\n",
cliui.Styles.Keyword.Render(workspaceName),
via,
cliui.Styles.DateTimeStamp.Render(dur.String()),
)
if n == int(pingNum) {
return nil
}
}
},
}
cmd.Options = clibase.OptionSet{
{
Flag: "wait",
Description: "Specifies how long to wait between pings.",
Default: "1s",
Value: clibase.DurationOf(&pingWait),
},
{
Flag: "timeout",
FlagShorthand: "t",
Default: "5s",
Description: "Specifies how long to wait for a ping to complete.",
Value: clibase.DurationOf(&pingTimeout),
},
{
Flag: "num",
FlagShorthand: "n",
Default: "10",
Description: "Specifies the number of pings to perform.",
Value: clibase.Int64Of(&pingNum),
},
}
return cmd
}
+54
View File
@@ -0,0 +1,54 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func TestPing(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
inv, root := clitest.New(t, "ping", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stderr = pty.Output()
inv.Stdout = pty.Output()
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
agentCloser := agent.New(agent.Options{
Client: agentClient,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer func() {
_ = agentCloser.Close()
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
pty.ExpectMatch("pong from " + workspace.Name)
cancel()
<-cmdDone
})
}
+39 -27
View File
@@ -12,26 +12,25 @@ import (
"syscall"
"github.com/pion/udp"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/agent"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func portForward() *cobra.Command {
func (r *RootCmd) portForward() *clibase.Cmd {
var (
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
)
cmd := &cobra.Command{
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "port-forward <workspace>",
Short: "Forward ports from machine to a workspace",
Aliases: []string{"tunnel"},
Args: cobra.ExactArgs(1),
Example: formatExamples(
Long: formatExamples(
example{
Description: "Port forward a single TCP port from 1234 in the workspace to port 5678 on your local machine",
Command: "coder port-forward <workspace> --tcp 5678:1234",
@@ -49,8 +48,12 @@ func portForward() *cobra.Command {
Command: "coder port-forward <workspace> --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012",
},
),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
specs, err := parsePortForwards(tcpForwards, udpForwards)
@@ -58,19 +61,14 @@ func portForward() *cobra.Command {
return xerrors.Errorf("parse port-forward specs: %w", err)
}
if len(specs) == 0 {
err = cmd.Help()
err = inv.Command.HelpHandler(inv)
if err != nil {
return xerrors.Errorf("generate help output: %w", err)
}
return xerrors.New("no port-forwards requested")
}
client, err := CreateClient(cmd)
if err != nil {
return err
}
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0])
if err != nil {
return err
}
@@ -78,13 +76,13 @@ func portForward() *cobra.Command {
return xerrors.New("workspace must be in start transition to port-forward")
}
if workspace.LatestBuild.Job.CompletedAt == nil {
err = cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID)
err = cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID)
if err != nil {
return err
}
}
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
err = cliui.Agent(ctx, inv.Stderr, cliui.AgentOptions{
WorkspaceName: workspace.Name,
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
@@ -116,7 +114,7 @@ func portForward() *cobra.Command {
defer closeAllListeners()
for i, spec := range specs {
l, err := listenAndPortForward(ctx, cmd, conn, wg, spec)
l, err := listenAndPortForward(ctx, inv, conn, wg, spec)
if err != nil {
return err
}
@@ -137,7 +135,7 @@ func portForward() *cobra.Command {
case <-ctx.Done():
closeErr = ctx.Err()
case <-sigs:
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "\nReceived signal, closing all listeners and active connections")
_, _ = fmt.Fprintln(inv.Stderr, "\nReceived signal, closing all listeners and active connections")
}
cancel()
@@ -145,19 +143,33 @@ func portForward() *cobra.Command {
}()
conn.AwaitReachable(ctx)
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
_, _ = fmt.Fprintln(inv.Stderr, "Ready!")
wg.Wait()
return closeErr
},
}
cliflag.StringArrayVarP(cmd.Flags(), &tcpForwards, "tcp", "p", "CODER_PORT_FORWARD_TCP", nil, "Forward TCP port(s) from the workspace to the local machine")
cliflag.StringArrayVarP(cmd.Flags(), &udpForwards, "udp", "", "CODER_PORT_FORWARD_UDP", nil, "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
cmd.Options = clibase.OptionSet{
{
Flag: "tcp",
FlagShorthand: "p",
Env: "CODER_PORT_FORWARD_TCP",
Description: "Forward TCP port(s) from the workspace to the local machine.",
Value: clibase.StringArrayOf(&tcpForwards),
},
{
Flag: "udp",
Env: "CODER_PORT_FORWARD_UDP",
Description: "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols.",
Value: clibase.StringArrayOf(&udpForwards),
},
}
return cmd
}
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.AgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
func listenAndPortForward(ctx context.Context, inv *clibase.Invocation, conn *codersdk.WorkspaceAgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
_, _ = fmt.Fprintf(inv.Stderr, "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
var (
l net.Listener
@@ -200,8 +212,8 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd
if xerrors.Is(err, net.ErrClosed) {
return
}
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener")
_, _ = fmt.Fprintf(inv.Stderr, "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
_, _ = fmt.Fprintln(inv.Stderr, "Killing listener")
return
}
@@ -209,7 +221,7 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd
defer netConn.Close()
remoteConn, err := conn.DialContext(ctx, spec.dialNetwork, spec.dialAddress)
if err != nil {
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err)
_, _ = fmt.Fprintf(inv.Stderr, "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err)
return
}
defer remoteConn.Close()
+26 -45
View File
@@ -17,7 +17,6 @@ import (
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
@@ -32,14 +31,12 @@ func TestPortForward(t *testing.T) {
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "port-forward", "blah")
inv, root := clitest.New(t, "port-forward", "blah")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
err := cmd.Execute()
err := inv.Run()
require.Error(t, err)
require.ErrorContains(t, err, "no port-forwards")
@@ -134,17 +131,17 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listener.
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("Ready!")
@@ -182,17 +179,17 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listeners.
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("Ready!")
@@ -239,17 +236,15 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listeners.
cmd, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("Ready!")
@@ -293,23 +288,9 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
// Setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
Agents: []*proto.Agent{{
Auth: &proto.Agent_Token{
Token: agentToken,
},
}},
}},
},
},
}},
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
})
// Create template and workspace
@@ -319,12 +300,12 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Start workspace agent in a goroutine
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
errC := make(chan error)
agentCtx, agentCancel := context.WithCancel(ctx)
t.Cleanup(func() {
@@ -333,7 +314,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.
require.NoError(t, err)
})
go func() {
errC <- cmd.ExecuteContext(agentCtx)
errC <- inv.WithContext(agentCtx).Run()
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
+30 -31
View File
@@ -3,32 +3,26 @@ package cli
import (
"strings"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func publickey() *cobra.Command {
var (
reset bool
)
cmd := &cobra.Command{
Use: "publickey",
Aliases: []string{"pubkey"},
Short: "Output your Coder public key used for Git operations",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create codersdk client: %w", err)
}
func (r *RootCmd) publickey() *clibase.Cmd {
var reset bool
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "publickey",
Aliases: []string{"pubkey"},
Short: "Output your Coder public key used for Git operations",
Middleware: r.InitClient(client),
Handler: func(inv *clibase.Invocation) error {
if reset {
// Confirm prompt if using --reset. We don't want to accidentally
// reset our public key.
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
_, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm regenerate a new sshkey for your workspaces? This will require updating the key " +
"on any services it is registered with. This action cannot be reverted.",
IsConfirm: true,
@@ -38,33 +32,38 @@ func publickey() *cobra.Command {
}
// Reset the public key, let the retrieve re-read it.
_, err = client.RegenerateGitSSHKey(cmd.Context(), codersdk.Me)
_, err = client.RegenerateGitSSHKey(inv.Context(), codersdk.Me)
if err != nil {
return err
}
}
key, err := client.GitSSHKey(cmd.Context(), codersdk.Me)
key, err := client.GitSSHKey(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("create codersdk client: %w", err)
}
cmd.Println(cliui.Styles.Wrap.Render(
"This is your public key for using " + cliui.Styles.Field.Render("git") + " in " +
"Coder. All clones with SSH will be authenticated automatically 🪄.",
))
cmd.Println()
cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey)))
cmd.Println()
cmd.Println("Add to GitHub and GitLab:")
cmd.Println(cliui.Styles.Prompt.String() + "https://github.com/settings/ssh/new")
cmd.Println(cliui.Styles.Prompt.String() + "https://gitlab.com/-/profile/keys")
cliui.Infof(inv.Stdout,
"This is your public key for using "+cliui.Styles.Field.Render("git")+" in "+
"Coder. All clones with SSH will be authenticated automatically 🪄.\n\n",
)
cliui.Infof(inv.Stdout, cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n\n")
cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:"+"\n")
cliui.Infof(inv.Stdout, cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new"+"\n")
cliui.Infof(inv.Stdout, cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys"+"\n")
return nil
},
}
cmd.Flags().BoolVar(&reset, "reset", false, "Regenerate your public key. This will require updating the key on any services it's registered with.")
cliui.AllowSkipPrompt(cmd)
cmd.Options = clibase.OptionSet{
{
Flag: "reset",
Description: "Regenerate your public key. This will require updating the key on any services it's registered with.",
Value: clibase.BoolOf(&reset),
},
cliui.SkipPromptOption(),
}
return cmd
}
+3 -3
View File
@@ -16,11 +16,11 @@ func TestPublicKey(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "publickey")
inv, root := clitest.New(t, "publickey")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
err := cmd.Execute()
inv.Stdout = buf
err := inv.Run()
require.NoError(t, err)
publicKey := buf.String()
require.NotEmpty(t, publicKey)

Some files were not shown because too many files have changed in this diff Show More