Compare commits

...

194 Commits

Author SHA1 Message Date
Stephen Kirby a11b169029 v2.10.2 changelog 2024-04-22 20:27:37 +00:00
Kyle Carberry 2a98123701 chore: add generate script for azure instance identity (#13028)
* chore: add generate script for azure instance identity

This also adds new issuing certificates from:
https://learn.microsoft.com/en-us/azure/security/fundamentals/azure-ca-details?tabs=certificate-authority-chains

* Fix shell lint

* Fix shell fmt

* Fix RSA issuing certificate
2024-04-22 19:43:21 +00:00
Stephen Kirby 2ed7226e85 added mainline disclaimer 2024-04-17 22:38:41 +00:00
Stephen Kirby 2101dbce03 updated version flags in kube install 2024-04-17 22:35:02 +00:00
Stephen Kirby cdeba67944 v2.10.1 changelog 2024-04-17 22:33:20 +00:00
Dean Sheather bda13a2818 fix: make terminal raw in ssh command on windows (#12990)
(cherry picked from commit d426569d4a)
2024-04-17 21:01:58 +00:00
Spike Curtis 353888a5d8 feat: add src_id and dst_id indexes to tailnet_tunnels (#12911)
Fixes #12780

Adds indexes to the `tailnet_tunnels` table to speed up `GetTailnetTunnelPeerIDs` and `GetTailnetTunnelPeerBindings` queries, which match on `src_id` and `dst_id`.

(cherry picked from commit a231b5aef5)
2024-04-17 21:01:39 +00:00
Spike Curtis 3fc6111994 fix: stop sending DeleteTailnetPeer when coordinator is unhealthy (#12925)
fixes #12923

Prevents Coordinate peer connections from generating spurious database queries like DeleteTailnetPeer when the coordinator is unhealthy.

It does this by checking the health of the querier before accepting a connection, rather than unconditionally accepting it only for it to get swatted down later.

(cherry picked from commit 06eae954c9)
2024-04-17 21:01:24 +00:00
Colin Adler 3eb9abcbd3 fix(coderd): prevent agent reverse proxy from using HTTP[S]_PROXY envs (#12875)
Updates https://github.com/coder/coder/issues/12790

(cherry picked from commit a2b28f80d7)
2024-04-17 21:01:12 +00:00
Stephen Kirby a7234f61a1 chore: update change log to v2.10.0 and install docs for release channels (#12863)
* 2.10.0 changelog

* updated install docs for mainline/stable releases

* make fmt

* cpp icon -> C++

* added disclaimer on MAX_TTL, support bundle info

* 'release schedule'

* lowercase mainline

* Agent OOM protection info

* minor tweak
2024-04-03 16:43:49 -05:00
Stephen Kirby bf19e3469f added 'JFrog' in front of XRay in guide (#12860) 2024-04-03 14:44:57 -05:00
Stephen Kirby d9211b6693 chore(docs): replace FAQ twisties with h3s (#12859)
* replace FAQ twisties with h3s

* make fmt
2024-04-03 14:44:28 -05:00
Colin Adler cb6fea61df chore: upgrade go to 1.21.9 (#12861) 2024-04-03 13:20:26 -05:00
Steven Masley a3187dc30f chore: enforce unique linked_ids (#12815)
* chore: enforce unique linked_ids

Duplicate linked_ids make no sense. 2 users cannot share the same
source user from a provider
2024-04-03 13:17:11 -05:00
Mathias Fredriksson 65f8d18ce5 feat(install.sh): add support for --mainline (default) and --stable (#12858)
Fixes #12461
2024-04-03 20:26:48 +03:00
Jon Ayers 426e9f2b96 feat: support adjusting child proc oom scores (#12655) 2024-04-03 09:42:03 -05:00
Marcin Tojek ac8d1c6696 docs: describe single region and multi-region deployments (#12779) 2024-04-03 12:45:01 +02:00
Kayla Washburn-Love caa49ea6a1 chore: stabilize light theme (#12855) 2024-04-02 16:06:31 -06:00
Colin Adler 41914256b3 chore: update terraform version in install.sh (#12856) 2024-04-02 16:53:36 -05:00
Kayla Washburn-Love 1dd840d149 test: add an e2e test for removing a group (#12844) 2024-04-02 11:29:43 -06:00
Kayla Washburn-Love f705f9a5eb test: ensure RequireActiveVersion is actually set when testing with AGPL store (#12843) 2024-04-02 11:29:22 -06:00
Kyle Carberry 7698cfda72 chore: remove unnecessary extraction library (#12847)
This was allocating ~256KB on init.
2024-04-02 11:19:54 -04:00
Steven Masley b5b5c37d03 docs: describe mutually exclusive create workspace template fields (#12834)
* docs: describe mutually exclusive create workspace template fields

Ideally we could do this in the OpenAPI spec, but there is no first
class "mutually exclusive" feature in OpenAPI. So in lieu of something
more complex, or changing our struct/validation, a description comment
should suffice.

* chore: Add description to code sample as well
2024-04-02 10:11:24 -05:00
Steven Masley 5137433123 chore: add validation errors to the cli output (#12814)
* chore: add validation errors to the cli output
2024-04-02 10:02:30 -05:00
Kyle Carberry 94e82f9662 chore: use fork of chroma to remove unused inits (#12842)
* chore: use fork of chroma to remove unused inits

This seems fine to do since compilation errors would occur
if it were actually in use.

Everything seems fine here.

* Update validator
2024-04-02 14:14:38 +00:00
Danny Kopping 79fb8e43c5 feat: expose workspace statuses (with details) as a prometheus metric (#12762)
Implements #12462
2024-04-02 09:57:36 +02:00
Toshiki Shimomura 114830de26 Fix coder-logstream-kube typo in deployment-logs.md (#12845) 2024-04-02 03:36:35 +00:00
Kyle Carberry f5a70500d2 chore: update tailscale for to lazily load hostinfo (#12840)
Speeds up `init()` by ~10ms
2024-04-01 17:04:39 -04:00
dependabot[bot] b47fb41783 chore: bump github.com/bramvdbogaerde/go-scp from 1.3.0 to 1.4.0 (#12825)
Bumps [github.com/bramvdbogaerde/go-scp](https://github.com/bramvdbogaerde/go-scp) from 1.3.0 to 1.4.0.
- [Release notes](https://github.com/bramvdbogaerde/go-scp/releases)
- [Commits](https://github.com/bramvdbogaerde/go-scp/compare/v1.3.0...v1.4.0)

---
updated-dependencies:
- dependency-name: github.com/bramvdbogaerde/go-scp
  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>
2024-04-01 20:48:52 +00:00
Kyle Carberry 2a30194ed7 chore: use validator fork to fix 10ms of init time (#12837)
@ammario inspired me
2024-04-01 16:31:26 -04:00
Kyle Carberry d428c05694 chore: move log output message before logs begin streaming (#12836) 2024-04-01 20:02:50 +00:00
Bruno Quaresma 7c1d10b952 chore(site): upgrade storybook to v8 (#12831) 2024-04-01 16:12:17 -03:00
Spike Curtis 3addf7ac5d fix: use latest coder/tailscale
Bad merge in #12252 clobbered #12574
2024-04-01 22:42:38 +04:00
dependabot[bot] 12ecc6554c chore: bump google.golang.org/api from 0.171.0 to 0.172.0 (#12827)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.171.0 to 0.172.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.171.0...v0.172.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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>
2024-04-01 18:14:17 +00:00
dependabot[bot] a4be2831d6 chore: bump github.com/charmbracelet/glamour from 0.6.0 to 0.7.0 (#12824)
Bumps [github.com/charmbracelet/glamour](https://github.com/charmbracelet/glamour) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/charmbracelet/glamour/releases)
- [Commits](https://github.com/charmbracelet/glamour/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/glamour
  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>
2024-04-01 18:13:51 +00:00
dependabot[bot] 3e28250849 chore: bump github.com/cenkalti/backoff/v4 from 4.2.1 to 4.3.0 (#12826)
Bumps [github.com/cenkalti/backoff/v4](https://github.com/cenkalti/backoff) from 4.2.1 to 4.3.0.
- [Commits](https://github.com/cenkalti/backoff/compare/v4.2.1...v4.3.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>
2024-04-01 13:02:23 -05:00
dependabot[bot] b7f5456e35 ci: bump the github-actions group with 1 update (#12828)
Bumps the github-actions group with 1 update: [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action).


Updates `aquasecurity/trivy-action` from 0.18.0 to 0.19.0
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/062f2592684a31eb3aa050cc61e7ca1451cecd3d...d710430a6722f083d3b36b8339ff66b32f22ee55)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 13:01:09 -05:00
Bruno Quaresma 2f437005b7 chore(site): clean up mocks after each test (#12805) 2024-04-01 13:14:36 -03:00
elasticspoon cfb94284e0 feat(cli): add golden tests for errors (#11588) (#12698)
* feat(cli): add golden tests for errors (#11588)

Creates golden files from `coder/cli/errors.go`.
Adds a unit test to test against golden files.
Adds a make file command to regenerate golden files.
Abstracts test against golden files.
2024-04-01 09:19:26 -05:00
Bruno Quaresma 75bf41ba02 chore(site): use static date and ignore dynamic values for storybook (#12830) 2024-04-01 11:02:54 -03:00
Kayla Washburn-Love 1d2d008b45 chore: add e2e tests for template permissions (#12731) 2024-03-29 10:43:23 -06:00
Steven Masley eeb3d63be6 chore: merge authorization contexts (#12816)
* chore: merge authorization contexts

Instead of 2 auth contexts from apikey and dbauthz, merge them to
just use dbauthz. It is annoying to have two.

* fixup authorization reference
2024-03-29 10:14:27 -05:00
Michael Brewer 8e2d026d99 docs: document how to run workspace-proxy as a system service (#12810)
* docs: document how to run workspace-proxy as a system service

* Update workspace-proxies.md

* Update workspace-proxies.md

Co-authored-by: Muhammad Atif Ali <me@matifali.dev>

* docs: fix duplication

---------

Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2024-03-29 15:06:32 +00:00
Kayla Washburn-Love f3cfe10c26 chore: add more e2e template settings tests (#12717) 2024-03-28 16:00:27 -06:00
Steven Masley 8cf1e84bb5 chore: ensure root handler has sudomain app mw (#12812)
Enterprise routes like scim touch this.
2024-03-28 15:49:43 -05:00
Steven Masley b785e996f8 chore: explain GIT_ASKPASS behavior in docs (#12784)
* chore: docs explaining GIT_ASKPASS behavior

- VSCode configuration requirements
2024-03-28 13:59:03 -05:00
Mathias Fredriksson 79441e3609 perf(coderd/database): optimize GetWorkspaceAgentAndLatestBuildByAuthToken (#12809) 2024-03-28 19:38:16 +02:00
Steven Masley 93a233ac10 chore: write auto-update message after success (#12804) 2024-03-28 08:55:15 -05:00
Mathias Fredriksson d50c20c453 fix(coderd/database): add fk index for workspace_agent_scripts (#12791) 2024-03-28 14:31:58 +02:00
Danny Kopping d734f3fb74 chore: reduce azure CA cert validity check period to 2 months (#12788)
Signed-off-by: Danny Kopping <danny@coder.com>
2024-03-28 11:17:02 +02:00
Muhammad Atif Ali 0288e73e9b docs: add guide for Xray integration (#12629)
* docs: add guides for Xray integration

* `make fmt`
2024-03-28 04:48:50 +03:00
Colin Adler dc8cf3eea5 fix: nil ptr dereference when removing a license (#12785) 2024-03-27 15:59:35 -05:00
Muhammad Atif Ali 5235faa79f chore(site): remove max ttl from scheduling description (#12715) 2024-03-27 14:24:26 -05:00
Mathias Fredriksson 539d6b0f3b test(coderd): fix template name too long in TestPatchTemplateMeta (#12781) 2024-03-27 18:25:42 +02:00
Mathias Fredriksson 421bf7e785 fix(coderd): use insights for DAUs, simplify metricscache (#12775)
Fixes #12134
Fixes https://github.com/coder/customers/issues/384
Refs #12122
2024-03-27 18:10:14 +02:00
Bruno Quaresma 5d82a78d4c fix(site): fix and improve pending state on template editor UI (#12766) 2024-03-27 12:42:07 -03:00
Mathias Fredriksson 47fd190064 fix(coderd/database): improve perf of GetTemplateInsightsByInterval (#12773)
Refs #12122
2024-03-27 14:10:46 +02:00
Mathias Fredriksson ba1eaceda4 feat(coderd): add sftp to insights apps (#12675) 2024-03-27 14:09:29 +02:00
Danny Kopping 6cb1fc8956 chore: add note about options use in numeric parameters (#12770) 2024-03-27 13:46:41 +02:00
Mathias Fredriksson 0da29d74ac fix(coderd/database): improve query perf of GetTemplateAppInsights (#12767)
Refs #12122
2024-03-27 12:28:36 +02:00
Danny Kopping a74ef4096e feat: allow number options with monotonic validation (#12726)
NOTE: terraform-provider-coder was updated to facilitate this change, and your template will require v0.19.0 for this feature to work. You can run terraform init -upgrade in your template directory. If you have a version constraint set, ensure it points to this version.
2024-03-27 08:54:42 +00:00
Ammar Bandukwala 0d9010e150 chore: fix 30% startup time hit from userpassword (#12769)
pbkdf2 is too expensive to run in init, so this change makes it load
lazily. I introduced a lazy package that I hope to use more in my
`GODEBUG=inittrace=1` adventure.


Benchmark results:

```
$ hyperfine "coder --help" "coder-new --help"
Benchmark 1: coder --help
  Time (mean ± σ):      82.1 ms ±   3.8 ms    [User: 93.3 ms, System: 30.4 ms]
  Range (min … max):    72.2 ms …  90.7 ms    35 runs
 
Benchmark 2: coder-new --help
  Time (mean ± σ):      52.0 ms ±   4.3 ms    [User: 62.4 ms, System: 30.8 ms]
  Range (min … max):    41.9 ms …  62.2 ms    52 runs
 
Summary
  coder-new --help ran
    1.58 ± 0.15 times faster than coder --help
```
2024-03-26 20:47:14 -05:00
dependabot[bot] 73fbdbbe2d chore: bump github.com/coreos/go-oidc/v3 from 3.9.0 to 3.10.0 (#12739)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.9.0...v3.10.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  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>
2024-03-26 12:45:42 -05:00
Colin Adler 4d5a7b2d56 chore(codersdk): move all tailscale imports out of codersdk (#12735)
Currently, importing `codersdk` just to interact with the API requires
importing tailscale, which causes builds to fail unless manually using
our fork.
2024-03-26 12:44:31 -05:00
dependabot[bot] 0bea8906d4 chore: bump google.golang.org/api from 0.170.0 to 0.171.0 (#12737)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.170.0 to 0.171.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.170.0...v0.171.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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>
2024-03-26 17:14:01 +00:00
dependabot[bot] a323e30450 chore: bump github.com/aws/aws-sdk-go-v2 from 1.25.3 to 1.26.0 (#12738)
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.25.3 to 1.26.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.25.3...v1.26.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  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>
2024-03-26 12:03:01 -05:00
Mathias Fredriksson ae0ee622bb fix(coderd/database): improve data exclusion in UpsertTemplateUsageStats (#12764)
The PostgreSQL query analyzer wasn't able to eliminate the agent stats without re-introducing this filter.

Before: https://explain.dalibo.com/plan/21h7gb4f4bef391g
After: https://explain.dalibo.com/plan/721ec1cccee91egc
2024-03-26 17:21:05 +02:00
Mathias Fredriksson f418ece9ae test(cli): prevent flake due to outdated build in TestSSH (#12760)
Fixes #12752
2024-03-26 10:46:58 +00:00
Spike Curtis 51491fc01b fix(provisionersdk): change test to use bash script instead of binary echo (#12759)
Just upgraded to macOS 14.4 and TestAgentScript/Run fails for me with error `signal: killed`.  I opened the test directory in a terminal and sure enough, when you execute the `echo` binary, it is immediately killed.  The binary has no extended attributes and is byte-identical to the one in `/bin/`.

This fix uses a different strategy: instead of copying the `echo` binary from the system around, we just copy a small bash script that _calls_ the `echo` command.
2024-03-26 14:37:20 +04:00
Colin Adler 5f28220eec fix(coderd): add timeout to websocket waitgroup on shutdown (#12754) 2024-03-26 03:04:15 +00:00
Kayla Washburn-Love cfb484fa25 fix: always use bash when executing web terminal tests (#12755) 2024-03-25 16:58:07 -06:00
dependabot[bot] 064a08efa5 ci: bump the github-actions group with 1 update (#12743)
Bumps the github-actions group with 1 update: [contributor-assistant/github-action](https://github.com/contributor-assistant/github-action).


Updates `contributor-assistant/github-action` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/contributor-assistant/github-action/releases)
- [Commits](https://github.com/contributor-assistant/github-action/compare/v2.3.1...v2.3.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-25 23:40:35 +03:00
Asher 40e5ad5499 feat: make OAuth2 provider not enterprise-only (#12732) 2024-03-25 11:52:22 -08:00
Garrett Delfosse 60f335113c chore: add protocol lag to shared ports description (#12728) 2024-03-25 15:28:38 -04:00
Kyle Carberry fd8010c26d chore: make build only run on main (#12753) 2024-03-25 19:15:18 +00:00
Kayla Washburn-Love 541ccd940c chore: change e2e testing port (#12751) 2024-03-25 13:07:34 -06:00
Kyle Carberry 03ab37b343 chore: remove middleware to request version and entitlement warnings (#12750)
This cleans up `root.go` a bit, adds tests for middleware HTTP transport
functions, and removes two HTTP requests we always always performed previously
when executing *any* client command.

It should improve CLI performance (especially for users with higher latency).
2024-03-25 15:01:42 -04:00
Cian Johnston ba3879ac47 fix(cli): fix newline escape sequence in support blurb (#12749) 2024-03-25 16:51:48 +00:00
Marcin Tojek 1e0bbd5e10 docs: describe operational readiness (#12723) 2024-03-25 17:10:24 +01:00
Mathias Fredriksson 7e183db199 test(coderd): fix todo for increased accuracy in insights test (#12727)
This PR updates the tests in `insights_test.go` to enable commented-out scenarios. This behavior was fixed by previous PRs in this stack. Note that the updated golden files are correct since they are "second template only" meaning that the newly introduced data is considered as expected. In other golden files there is no change since "only count once" is applied.
2024-03-25 17:55:53 +02:00
Mathias Fredriksson b183236482 feat(coderd/database): use template_usage_stats in *ByTemplate insights queries (#12668)
This PR updates the `*ByTempalte` insights queries used for generating Prometheus metrics to behave the same way as the new rollup query and re-written insights queries that utilize the rolled up data.
2024-03-25 17:42:02 +02:00
Cian Johnston 01f9a9ab77 feat(cli): unhide support bundle cmd (#12745)
* chore(cli): add another test to ensure no secret leakage

* feat(cli): unhide support bundle cmd
2024-03-25 15:14:27 +00:00
Mathias Fredriksson 2332d8197a feat(coderd/database): use template_usage_stats in GetUserActivityInsights query (#12672)
This PR updates the `GetUserActivityInsights` query to use rolled up `template_usage_stats` instead of raw agent and app stats.
2024-03-25 16:16:41 +02:00
Mathias Fredriksson a8ed689bda feat(coderd/database): use template_usage_stats in GetUserLatencyInsights query (#12671)
This PR updates the `GetUserLatencyInsights` query to use rolled up `template_usage_stats` instead of raw agent and app stats.
2024-03-25 16:07:40 +02:00
Mathias Fredriksson 5738a03930 feat(coderd/database): use template_usage_stats in GetTemplateAppInsights query (#12669)
This PR updates the `GetTemplateAppInsights` query to use rolled up `template_usage_stats` instead of raw agent and app stats.
2024-03-25 15:58:37 +02:00
Mathias Fredriksson 5f3be62c83 feat(coderd/database): use template_usage_stats in GetTemplateInsightsByInterval query (#12667)
This PR updates the `GetTemplateInsightsByInterval` query to use rolled up `template_usage_stats` instead of raw agent and app stats.
2024-03-25 15:45:49 +02:00
Mathias Fredriksson 35d08434a9 feat(coderd/database): use template_usage_stats in GetTemplateInsights query (#12666)
This PR updates the `GetTemplateInsights` query to use rolled up `template_usage_stats` instead of raw agent and app stats.
2024-03-25 15:33:31 +02:00
Mathias Fredriksson f34592f45d fix(coderd): skip logging error for cancelled query in agent report stats (#12730) 2024-03-25 12:20:16 +02:00
Jon Ayers 951dfaa99c feat: add workspace_id to workspace_build audit logs (#12718) 2024-03-22 15:10:32 -05:00
Kayla Washburn-Love 0966fe2560 fix: create workspace with optional auth providers (#12729) 2024-03-22 13:26:02 -06:00
Steven Masley c674128105 chore: allow search by build params in workspace search filter (#12694)
* chore: workspace search filter allow search by params
* has_param will return all workspaces with the param existance
* exact matching
2024-03-22 14:22:47 -05:00
Mathias Fredriksson b4fd819f0d test(coderd): enable dbrollup service for insights tests (#12673) 2024-03-22 20:18:20 +02:00
Mathias Fredriksson 12e6fbf11e feat(coderd/database): add dbrollup service to rollup insights (#12665)
Add `dbrollup` service that runs the `UpsertTemplateUsageStats` query
every 5 minutes, on the minute. This allows us to have fairly real-time
insights data when viewing "today".
2024-03-22 18:42:43 +02:00
Mathias Fredriksson 04f0510b09 feat(coderd/database): add template_usage_stats table and rollup query (#12664)
Add `template_usage_stats` table for aggregating tempalte usage data.
Data is rolled up by the `UpsertTemplateUsageStats` query, which fetches
data from the `workspace_agent_stats` and `workspace_app_stats` tables.
2024-03-22 18:33:34 +02:00
Kyle Carberry a6b8f381f0 feat: support windows containers in bootstrap script (#12662)
Fixes #7462.
2024-03-22 14:48:51 +00:00
Muhammad Atif Ali 58cbd8335f chore(site): reorganize template schedule strings page (#12714) 2024-03-22 15:44:16 +03:00
Marcin Tojek a7d9d87ba2 docs: use scale testing utility (#12643) 2024-03-22 11:33:31 +00:00
Colin Adler 37a05372fa fix: disable relay if built-in DERP is disabled (#12654)
Fixes https://github.com/coder/coder/issues/12493
2024-03-21 16:53:41 -05:00
Kyle Carberry d3c9aaf57b chore: update hero image for refreshed dashboard (#12712)
* chore: update hero image for refreshed dashboard

I used html.to.figma for this. It's in the following Figma:
https://www.figma.com/file/m4zkIU7e64BNyRlUShuluq/README-Screenshot?type=design&node-id=13-1993&mode=design&t=o2AhL8y7u8uFMVPI-0

I couldn't take screenshots directly, because I'm on Linux and the macOS
browser with the icons look much better.

* Try stacked images

* Try stacked inverted
2024-03-21 16:28:02 -04:00
Cian Johnston 28730ca3d8 fix(support): sanitize manifest (#12711) 2024-03-21 19:55:34 +00:00
Cian Johnston f2a9e515df feat(cli/support): confirm before creating bundle (#12684)
Forces user to confirm before creating a support bundle.
Also adds contextual information to the bundle under cli_logs.txt.
2024-03-21 17:06:28 +00:00
Cian Johnston 8ea5fb7115 chore(coderd/workspaceapps/apptest): fix test flake due to concurrent usage of same deployment (#12700)
- Updates existing tests under workspaceapps/apptest to not reuse existing appDetails as assertWorkspaceLastUsed(Not)?Updated calls FlushStats() which was causing racy test behaviour and incorrect test assertions.
- Expands scope of assertWorkspaceLastUsedAtUpdated and its counterpart to ProxySubdomain tests.
2024-03-21 15:38:38 +00:00
Cian Johnston 5454f4997b chore(ci): clean up databases after test finishes in CI (#12702) 2024-03-21 14:53:16 +00:00
Mathias Fredriksson 9c84fb7fb1 fix(coderd/agentapi): always write agent stats when provided (#12699) 2024-03-21 16:47:06 +02:00
Steven Masley bd6ad88077 chore: nolint always return error function (#12701) 2024-03-21 09:35:10 -05:00
Steven Masley b4492fffba chore: support multiple key:value search query params (#12690)
This more closely aligns with GitHub's label search style. Actual search params need to be converted to allow this format, by default they will throw an error if they do not support listing.
2024-03-21 08:37:19 -05:00
Bruno Quaresma 8499eacf67 chore(site): add tests for deprecate template flow (#12685)
Closes #12505
2024-03-21 10:37:08 -03:00
Steven Masley 131d0bd2ba chore: fix linting issue in main(#12697) 2024-03-20 20:15:01 -05:00
dependabot[bot] f93491ffe8 chore: bump github.com/docker/docker from 24.0.7+incompatible to 24.0.9+incompatible (#12692)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-21 02:14:30 +03:00
Dean Sheather 2b773f9034 fix: allow proxy version mismatch (with warning) (#12433) 2024-03-20 18:24:18 +00:00
Garrett Delfosse 4d9fe05f5a feat: add awsiamrds db auth driver (#12566) 2024-03-20 13:14:43 -04:00
Ammar Bandukwala 0d86dca852 fix(codersdk): abort in-progress writes/reads when closing websocket (#12650)
Fixes #9203

Related #12065

Also, adds some basic tracing infrastructure that we can build upon for more improvements.
2024-03-20 11:53:32 -05:00
Cian Johnston 92aa1eba97 fix(cli): port-forward: update workspace last_used_at (#12659)
This PR updates the coder port-forward command to periodically inform coderd that the workspace is being used:

- Adds workspaceusage.Tracker which periodically batch-updates workspace LastUsedAt
- Adds coderd endpoint to signal workspace usage
- Updates coder port-forward to periodically hit this endpoint
- Modifies BatchUpdateWorkspacesLastUsedAt to avoid overwriting with stale data

Co-authored-by: Danny Kopping <danny@coder.com>
2024-03-20 16:44:12 +00:00
Steven Masley d789a60d47 chore: remove max_ttl from templates (#12644)
* chore: remove max_ttl from templates

Completely removing max_ttl as a feature on template scheduling. Must use other template scheduling features to achieve autostop.
2024-03-20 10:37:57 -05:00
Bruno Quaresma d82e20152b feat(site): make listening ports scrollable (#12660) 2024-03-20 09:34:30 -03:00
Kayla Washburn-Love 9028717c9b fix: disable auto-create if external auth requirements aren't met (#12538) 2024-03-19 16:42:40 -06:00
Muhammad Atif Ali ef26ad96a9 chore(dogfood): bump modules versions 2024-03-19 23:54:16 +03:00
Kyle Carberry 4ae1f40eee chore: add docs for adding e2e tests (#12677) 2024-03-19 18:25:05 +00:00
Ben Potter c92ceffac9 chore: fix changelog typos (#12663) 2024-03-19 16:04:27 +00:00
Steven Masley 00283d1f8b chore: helm golden file test log output on failure (#12661)
Debugging this is hard without the actual helm error
2024-03-19 16:01:54 +00:00
Bruno Quaresma 23e3e4ce58 chore(site): upgrade msw to 2.0 (#12597)
Closes https://github.com/coder/coder/issues/11426
2024-03-19 09:30:20 -03:00
Danny Kopping 9cfd5baa91 feat(coderd): export metric indicating each experiment's status (#12657) 2024-03-19 14:11:27 +02:00
Ben Potter 1a9f7e7b00 hotfix(docs): we do not offer phone support yet (#12658) 2024-03-19 10:22:32 +00:00
Danny Kopping ab95ae827d feat(coderd): add enabled experiments to telemetry (#12656) 2024-03-19 11:05:29 +02:00
Steven Masley f0f9569d51 chore: enforce that provisioners can only acquire jobs in their own organization (#12600)
* chore: add org ID as optional param to AcquireJob
* chore: plumb through organization id to provisioner daemons
* add org id to provisioner domain key
* enforce org id argument
* dbgen provisioner jobs defaults to default org
2024-03-18 12:48:13 -05:00
Marcin Tojek 0e8ebb9b22 fix: fix flaky TestWorkspaceProxy_Server_PrometheusEnabled (#12645) 2024-03-18 16:43:41 +01:00
dependabot[bot] 2cd5fbc712 chore: bump github.com/ammario/tlru from 0.3.0 to 0.4.0 (#12635)
Bumps [github.com/ammario/tlru](https://github.com/ammario/tlru) from 0.3.0 to 0.4.0.
- [Commits](https://github.com/ammario/tlru/compare/v0.3.0...v0.4.0)

---
updated-dependencies:
- dependency-name: github.com/ammario/tlru
  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>
2024-03-18 11:25:52 -04:00
dependabot[bot] 124da2e51c chore: bump github.com/u-root/u-root from 0.13.0 to 0.14.0 (#12636)
Bumps [github.com/u-root/u-root](https://github.com/u-root/u-root) from 0.13.0 to 0.14.0.
- [Release notes](https://github.com/u-root/u-root/releases)
- [Changelog](https://github.com/u-root/u-root/blob/main/RELEASES)
- [Commits](https://github.com/u-root/u-root/compare/v0.13.0...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/u-root/u-root
  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>
2024-03-18 11:25:35 -04:00
Marcin Tojek cae769eac0 fix: implicit schema in dump (#12646) 2024-03-18 16:25:08 +01:00
dependabot[bot] 543a8ccb31 chore: bump github.com/coder/serpent (#12637)
Bumps [github.com/coder/serpent](https://github.com/coder/serpent) from 0.4.1-0.20240315163851-a0148c87ea3f to 0.5.0.
- [Commits](https://github.com/coder/serpent/commits/v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/coder/serpent
  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>
2024-03-18 09:39:28 -05:00
Muhammad Atif Ali f0c5e8e960 chore: reduce slack spam for manually run docs link checker workflows (#12630) 2024-03-18 17:23:54 +03:00
Marcin Tojek 15845d1a65 chore: use sqlc-vet to verify schema (#12642) 2024-03-18 15:23:25 +01:00
Ammar Bandukwala e5cc17af92 chore(cli): hide --organization (#12626)
It's potentially confusing to users since we aren't fleshing out organizations right now.
2024-03-18 09:09:26 -05:00
Dean Sheather cf50461ab4 fix: prevent single replica proxies from staying unhealthy (#12641)
In the peer healthcheck code, when an error pinging peers is detected we
write a "replicaErr" string with the error reason. However, if there are
no peer replicas to ping we returned early without setting the string to
empty. This would cause replicas that had peers (which were failing) and
then the peers left to permanently show an error until a new peer
appeared.

Also demotes DERP replica checking to a "warning" rather than an "error"
which should prevent the primary from removing the proxy from the region
map if DERP meshing is non-functional. This can happen without causing
problems if the peer is shutting down so we don't want to disrupt
everything if there isn't an issue.
2024-03-18 23:45:25 +10:00
Kyle Carberry 8a2f38a746 chore: simplify readme (#12639)
* chore: simplify readme

Closes #12628.

* Add link to module registry
2024-03-18 09:40:47 -04:00
Bruno Quaresma c84d96b747 fix(site): display not found page when pagination page is invalid (#12611) 2024-03-18 10:35:59 -03:00
dependabot[bot] b121f407f5 chore: bump golang.org/x/tools from 0.18.0 to 0.19.0 (#12632)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-18 14:44:16 +03:00
dependabot[bot] eb20d8cf18 chore: bump golang.org/x/mod from 0.15.0 to 0.16.0 (#12633)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-18 14:44:05 +03:00
dependabot[bot] fffa3dc422 chore: bump github.com/gohugoio/hugo from 0.123.3 to 0.124.0 (#12634)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-18 14:43:58 +03:00
Danny Kopping 93933d7905 feat(cli): show queue position during workspace builds (#12606) 2024-03-18 12:05:05 +02:00
dependabot[bot] c7597fdf02 chore: bump go.nhat.io/otelsql from 0.12.0 to 0.13.0 (#12521)
Bumps [go.nhat.io/otelsql](https://github.com/nhatthm/otelsql) from 0.12.0 to 0.13.0.
- [Release notes](https://github.com/nhatthm/otelsql/releases)
- [Commits](https://github.com/nhatthm/otelsql/compare/v0.12.0...v0.13.0)

---
updated-dependencies:
- dependency-name: go.nhat.io/otelsql
  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>
2024-03-18 11:03:45 +03:00
dependabot[bot] 77cc170f04 chore: bump google.golang.org/api from 0.152.0 to 0.170.0 (#12607)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.152.0 to 0.170.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.152.0...v0.170.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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>
2024-03-18 10:45:52 +03:00
Danny Kopping 53b58ed74b fix: correct troubleshooting links (#12608) 2024-03-18 08:52:20 +02:00
elasticspoon 5011edc292 fix(cli): show error/hide help for unsupported subcommands (#10760) (#12624)
Fixes #10760 

The coder CLI quietly accepts any subcommand arguments and silently swallows them.

Currently:
```sh
❯ coder | head -n5
coder v2.3.3+e491217

USAGE:
  coder [global-flags] <subcommand>
```

```sh
❯ coder idontexist | head -n5
coder v2.3.3+e491217

USAGE:
  coder [global-flags] <subcommand>
```

Now help output will not be show when there is an unknown subcommand error. Instead users will be given the command for the help output.

```sh
❯ coder idontexist
Encountered an error running "coder", see "coder --help" for more information 
error: unrecognized subcommand "idontexist"
```
```sh
❯ coder iexistbut idontexist
Encountered an error running "coder iexistbut", see "coder iexistbut --help" for more information 
error: unrecognized subcommand "idontexist"
```

Also this stuff: `Encountered an error running "coder iexistbut"... ` gets written to `os.Stdout` in `prettyErrorFormatter{w: os.Stderr, verbose: r.verbose}`, not sure how to test that output.
2024-03-17 22:17:43 -05:00
Steven Masley c189cc93e4 chore: bump gopkg.in/DataDog/dd-trace-go.v1 from 1.57.0 to 1.61.0 (#12610)
* chore: bump gopkg.in/DataDog/dd-trace-go.v1 from 1.57.0 to 1.61.0

* Fix tracer implementation
* Use alias vs 2 structs
2024-03-17 22:08:32 -05:00
Ammar Bandukwala b4c0fa80d8 chore(cli): rename Cmd to Command (#12616)
I think Command is cleaner and my original decision to use "Cmd"
a mistake.

Plus this creates better parity with cobra.
2024-03-17 09:45:26 -05:00
Muhammad Atif Ali 2a77580ba6 chore: fix false positives for docs links checker (#12623) 2024-03-17 16:44:45 +03:00
dependabot[bot] aa3ab209f3 ci: bump the github-actions group with 3 updates (#12622)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 13:27:15 +00:00
Gary Wang 1a5c5d0d57 fix: correct typo error about minTerraformVersion (#12621) 2024-03-17 13:18:46 +00:00
dependabot[bot] 4bdb019001 chore: bump google.golang.org/protobuf from 1.32.0 to 1.33.0 (#12614)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 16:12:51 +03:00
Gábor 9c69672382 fix(migration): removed hardcoded public (#12620) 2024-03-16 10:11:14 -04:00
Cian Johnston 9ff0bafcee fix(support): also sanitize agent environment (#12615) 2024-03-15 20:19:35 +00:00
Bruno Quaresma 6f0ba5bfe7 chore(site): add AgentLogs storybook (#12601) 2024-03-15 14:57:35 -03:00
Cian Johnston 25b605f764 fix(examples/lima/coder.yaml): update base image, remove usage of deprecated LIMA_CIDATA (#12613)
* fix(examples/lima/coder.yaml): update base image, remove usage of deprecated LIMA_CIDATA, name template consistently

* make fmt
2024-03-15 17:22:44 +00:00
Ammar Bandukwala 496232446d chore(cli): replace clibase with external coder/serpent (#12252) 2024-03-15 11:24:38 -05:00
Marcin Tojek bed2545636 docs: describe reference architectures (#12609) 2024-03-15 17:01:45 +01:00
Cian Johnston b0c4e7504c feat(support): add client magicsock and agent prometheus metrics to support bundle (#12604)
* feat(codersdk): add ability to fetch prometheus metrics directly from agent
* feat(support): add client magicsock and agent prometheus metrics to support bundle
* refactor(support): simplify AgentInfo control flow

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2024-03-15 15:33:49 +00:00
Eric Paulsen 4d9e6c0134 add updated architecture diagrams (#12584) 2024-03-15 11:09:03 -04:00
Cian Johnston 2fc9f097ed chore: apply linter auto-fixes (#12605) 2024-03-15 14:39:25 +00:00
Cian Johnston 18c1e02bf0 dogfood: replace siegfried with greenhill (#12599) 2024-03-15 23:33:43 +10:00
dependabot[bot] e1685b96e4 chore: bump golang.org/x/tools from 0.18.0 to 0.19.0 (#12519)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.18.0 to 0.19.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  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>
2024-03-15 13:18:35 +00:00
Kyle Carberry 895df54051 fix: separate signals for passive, active, and forced shutdown (#12358)
* fix: separate signals for passive, active, and forced shutdown

`SIGTERM`: Passive shutdown stopping provisioner daemons from accepting new
jobs but waiting for existing jobs to successfully complete.

`SIGINT` (old existing behavior): Notify provisioner daemons to cancel in-flight jobs, wait 5s for jobs to be exited, then force quit.

`SIGKILL`: Untouched from before, will force-quit.

* Revert dramatic signal changes

* Rename

* Fix shutdown behavior for provisioner daemons

* Add test for graceful shutdown
2024-03-15 13:16:36 +00:00
dependabot[bot] 2c947c1921 chore: bump golang.org/x/mod from 0.15.0 to 0.16.0 (#12520)
Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.15.0 to 0.16.0.
- [Commits](https://github.com/golang/mod/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  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>
2024-03-15 13:09:50 +00:00
dependabot[bot] 78f26bf24a chore: bump github.com/u-root/u-root from 0.13.0 to 0.14.0 (#12397)
Bumps [github.com/u-root/u-root](https://github.com/u-root/u-root) from 0.13.0 to 0.14.0.
- [Release notes](https://github.com/u-root/u-root/releases)
- [Changelog](https://github.com/u-root/u-root/blob/main/RELEASES)
- [Commits](https://github.com/u-root/u-root/compare/v0.13.0...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/u-root/u-root
  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>
2024-03-15 08:55:10 -04:00
dependabot[bot] cf7f95b418 chore: bump golang.org/x/oauth2 from 0.17.0 to 0.18.0 (#12528)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  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>
2024-03-15 08:54:32 -04:00
dependabot[bot] 351706b896 chore: bump google.golang.org/protobuf from 1.32.0 to 1.33.0 (#12524)
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  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>
2024-03-15 08:54:26 -04:00
Cian Johnston 2abc1cd2b7 feat(support): fetch agent network info over tailnet (#12577)
Adds agent-related information to support bundle command.
2024-03-15 11:01:39 +00:00
Cian Johnston 653ddccd8e fix(agent): remove unused token debug handler (#12602) 2024-03-15 09:43:36 +00:00
dependabot[bot] 8d7819f6d6 chore: bump github.com/stretchr/testify from 1.8.4 to 1.9.0 (#12404)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.4 to 1.9.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.4...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  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>
2024-03-14 19:37:32 -05:00
Steven Masley 29c8cf20e0 fix: handle CLI default organization when none exists in <v2.9.0 coderd (#12594)
* chore: 'coder org set' help message was incorrect
* fix: handler coder cli against older versions of Coder
2024-03-14 15:11:29 -05:00
Bruno Quaresma f78b5c1cfe chore(site): refactor logs and add stories (#12553) 2024-03-14 14:49:37 -03:00
Garrett Delfosse 0723dd3abf fix: ensure agent token is from latest build in middleware (#12443) 2024-03-14 12:27:32 -04:00
Cian Johnston 63696d762f feat(codersdk): add debug handlers for logs, manifest, and token to agent (#12593)
* feat(codersdk): add debug handlers for logs, manifest, and token to agent

* add more logging

* use io.LimitReader instead of seeking
2024-03-14 15:36:12 +00:00
Cian Johnston 135381bb4e chore(cli): skip support bundle test on windows (#12596) 2024-03-14 15:25:09 +00:00
Mathias Fredriksson 5dd436c19b feat(examples): add linting to all examples (#12595)
Fixes #12588
2024-03-14 16:49:44 +02:00
Steven Masley 410a7d54ee chore: external auth flow opens new window, it does not need an href (#12586) 2024-03-14 09:17:50 -05:00
Steven Masley 4cba83b30f test: apptest was accidently choosing ports in use (#12580)
Apptest requires a port without a listening server to test failure
cases. This port was chosen and had a chance of actually being
provisioned. To prevent this accident, a port <1k is chosen,
since those will never be allocated.
2024-03-14 08:54:12 -05:00
Danny Kopping 14130deb07 fix: clean template destination path for pull (#12559) 2024-03-14 12:41:23 +00:00
Cian Johnston 395bf54f4f chore(examples/templates/incus): fix metadata harder (#12589) 2024-03-14 11:43:34 +00:00
Muhammad Atif Ali 04b711f187 chore(examples/templates/incus): fix metadata (#12587) 2024-03-14 11:14:37 +00:00
Cian Johnston 3b406878e0 feat(agent): expose HTTP debug server over tailnet API (#12582) 2024-03-14 10:02:01 +00:00
Asher 0d16df9df9 fix: importing api into vscode-coder (#12570) 2024-03-13 10:05:44 -08:00
Kayla Washburn-Love efba477b36 fix: hide actions and notifications from deleted workspaces (#12563) 2024-03-13 11:43:48 -06:00
Bruno Quaresma 489b0ec497 chore(site): refactor useAuth and related hooks (#12567)
Close https://github.com/coder/coder/issues/12487
2024-03-13 13:24:18 -03:00
Spike Curtis fe6def31eb feat: upgrade tailscale fork to set TCP options for perf (#12574)
Upgrades our tailscale fork to include some TCP options tweaks for performance. c.f. https://github.com/coder/tailscale/pull/46
2024-03-13 16:02:02 +04:00
Michael Brewer 903f8b21c4 feat(site): add cpp icon (#12572)
* feat(site): add cpp icon

Add the C++ icon

Source is WikiCommons: https://en.m.wikipedia.org/wiki/File:ISO_C%2B%2B_Logo.svg

* fix: correct order
2024-03-13 07:49:03 -04:00
Danny Kopping da54c8a51f fix: fix data race in TestLabelsAggregation tests (#12578) 2024-03-13 13:47:22 +02:00
Danny Kopping 7a7105ad66 feat: make agent stats' cardinality configurable (#12535) 2024-03-13 12:03:36 +02:00
Bruno Quaresma e45d511f28 chore(site): add missing stories for templates (#12537) 2024-03-12 17:54:37 -03:00
Kayla Washburn-Love 301c60d824 chore(dogfood): add fish and helix ppa packages to dogfood (#12568) 2024-03-12 13:58:40 -06:00
Cian Johnston 096d472de9 chore(dogfood): update remaining focal PPAs to jammy (#12565) 2024-03-12 19:24:58 +00:00
Cian Johnston 901668ad4b feat(dogfood): add git from git-core ppa (#12564) 2024-03-12 17:28:07 +00:00
Bruno Quaresma 8489b4dfb1 chore(site): add WorkspaceBuildData stories (#12550) 2024-03-12 14:18:31 -03:00
Bruno Quaresma e947e0e829 chore(site): refactor dormant badge and add stories (#12555) 2024-03-12 14:17:55 -03:00
690 changed files with 26001 additions and 20987 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.21.5"
default: "1.21.9"
runs:
using: "composite"
steps:
+19 -7
View File
@@ -142,7 +142,7 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@v1.18.2
uses: crate-ci/typos@v1.19.0
with:
config: .github/workflows/typos.toml
@@ -155,7 +155,7 @@ jobs:
# Needed for helm chart linting
- name: Install helm
uses: azure/setup-helm@v3
uses: azure/setup-helm@v4
with:
version: v3.9.2
@@ -202,7 +202,9 @@ jobs:
popd
- name: make gen
run: "make --output-sync -j -B gen"
# no `-j` flag as `make` fails with:
# coderd/rbac/object_gen.go:1:1: syntax error: package statement must be first
run: "make --output-sync -B gen"
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
@@ -226,7 +228,7 @@ jobs:
with:
# This doesn't need caching. It's super fast anyways!
cache: false
go-version: 1.21.5
go-version: 1.21.9
- name: Install shfmt
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
@@ -470,11 +472,19 @@ jobs:
- run: pnpm playwright:install
working-directory: site
- run: pnpm playwright:test --workers 1
# Run tests that don't require an enterprise license without an enterprise license
- run: pnpm playwright:test --forbid-only --workers 1
env:
DEBUG: pw:api
working-directory: site
# Run all of the tests with an enterprise license
- run: pnpm playwright:test --forbid-only --workers 1
env:
DEBUG: pw:api
CODER_E2E_ENTERPRISE_LICENSE: ${{ secrets.CODER_E2E_ENTERPRISE_LICENSE }}
working-directory: site
- name: Upload Playwright Failed Tests
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v4
@@ -615,8 +625,10 @@ jobs:
pnpm lint
- name: Build
# no `-j` flag as `make` fails with:
# coderd/rbac/object_gen.go:1:1: syntax error: package statement must be first
run: |
make -j build/coder_docs_"$(./scripts/version.sh)".tgz
make build/coder_docs_"$(./scripts/version.sh)".tgz
required:
runs-on: ubuntu-latest
@@ -662,7 +674,7 @@ jobs:
# to main branch. We are only building this for amd64 platform. (>95% pulls
# are for amd64)
needs: changes
if: needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: cla
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.3.1
uses: contributor-assistant/github-action@v2.3.2
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
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@84384bd6e777ef152729993b8145ea352e9dd3ef
uses: aquasecurity/trivy-action@d710430a6722f083d3b36b8339ff66b32f22ee55
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
file-path: "./README.md"
- name: Send Slack notification
if: failure()
if: failure() && github.event_name != 'workflow_dispatch'
run: |
curl -X POST -H 'Content-type: application/json' -d '{"msg":"Broken links found in the documentation. Please check the logs at ${{ env.LOGS_URL }}"}' ${{ secrets.DOCS_LINK_SLACK_WEBHOOK }}
echo "Sent Slack notification"
+8 -2
View File
@@ -428,7 +428,7 @@ else
endif
.PHONY: fmt/shfmt
lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons
.PHONY: lint
lint/site-icons:
@@ -447,6 +447,10 @@ lint/go:
golangci-lint run
.PHONY: lint/go
lint/examples:
go run ./scripts/examplegen/main.go -lint
.PHONY: lint/examples
# Use shfmt to determine the shell files, takes editorconfig into consideration.
lint/shellcheck: $(SHELL_SRC_FILES)
echo "--- shellcheck"
@@ -638,7 +642,7 @@ update-golden-files: \
.PHONY: update-golden-files
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
go test ./cli -run="Test(CommandHelp|ServerYAML)" -update
go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples)" -update
touch "$@"
enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard enterprise/cli/*_test.go)
@@ -779,6 +783,7 @@ test-postgres: test-postgres-docker
-count=1
.PHONY: test-postgres
# NOTE: we set --memory to the same size as a GitHub runner.
test-postgres-docker:
docker rm -f test-postgres-docker || true
docker run \
@@ -791,6 +796,7 @@ test-postgres-docker:
--name test-postgres-docker \
--restart no \
--detach \
--memory 16GB \
gcr.io/coder-dev-1/postgres:13 \
-c shared_buffers=1GB \
-c work_mem=1GB \
+11 -18
View File
@@ -23,7 +23,6 @@
[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)
[![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)
@@ -53,8 +52,8 @@ 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
# Navigate to http://localhost:3000 to create your initial user,
# create a Docker template, and provision a workspace
```
## Install
@@ -68,11 +67,11 @@ Releases.
curl -L https://coder.com/install.sh | sh
```
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.
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
Once installed, you can start a production deployment<sup>1</sup> with a single command:
Once installed, you can start a production deployment with a single command:
```shell
# Automatically sets up an external access URL on *.try.coder.app
@@ -82,8 +81,6 @@ coder server
coder server --postgres-url <url> --access-url <url>
```
> <sup>1</sup> For production deployments, set up an external PostgreSQL instance for reliability.
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
## Documentation
@@ -96,19 +93,13 @@ Browse our docs [here](https://coder.com/docs/v2) or visit a specific section be
- [**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
## Support
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.
[Join our Discord](https://discord.gg/coder) or [Slack](https://cdr.co/join-community) to provide feedback on in-progress features, and chat with the community using Coder!
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
## Contributing
Contributions are welcome! Read the [contributing docs](https://coder.com/docs/v2/latest/CONTRIBUTING) to get started.
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
## Related
## Integrations
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.
@@ -116,10 +107,12 @@ We are always working on new integrations. Feel free to open an issue to request
- [**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
- [**Dev Container Builder**](https://github.com/coder/envbuilder): Build development environments using `devcontainer.json` on Docker, Kubernetes, and OpenShift
- [**Module Registry**](https://registry.coder.com): Extend development environments with common use-cases
- [**Kubernetes Log Stream**](https://github.com/coder/coder-logstream-kube): Stream Kubernetes Pod events to the Coder startup logs
- [**Self-Hosted VS Code Extension Marketplace**](https://github.com/coder/code-marketplace): A private extension marketplace that works in restricted or airgapped networks integrating with [code-server](https://github.com/coder/code-server).
### 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.
- [**Coder Template GitHub Action**](https://github.com/marketplace/actions/update-coder-template): A GitHub Action that updates Coder templates
+260 -77
View File
@@ -25,6 +25,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/expfmt"
"github.com/spf13/afero"
"go.uber.org/atomic"
"golang.org/x/exp/slices"
@@ -34,10 +35,9 @@ import (
"tailscale.com/net/speedtest"
"tailscale.com/tailcfg"
"tailscale.com/types/netlogtype"
"tailscale.com/util/clientmetric"
"cdr.dev/slog"
"github.com/coder/retry"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentssh"
@@ -48,8 +48,10 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/tailnet"
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
"github.com/coder/retry"
)
const (
@@ -60,7 +62,10 @@ const (
// EnvProcPrioMgmt determines whether we attempt to manage
// process CPU and OOM Killer priority.
const EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT"
const (
EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT"
EnvProcOOMScore = "CODER_PROC_OOM_SCORE"
)
type Options struct {
Filesystem afero.Fs
@@ -1105,7 +1110,7 @@ func (a *agent) wireguardAddresses(agentID uuid.UUID) []netip.Prefix {
netip.PrefixFrom(tailnet.IPFromUUID(agentID), 128),
// We also listen on the legacy codersdk.WorkspaceAgentIP. This
// allows for a transition away from wsconncache.
netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128),
netip.PrefixFrom(workspacesdk.AgentIP, 128),
}
}
@@ -1145,7 +1150,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
}
}()
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentSSHPort))
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentSSHPort))
if err != nil {
return nil, xerrors.Errorf("listen on the ssh port: %w", err)
}
@@ -1160,7 +1165,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
return nil, err
}
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentReconnectingPTYPort))
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentReconnectingPTYPort))
if err != nil {
return nil, xerrors.Errorf("listen for reconnecting pty: %w", err)
}
@@ -1209,7 +1214,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
if err != nil {
return
}
var msg codersdk.WorkspaceAgentReconnectingPTYInit
var msg workspacesdk.AgentReconnectingPTYInit
err = json.Unmarshal(data, &msg)
if err != nil {
logger.Warn(ctx, "failed to unmarshal init", slog.F("raw", data))
@@ -1223,7 +1228,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
return nil, err
}
speedtestListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentSpeedtestPort))
speedtestListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentSpeedtestPort))
if err != nil {
return nil, xerrors.Errorf("listen for speedtest: %w", err)
}
@@ -1271,7 +1276,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
return nil, err
}
apiListener, err := network.Listen("tcp", ":"+strconv.Itoa(codersdk.WorkspaceAgentHTTPAPIServerPort))
apiListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentHTTPAPIServerPort))
if err != nil {
return nil, xerrors.Errorf("api listener: %w", err)
}
@@ -1384,7 +1389,7 @@ func (a *agent) runDERPMapSubscriber(ctx context.Context, conn drpc.Conn, networ
}
}
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.WorkspaceAgentReconnectingPTYInit, conn net.Conn) (retErr error) {
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg workspacesdk.AgentReconnectingPTYInit, conn net.Conn) (retErr error) {
defer conn.Close()
a.metrics.connectionsTotal.Add(1)
@@ -1573,10 +1578,31 @@ func (a *agent) manageProcessPriorityUntilGracefulShutdown() {
a.processManagementTick = ticker.C
}
oomScore := unsetOOMScore
if scoreStr, ok := a.environmentVariables[EnvProcOOMScore]; ok {
score, err := strconv.Atoi(strings.TrimSpace(scoreStr))
if err == nil && score >= -1000 && score <= 1000 {
oomScore = score
} else {
a.logger.Error(ctx, "invalid oom score",
slog.F("min_value", -1000),
slog.F("max_value", 1000),
slog.F("value", scoreStr),
)
}
}
debouncer := &logDebouncer{
logger: a.logger,
messages: map[string]time.Time{},
interval: time.Minute,
}
for {
procs, err := a.manageProcessPriority(ctx)
procs, err := a.manageProcessPriority(ctx, debouncer, oomScore)
// Avoid spamming the logs too often.
if err != nil {
a.logger.Error(ctx, "manage process priority",
debouncer.Error(ctx, "manage process priority",
slog.Error(err),
)
}
@@ -1592,27 +1618,34 @@ func (a *agent) manageProcessPriorityUntilGracefulShutdown() {
}
}
func (a *agent) manageProcessPriority(ctx context.Context) ([]*agentproc.Process, error) {
// unsetOOMScore is set to an invalid OOM score to imply an unset value.
const unsetOOMScore = 1001
func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebouncer, oomScore int) ([]*agentproc.Process, error) {
const (
niceness = 10
)
// We fetch the agent score each time because it's possible someone updates the
// value after it is started.
agentScore, err := a.getAgentOOMScore()
if err != nil {
agentScore = unsetOOMScore
}
if oomScore == unsetOOMScore && agentScore != unsetOOMScore {
// If the child score has not been explicitly specified we should
// set it to a score relative to the agent score.
oomScore = childOOMScore(agentScore)
}
procs, err := agentproc.List(a.filesystem, a.syscaller)
if err != nil {
return nil, xerrors.Errorf("list: %w", err)
}
var (
modProcs = []*agentproc.Process{}
logger slog.Logger
)
modProcs := []*agentproc.Process{}
for _, proc := range procs {
logger = a.logger.With(
slog.F("cmd", proc.Cmd()),
slog.F("pid", proc.PID),
)
containsFn := func(e string) bool {
contains := strings.Contains(proc.Cmd(), e)
return contains
@@ -1620,14 +1653,16 @@ func (a *agent) manageProcessPriority(ctx context.Context) ([]*agentproc.Process
// If the process is prioritized we should adjust
// it's oom_score_adj and avoid lowering its niceness.
if slices.ContainsFunc[[]string, string](prioritizedProcs, containsFn) {
if slices.ContainsFunc(prioritizedProcs, containsFn) {
continue
}
score, err := proc.Niceness(a.syscaller)
if err != nil {
logger.Warn(ctx, "unable to get proc niceness",
slog.Error(err),
score, niceErr := proc.Niceness(a.syscaller)
if niceErr != nil && !xerrors.Is(niceErr, os.ErrPermission) {
debouncer.Warn(ctx, "unable to get proc niceness",
slog.F("cmd", proc.Cmd()),
slog.F("pid", proc.PID),
slog.Error(niceErr),
)
continue
}
@@ -1641,15 +1676,31 @@ func (a *agent) manageProcessPriority(ctx context.Context) ([]*agentproc.Process
continue
}
err = proc.SetNiceness(a.syscaller, niceness)
if err != nil {
logger.Warn(ctx, "unable to set proc niceness",
slog.F("niceness", niceness),
slog.Error(err),
)
continue
if niceErr == nil {
err := proc.SetNiceness(a.syscaller, niceness)
if err != nil && !xerrors.Is(err, os.ErrPermission) {
debouncer.Warn(ctx, "unable to set proc niceness",
slog.F("cmd", proc.Cmd()),
slog.F("pid", proc.PID),
slog.F("niceness", niceness),
slog.Error(err),
)
}
}
// If the oom score is valid and it's not already set and isn't a custom value set by another process then it's ok to update it.
if oomScore != unsetOOMScore && oomScore != proc.OOMScoreAdj && !isCustomOOMScore(agentScore, proc) {
oomScoreStr := strconv.Itoa(oomScore)
err := afero.WriteFile(a.filesystem, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), []byte(oomScoreStr), 0o644)
if err != nil && !xerrors.Is(err, os.ErrPermission) {
debouncer.Warn(ctx, "unable to set oom_score_adj",
slog.F("cmd", proc.Cmd()),
slog.F("pid", proc.PID),
slog.F("score", oomScoreStr),
slog.Error(err),
)
}
}
modProcs = append(modProcs, proc)
}
return modProcs, nil
@@ -1660,52 +1711,87 @@ func (a *agent) isClosed() bool {
return a.hardCtx.Err() != nil
}
func (a *agent) requireNetwork() (*tailnet.Conn, bool) {
a.closeMutex.Lock()
defer a.closeMutex.Unlock()
return a.network, a.network != nil
}
func (a *agent) HandleHTTPDebugMagicsock(w http.ResponseWriter, r *http.Request) {
network, ok := a.requireNetwork()
if !ok {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("network is not ready yet"))
return
}
network.MagicsockServeHTTPDebug(w, r)
}
func (a *agent) HandleHTTPMagicsockDebugLoggingState(w http.ResponseWriter, r *http.Request) {
state := chi.URLParam(r, "state")
stateBool, err := strconv.ParseBool(state)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, "invalid state %q, must be a boolean", state)
return
}
network, ok := a.requireNetwork()
if !ok {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("network is not ready yet"))
return
}
network.MagicsockSetDebugLoggingEnabled(stateBool)
a.logger.Info(r.Context(), "updated magicsock debug logging due to debug request", slog.F("new_state", stateBool))
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "updated magicsock debug logging to %v", stateBool)
}
func (a *agent) HandleHTTPDebugManifest(w http.ResponseWriter, r *http.Request) {
sdkManifest := a.manifest.Load()
if sdkManifest == nil {
a.logger.Error(r.Context(), "no manifest in-memory")
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, "no manifest in-memory")
return
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(sdkManifest); err != nil {
a.logger.Error(a.hardCtx, "write debug manifest", slog.Error(err))
}
}
func (a *agent) HandleHTTPDebugLogs(w http.ResponseWriter, r *http.Request) {
logPath := filepath.Join(a.logDir, "coder-agent.log")
f, err := os.Open(logPath)
if err != nil {
a.logger.Error(r.Context(), "open agent log file", slog.Error(err), slog.F("path", logPath))
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, "could not open log file: %s", err)
return
}
defer f.Close()
// Limit to 10MB.
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, io.LimitReader(f, 10*1024*1024))
if err != nil && !errors.Is(err, io.EOF) {
a.logger.Error(r.Context(), "read agent log file", slog.Error(err))
return
}
}
func (a *agent) HTTPDebug() http.Handler {
r := chi.NewRouter()
requireNetwork := func(w http.ResponseWriter) (*tailnet.Conn, bool) {
a.closeMutex.Lock()
network := a.network
a.closeMutex.Unlock()
if network == nil {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("network is not ready yet"))
return nil, false
}
return network, true
}
r.Get("/debug/magicsock", func(w http.ResponseWriter, r *http.Request) {
network, ok := requireNetwork(w)
if !ok {
return
}
network.MagicsockServeHTTPDebug(w, r)
})
r.Get("/debug/magicsock/debug-logging/{state}", func(w http.ResponseWriter, r *http.Request) {
state := chi.URLParam(r, "state")
stateBool, err := strconv.ParseBool(state)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, "invalid state %q, must be a boolean", state)
return
}
network, ok := requireNetwork(w)
if !ok {
return
}
network.MagicsockSetDebugLoggingEnabled(stateBool)
a.logger.Info(r.Context(), "updated magicsock debug logging due to debug request", slog.F("new_state", stateBool))
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "updated magicsock debug logging to %v", stateBool)
})
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
r.Get("/debug/manifest", a.HandleHTTPDebugManifest)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("404 not found"))
@@ -1945,3 +2031,100 @@ func (a *apiConnRoutineManager) start(name string, b gracefulShutdownBehavior, f
func (a *apiConnRoutineManager) wait() error {
return a.eg.Wait()
}
func PrometheusMetricsHandler(prometheusRegistry *prometheus.Registry, logger slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
// Based on: https://github.com/tailscale/tailscale/blob/280255acae604796a1113861f5a84e6fa2dc6121/ipn/localapi/localapi.go#L489
clientmetric.WritePrometheusExpositionFormat(w)
metricFamilies, err := prometheusRegistry.Gather()
if err != nil {
logger.Error(context.Background(), "prometheus handler failed to gather metric families", slog.Error(err))
return
}
for _, metricFamily := range metricFamilies {
_, err = expfmt.MetricFamilyToText(w, metricFamily)
if err != nil {
logger.Error(context.Background(), "expfmt.MetricFamilyToText failed", slog.Error(err))
return
}
}
})
}
// childOOMScore returns the oom_score_adj for a child process. It is based
// on the oom_score_adj of the agent process.
func childOOMScore(agentScore int) int {
// If the agent has a negative oom_score_adj, we set the child to 0
// so it's treated like every other process.
if agentScore < 0 {
return 0
}
// If the agent is already almost at the maximum then set it to the max.
if agentScore >= 998 {
return 1000
}
// If the agent oom_score_adj is >=0, we set the child to slightly
// less than the maximum. If users want a different score they set it
// directly.
return 998
}
func (a *agent) getAgentOOMScore() (int, error) {
scoreStr, err := afero.ReadFile(a.filesystem, "/proc/self/oom_score_adj")
if err != nil {
return 0, xerrors.Errorf("read file: %w", err)
}
score, err := strconv.Atoi(strings.TrimSpace(string(scoreStr)))
if err != nil {
return 0, xerrors.Errorf("parse int: %w", err)
}
return score, nil
}
// isCustomOOMScore checks to see if the oom_score_adj is not a value that would
// originate from an agent-spawned process.
func isCustomOOMScore(agentScore int, process *agentproc.Process) bool {
score := process.OOMScoreAdj
return agentScore != score && score != 1000 && score != 0 && score != 998
}
// logDebouncer skips writing a log for a particular message if
// it's been emitted within the given interval duration.
// It's a shoddy implementation used in one spot that should be replaced at
// some point.
type logDebouncer struct {
logger slog.Logger
messages map[string]time.Time
interval time.Duration
}
func (l *logDebouncer) Warn(ctx context.Context, msg string, fields ...any) {
l.log(ctx, slog.LevelWarn, msg, fields...)
}
func (l *logDebouncer) Error(ctx context.Context, msg string, fields ...any) {
l.log(ctx, slog.LevelError, msg, fields...)
}
func (l *logDebouncer) log(ctx context.Context, level slog.Level, msg string, fields ...any) {
// This (bad) implementation assumes you wouldn't reuse the same msg
// for different levels.
if last, ok := l.messages[msg]; ok && time.Since(last) < l.interval {
return
}
switch level {
case slog.LevelWarn:
l.logger.Warn(ctx, msg, fields...)
case slog.LevelError:
l.logger.Error(ctx, msg, fields...)
}
l.messages[msg] = time.Now()
}
+133 -16
View File
@@ -46,7 +46,6 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/agentproc/agentproctest"
@@ -55,6 +54,8 @@ import (
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/tailnettest"
@@ -112,7 +113,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
require.NoError(t, err)
defer ptyConn.Close()
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
data, err := json.Marshal(workspacesdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
@@ -1605,7 +1606,7 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
require.NoError(t, tr1.ReadUntil(ctx, matchPrompt), "find prompt")
require.NoError(t, tr2.ReadUntil(ctx, matchPrompt), "find prompt")
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
data, err := json.Marshal(workspacesdk.ReconnectingPTYRequest{
Data: "echo test\r",
})
require.NoError(t, err)
@@ -1633,7 +1634,7 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
require.NoError(t, tr3.ReadUntil(ctx, matchEchoOutput), "find echo output")
// Exit should cause the connection to close.
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
data, err = json.Marshal(workspacesdk.ReconnectingPTYRequest{
Data: "exit\r",
})
require.NoError(t, err)
@@ -1782,7 +1783,7 @@ func TestAgent_UpdatedDERP(t *testing.T) {
})
// Setup a client connection.
newClientConn := func(derpMap *tailcfg.DERPMap, name string) *codersdk.WorkspaceAgentConn {
newClientConn := func(derpMap *tailcfg.DERPMap, name string) *workspacesdk.AgentConn {
conn, err := tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
DERPMap: derpMap,
@@ -1811,9 +1812,9 @@ func TestAgent_UpdatedDERP(t *testing.T) {
// Force DERP.
conn.SetBlockEndpoints(true)
sdkConn := codersdk.NewWorkspaceAgentConn(conn, codersdk.WorkspaceAgentConnOptions{
sdkConn := workspacesdk.NewAgentConn(conn, workspacesdk.AgentConnOptions{
AgentID: agentID,
CloseFunc: func() error { return codersdk.ErrSkipClose },
CloseFunc: func() error { return workspacesdk.ErrSkipClose },
})
t.Cleanup(func() {
t.Logf("closing sdkConn %s", name)
@@ -1974,11 +1975,21 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) {
func TestAgent_DebugServer(t *testing.T) {
t.Parallel()
logDir := t.TempDir()
logPath := filepath.Join(logDir, "coder-agent.log")
randLogStr, err := cryptorand.String(32)
require.NoError(t, err)
require.NoError(t, os.WriteFile(logPath, []byte(randLogStr), 0o600))
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
//nolint:dogsled
conn, _, _, _, agnt := setupAgent(t, agentsdk.Manifest{
DERPMap: derpMap,
}, 0)
}, 0, func(c *agenttest.Client, o *agent.Options) {
o.ExchangeToken = func(context.Context) (string, error) {
return "token", nil
}
o.LogDir = logDir
})
awaitReachableCtx := testutil.Context(t, testutil.WaitLong)
ok := conn.AwaitReachable(awaitReachableCtx)
@@ -2059,6 +2070,40 @@ func TestAgent_DebugServer(t *testing.T) {
require.Contains(t, string(resBody), `invalid state "blah", must be a boolean`)
})
})
t.Run("Manifest", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/manifest", nil)
require.NoError(t, err)
res, err := srv.Client().Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var v agentsdk.Manifest
require.NoError(t, json.NewDecoder(res.Body).Decode(&v))
require.NotNil(t, v)
})
t.Run("Logs", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/logs", nil)
require.NoError(t, err)
res, err := srv.Client().Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
defer res.Body.Close()
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NotEmpty(t, string(resBody))
require.Contains(t, string(resBody), randLogStr)
})
}
func TestAgent_ScriptLogging(t *testing.T) {
@@ -2178,7 +2223,7 @@ func setupSSHSession(
}
func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Duration, opts ...func(*agenttest.Client, *agent.Options)) (
*codersdk.WorkspaceAgentConn,
*workspacesdk.AgentConn,
*agenttest.Client,
<-chan *proto.Stats,
afero.Fs,
@@ -2251,7 +2296,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
t.Logf("error closing in-mem coordination: %s", err.Error())
}
})
agentConn := codersdk.NewWorkspaceAgentConn(conn, codersdk.WorkspaceAgentConnOptions{
agentConn := workspacesdk.NewAgentConn(conn, workspacesdk.AgentConnOptions{
AgentID: metadata.AgentID,
})
t.Cleanup(func() {
@@ -2484,11 +2529,11 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
logger = slog.Make(sloghuman.Sink(io.Discard))
)
requireFileWrite(t, fs, "/proc/self/oom_score_adj", "-500")
// Create some processes.
for i := 0; i < 4; i++ {
// Create a prioritized process. This process should
// have it's oom_score_adj set to -500 and its nice
// score should be untouched.
// Create a prioritized process.
var proc agentproc.Process
if i == 0 {
proc = agentproctest.GenerateProcess(t, fs,
@@ -2506,8 +2551,8 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
},
)
syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil)
syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil)
syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil)
}
syscaller.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
@@ -2526,6 +2571,9 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
})
actualProcs := <-modProcs
require.Len(t, actualProcs, len(expectedProcs)-1)
for _, proc := range actualProcs {
requireFileEquals(t, fs, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), "0")
}
})
t.Run("IgnoreCustomNice", func(t *testing.T) {
@@ -2544,8 +2592,11 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
logger = slog.Make(sloghuman.Sink(io.Discard))
)
err := afero.WriteFile(fs, "/proc/self/oom_score_adj", []byte("0"), 0o644)
require.NoError(t, err)
// Create some processes.
for i := 0; i < 2; i++ {
for i := 0; i < 3; i++ {
proc := agentproctest.GenerateProcess(t, fs)
syscaller.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
@@ -2573,7 +2624,59 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
})
actualProcs := <-modProcs
// We should ignore the process with a custom nice score.
require.Len(t, actualProcs, 1)
require.Len(t, actualProcs, 2)
for _, proc := range actualProcs {
_, ok := expectedProcs[proc.PID]
require.True(t, ok)
requireFileEquals(t, fs, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), "998")
}
})
t.Run("CustomOOMScore", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("Skipping non-linux environment")
}
var (
fs = afero.NewMemMapFs()
ticker = make(chan time.Time)
syscaller = agentproctest.NewMockSyscaller(gomock.NewController(t))
modProcs = make(chan []*agentproc.Process)
logger = slog.Make(sloghuman.Sink(io.Discard))
)
err := afero.WriteFile(fs, "/proc/self/oom_score_adj", []byte("0"), 0o644)
require.NoError(t, err)
// Create some processes.
for i := 0; i < 3; i++ {
proc := agentproctest.GenerateProcess(t, fs)
syscaller.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(nil)
syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil)
syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil)
}
_, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) {
o.Syscaller = syscaller
o.ModifiedProcesses = modProcs
o.EnvironmentVariables = map[string]string{
agent.EnvProcPrioMgmt: "1",
agent.EnvProcOOMScore: "-567",
}
o.Filesystem = fs
o.Logger = logger
o.ProcessManagementTick = ticker
})
actualProcs := <-modProcs
// We should ignore the process with a custom nice score.
require.Len(t, actualProcs, 3)
for _, proc := range actualProcs {
requireFileEquals(t, fs, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), "-567")
}
})
t.Run("DisabledByDefault", func(t *testing.T) {
@@ -2694,3 +2797,17 @@ func requireEcho(t *testing.T, conn net.Conn) {
require.NoError(t, err)
require.Equal(t, "test", string(b))
}
func requireFileWrite(t testing.TB, fs afero.Fs, fp, data string) {
t.Helper()
err := afero.WriteFile(fs, fp, []byte(data), 0o600)
require.NoError(t, err)
}
func requireFileEquals(t testing.TB, fs afero.Fs, fp, expect string) {
t.Helper()
actual, err := afero.ReadFile(fs, fp)
require.NoError(t, err)
require.Equal(t, expect, string(actual))
}
+8 -2
View File
@@ -2,6 +2,7 @@ package agentproctest
import (
"fmt"
"strconv"
"testing"
"github.com/spf13/afero"
@@ -29,8 +30,9 @@ func GenerateProcess(t *testing.T, fs afero.Fs, muts ...func(*agentproc.Process)
cmdline := fmt.Sprintf("%s\x00%s\x00%s", arg1, arg2, arg3)
process := agentproc.Process{
CmdLine: cmdline,
PID: int32(pid),
CmdLine: cmdline,
PID: int32(pid),
OOMScoreAdj: 0,
}
for _, mut := range muts {
@@ -45,5 +47,9 @@ func GenerateProcess(t *testing.T, fs afero.Fs, muts ...func(*agentproc.Process)
err = afero.WriteFile(fs, fmt.Sprintf("%s/cmdline", process.Dir), []byte(process.CmdLine), 0o444)
require.NoError(t, err)
score := strconv.Itoa(process.OOMScoreAdj)
err = afero.WriteFile(fs, fmt.Sprintf("%s/oom_score_adj", process.Dir), []byte(score), 0o444)
require.NoError(t, err)
return process
}
+20 -3
View File
@@ -5,6 +5,7 @@ package agentproc
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
@@ -50,10 +51,26 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
}
return nil, xerrors.Errorf("read cmdline: %w", err)
}
oomScore, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "oom_score_adj"))
if err != nil {
if xerrors.Is(err, os.ErrPermission) {
continue
}
return nil, xerrors.Errorf("read oom_score_adj: %w", err)
}
oom, err := strconv.Atoi(strings.TrimSpace(string(oomScore)))
if err != nil {
return nil, xerrors.Errorf("convert oom score: %w", err)
}
processes = append(processes, &Process{
PID: int32(pid),
CmdLine: string(cmdline),
Dir: filepath.Join(defaultProcDir, entry),
PID: int32(pid),
CmdLine: string(cmdline),
Dir: filepath.Join(defaultProcDir, entry),
OOMScoreAdj: oom,
})
}
+4 -3
View File
@@ -14,7 +14,8 @@ type Syscaller interface {
const defaultProcDir = "/proc"
type Process struct {
Dir string
CmdLine string
PID int32
Dir string
CmdLine string
PID int32
OOMScoreAdj int
}
+6
View File
@@ -35,7 +35,13 @@ func (a *agent) apiHandler() http.Handler {
ignorePorts: cpy,
cacheDuration: cacheDuration,
}
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
r.Get("/api/v0/listening-ports", lp.handler)
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
r.Get("/debug/manifest", a.HandleHTTPDebugManifest)
r.Get("/debug/prometheus", promHandler.ServeHTTP)
return r
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
@@ -32,7 +33,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentL
seen := make(map[uint16]struct{}, len(tabs))
ports := []codersdk.WorkspaceAgentListeningPort{}
for _, tab := range tabs {
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort {
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
continue
}
+2 -3
View File
@@ -14,8 +14,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty"
)
@@ -197,7 +196,7 @@ func (s *ptyState) waitForStateOrContext(ctx context.Context, state State) (Stat
func readConnLoop(ctx context.Context, conn net.Conn, ptty pty.PTYCmd, metrics *prometheus.CounterVec, logger slog.Logger) {
decoder := json.NewDecoder(conn)
for {
var req codersdk.ReconnectingPTYRequest
var req workspacesdk.ReconnectingPTYRequest
err := decoder.Decode(&req)
if xerrors.Is(err, io.EOF) {
return
+59
View File
@@ -1,7 +1,10 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"io"
"net/netip"
"sync"
"testing"
@@ -14,6 +17,7 @@ import (
"tailscale.com/types/netlogtype"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogjson"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/testutil"
@@ -210,3 +214,58 @@ func newFakeStatsDest() *fakeStatsDest {
resps: make(chan *proto.UpdateStatsResponse),
}
}
func Test_logDebouncer(t *testing.T) {
t.Parallel()
var (
buf bytes.Buffer
logger = slog.Make(slogjson.Sink(&buf))
ctx = context.Background()
)
debouncer := &logDebouncer{
logger: logger,
messages: map[string]time.Time{},
interval: time.Minute,
}
fields := map[string]interface{}{
"field_1": float64(1),
"field_2": "2",
}
debouncer.Error(ctx, "my message", "field_1", 1, "field_2", "2")
debouncer.Warn(ctx, "another message", "field_1", 1, "field_2", "2")
// Shouldn't log this.
debouncer.Warn(ctx, "another message", "field_1", 1, "field_2", "2")
require.Len(t, debouncer.messages, 2)
type entry struct {
Msg string `json:"msg"`
Level string `json:"level"`
Fields map[string]interface{} `json:"fields"`
}
assertLog := func(msg string, level string, fields map[string]interface{}) {
line, err := buf.ReadString('\n')
require.NoError(t, err)
var e entry
err = json.Unmarshal([]byte(line), &e)
require.NoError(t, err)
require.Equal(t, msg, e.Msg)
require.Equal(t, level, e.Level)
require.Equal(t, fields, e.Fields)
}
assertLog("my message", "ERROR", fields)
assertLog("another message", "WARN", fields)
debouncer.messages["another message"] = time.Now().Add(-2 * time.Minute)
debouncer.Warn(ctx, "another message", "field_1", 1, "field_2", "2")
assertLog("another message", "WARN", fields)
// Assert nothing else was written.
_, err := buf.ReadString('\n')
require.ErrorIs(t, err, io.EOF)
}
+24 -45
View File
@@ -18,10 +18,8 @@ import (
"cloud.google.com/go/compute/metadata"
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
"tailscale.com/util/clientmetric"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/expfmt"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
@@ -31,12 +29,12 @@ import (
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/reaper"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/serpent"
)
func (r *RootCmd) workspaceAgent() *clibase.Cmd {
func (r *RootCmd) workspaceAgent() *serpent.Command {
var (
auth string
logDir string
@@ -51,12 +49,12 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
slogJSONPath string
slogStackdriverPath string
)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "agent",
Short: `Starts the Coder workspace agent.`,
// This command isn't useful to manually execute.
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
@@ -125,7 +123,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
args := append(os.Args, "--no-reap")
err := reaper.ForkReap(
reaper.WithExecArgs(args...),
reaper.WithCatchSignals(InterruptSignals...),
reaper.WithCatchSignals(StopSignals...),
)
if err != nil {
logger.Error(ctx, "agent process reaper unable to fork", slog.Error(err))
@@ -144,7 +142,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
// 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 := inv.SignalNotifyContext(ctx, InterruptSignals...)
ctx, stopNotify := inv.SignalNotifyContext(ctx, StopSignals...)
defer stopNotify()
// DumpHandler does signal handling, so we call it after the
@@ -285,6 +283,9 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
if v, ok := os.LookupEnv(agent.EnvProcPrioMgmt); ok {
environmentVariables[agent.EnvProcPrioMgmt] = v
}
if v, ok := os.LookupEnv(agent.EnvProcOOMScore); ok {
environmentVariables[agent.EnvProcOOMScore] = v
}
agnt := agent.New(agent.Options{
Client: client,
@@ -315,7 +316,8 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
ModifiedProcesses: nil,
})
prometheusSrvClose := ServeHandler(ctx, logger, prometheusMetricsHandler(prometheusRegistry, logger), prometheusAddress, "prometheus")
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
defer prometheusSrvClose()
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
@@ -326,33 +328,33 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "auth",
Default: "token",
Description: "Specify the authentication type to use for the agent.",
Env: "CODER_AGENT_AUTH",
Value: clibase.StringOf(&auth),
Value: serpent.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),
Value: serpent.StringOf(&logDir),
},
{
Flag: "script-data-dir",
Default: os.TempDir(),
Description: "Specify the location for storing script data.",
Env: "CODER_AGENT_SCRIPT_DATA_DIR",
Value: clibase.StringOf(&scriptDataDir),
Value: serpent.StringOf(&scriptDataDir),
},
{
Flag: "pprof-address",
Default: "127.0.0.1:6060",
Env: "CODER_AGENT_PPROF_ADDRESS",
Value: clibase.StringOf(&pprofAddress),
Value: serpent.StringOf(&pprofAddress),
Description: "The address to serve pprof.",
},
{
@@ -360,7 +362,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
Env: "",
Description: "Do not start a process reaper.",
Value: clibase.BoolOf(&noReap),
Value: serpent.BoolOf(&noReap),
},
{
Flag: "ssh-max-timeout",
@@ -368,27 +370,27 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
Default: "72h",
Env: "CODER_AGENT_SSH_MAX_TIMEOUT",
Description: "Specify the max timeout for a SSH connection, it is advisable to set it to a minimum of 60s, but no more than 72h.",
Value: clibase.DurationOf(&sshMaxTimeout),
Value: serpent.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),
Value: serpent.Int64Of(&tailnetListenPort),
},
{
Flag: "prometheus-address",
Default: "127.0.0.1:2112",
Env: "CODER_AGENT_PROMETHEUS_ADDRESS",
Value: clibase.StringOf(&prometheusAddress),
Value: serpent.StringOf(&prometheusAddress),
Description: "The bind address to serve Prometheus metrics.",
},
{
Flag: "debug-address",
Default: "127.0.0.1:2113",
Env: "CODER_AGENT_DEBUG_ADDRESS",
Value: clibase.StringOf(&debugAddress),
Value: serpent.StringOf(&debugAddress),
Description: "The bind address to serve a debug HTTP server.",
},
{
@@ -397,7 +399,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
Flag: "log-human",
Env: "CODER_AGENT_LOGGING_HUMAN",
Default: "/dev/stderr",
Value: clibase.StringOf(&slogHumanPath),
Value: serpent.StringOf(&slogHumanPath),
},
{
Name: "JSON Log Location",
@@ -405,7 +407,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
Flag: "log-json",
Env: "CODER_AGENT_LOGGING_JSON",
Default: "",
Value: clibase.StringOf(&slogJSONPath),
Value: serpent.StringOf(&slogJSONPath),
},
{
Name: "Stackdriver Log Location",
@@ -413,7 +415,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
Flag: "log-stackdriver",
Env: "CODER_AGENT_LOGGING_STACKDRIVER",
Default: "",
Value: clibase.StringOf(&slogStackdriverPath),
Value: serpent.StringOf(&slogStackdriverPath),
},
}
@@ -501,26 +503,3 @@ func urlPort(u string) (int, error) {
}
return -1, xerrors.Errorf("invalid port: %s", u)
}
func prometheusMetricsHandler(prometheusRegistry *prometheus.Registry, logger slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
// Based on: https://github.com/tailscale/tailscale/blob/280255acae604796a1113861f5a84e6fa2dc6121/ipn/localapi/localapi.go#L489
clientmetric.WritePrometheusExpositionFormat(w)
metricFamilies, err := prometheusRegistry.Gather()
if err != nil {
logger.Error(context.Background(), "Prometheus handler can't gather metric families", slog.Error(err))
return
}
for _, metricFamily := range metricFamilies {
_, err = expfmt.MetricFamilyToText(w, metricFamily)
if err != nil {
logger.Error(context.Background(), "expfmt.MetricFamilyToText failed", slog.Error(err))
return
}
}
})
}
+6 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
@@ -91,7 +92,8 @@ func TestWorkspaceAgent(t *testing.T) {
if assert.NotEmpty(t, workspace.LatestBuild.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 := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer dialer.Close()
require.True(t, dialer.AwaitReachable(ctx))
@@ -130,7 +132,8 @@ func TestWorkspaceAgent(t *testing.T) {
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 := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer dialer.Close()
require.True(t, dialer.AwaitReachable(ctx))
@@ -173,7 +176,7 @@ func TestWorkspaceAgent(t *testing.T) {
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 := workspacesdk.New(client).DialAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer dialer.Close()
require.True(t, dialer.AwaitReachable(ctx))
+6 -6
View File
@@ -6,22 +6,22 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) autoupdate() *clibase.Cmd {
func (r *RootCmd) autoupdate() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "autoupdate <workspace> <always|never>",
Short: "Toggle auto-update policy for a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
Middleware: serpent.Chain(
serpent.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
policy := strings.ToLower(inv.Args[1])
err := validateAutoUpdatePolicy(policy)
if err != nil {
-80
View File
@@ -1,80 +0,0 @@
// 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"`
YAML string `json:"yaml,omitempty"`
Description string `json:"description,omitempty"`
}
// 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
}
-633
View File
@@ -1,633 +0,0 @@
package clibase
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"testing"
"unicode"
"cdr.dev/slog"
"github.com/spf13/pflag"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
"github.com/coder/coder/v2/coderd/util/slice"
)
// 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
for i := range c.Options {
opt := &c.Options[i]
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.Options, func(a, b Option) int {
return slice.Ascending(a.Name, b.Name)
})
slices.SortFunc(c.Children, func(a, b *Cmd) int {
return slice.Ascending(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, " ")
}
// FullOptions returns the options of the command and its parents.
func (c *Cmd) FullOptions() OptionSet {
var opts OptionSet
if c.Parent != nil {
opts = append(opts, c.Parent.FullOptions()...)
}
opts = append(opts, c.Options...)
return opts
}
// 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(""),
Logger: slog.Make(),
}
}
// 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
Logger slog.Logger
Net Net
// testing
signalNotifyContext func(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc)
}
// 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(), "")
i.Net = osNet{}
})
}
// WithTestSignalNotifyContext allows overriding the default implementation of SignalNotifyContext.
// This should only be used in testing.
func (inv *Invocation) WithTestSignalNotifyContext(
_ testing.TB, // ensure we only call this from tests
f func(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc),
) *Invocation {
return inv.with(func(i *Invocation) {
i.signalNotifyContext = f
})
}
// SignalNotifyContext is equivalent to signal.NotifyContext, but supports being overridden in
// tests.
func (inv *Invocation) SignalNotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
if inv.signalNotifyContext == nil {
return signal.NotifyContext(parent, signals...)
}
return inv.signalNotifyContext(parent, signals...)
}
func (inv *Invocation) WithTestParsedFlags(
_ testing.TB, // ensure we only call this from tests
parsedFlags *pflag.FlagSet,
) *Invocation {
return inv.with(func(i *Invocation) {
i.parsedFlags = parsedFlags
})
}
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 value sources for flags.
for i, opt := range inv.Command.Options {
if fl := inv.parsedFlags.Lookup(opt.Flag); fl != nil && fl.Changed {
inv.Command.Options[i].ValueSource = ValueSourceFlag
}
}
// Read YAML configs, if any.
for _, opt := range inv.Command.Options {
path, ok := opt.Value.(*YAMLConfigPath)
if !ok || path.String() == "" {
continue
}
byt, err := os.ReadFile(path.String())
if err != nil {
return xerrors.Errorf("reading yaml: %w", err)
}
var n yaml.Node
err = yaml.Unmarshal(byt, &n)
if err != nil {
return xerrors.Errorf("decoding yaml: %w", err)
}
err = inv.Command.Options.UnmarshalYAML(&n)
if err != nil {
return xerrors.Errorf("applying yaml: %w", err)
}
}
err = inv.Command.Options.SetDefaults()
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,
)
}
// All options should be set. Check all required options have sources,
// meaning they were set by the user in some way (env, flag, etc).
var missing []string
for _, opt := range inv.Command.Options {
if opt.Required && opt.ValueSource == ValueSourceNone {
missing = append(missing, opt.Flag)
}
}
// Don't error for missing flags if `--help` was supplied.
if len(missing) > 0 && !errors.Is(state.flagParseErr, pflag.ErrHelp) {
return xerrors.Errorf("Missing values for the required flags: %s", strings.Join(missing, ", "))
}
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)
}
}()
// We close Stdin to prevent deadlocks, e.g. when the command
// has ended but an io.Copy is still reading from Stdin.
defer func() {
if inv.Stdin == nil {
return
}
rc, ok := inv.Stdin.(io.ReadCloser)
if !ok {
return
}
e := rc.Close()
err = errors.Join(err, e)
}()
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 ShowUsageOnError(next HandlerFunc) HandlerFunc {
return func(i *Invocation) error {
err := next(i)
if err != nil {
return xerrors.Errorf("Usage: %s\nError: %w", i.Command.FullUsage(), err)
}
return nil
}
}
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 {
// ShowUsageOnError will add the command usage before the error message.
return ShowUsageOnError(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
-735
View File
@@ -1,735 +0,0 @@
package clibase_test
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/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
reqBool bool
reqStr 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: "required-flag --req-bool=true --req-string=foo",
Short: "Example with required flags",
Options: clibase.OptionSet{
clibase.Option{
Name: "req-bool",
Flag: "req-bool",
Value: clibase.BoolOf(&reqBool),
Required: true,
},
clibase.Option{
Name: "req-string",
Flag: "req-string",
Value: clibase.Validate(clibase.StringOf(&reqStr), func(value *clibase.String) error {
ok := strings.Contains(value.String(), " ")
if !ok {
return xerrors.Errorf("string must contain a space")
}
return nil
}),
Required: true,
},
},
HelpHandler: func(i *clibase.Invocation) error {
_, _ = i.Stdout.Write([]byte("help text.png"))
return nil
},
Handler: func(i *clibase.Invocation) error {
_, _ = i.Stdout.Write([]byte(fmt.Sprintf("%s-%t", reqStr, reqBool)))
return nil
},
},
{
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())
})
t.Run("RequiredFlagsMissing", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag",
)
fio := fakeIO(i)
err := i.Run()
require.Error(t, err, fio.Stdout.String())
require.ErrorContains(t, err, "Missing values")
})
t.Run("RequiredFlagsMissingWithHelp", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag",
"--help",
)
fio := fakeIO(i)
err := i.Run()
require.NoError(t, err)
require.Contains(t, fio.Stdout.String(), "help text.png")
})
t.Run("RequiredFlagsMissingBool", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag", "--req-string", "foo bar",
)
fio := fakeIO(i)
err := i.Run()
require.Error(t, err, fio.Stdout.String())
require.ErrorContains(t, err, "Missing values for the required flags: req-bool")
})
t.Run("RequiredFlagsMissingString", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag", "--req-bool", "true",
)
fio := fakeIO(i)
err := i.Run()
require.Error(t, err, fio.Stdout.String())
require.ErrorContains(t, err, "Missing values for the required flags: req-string")
})
t.Run("RequiredFlagsInvalid", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag", "--req-string", "nospace",
)
fio := fakeIO(i)
err := i.Run()
require.Error(t, err, fio.Stdout.String())
require.ErrorContains(t, err, "string must contain a space")
})
t.Run("RequiredFlagsOK", func(t *testing.T) {
t.Parallel()
i := cmd().Invoke(
"required-flag", "--req-bool", "true", "--req-string", "foo bar",
)
fio := fakeIO(i)
err := i.Run()
require.NoError(t, err, 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()
test := func(name string, want string, fn func(t *testing.T, inv *clibase.Invocation)) {
t.Run(name, func(t *testing.T) {
t.Parallel()
var (
got string
config clibase.YAMLConfigPath
)
cmd := &clibase.Cmd{
Options: clibase.OptionSet{
{
Name: "url",
Flag: "url",
Default: "def.com",
Env: "URL",
Value: clibase.StringOf(&got),
YAML: "url",
},
{
Name: "config",
Flag: "config",
Default: "",
Value: &config,
},
},
Handler: (func(i *clibase.Invocation) error {
_, _ = fmt.Fprintf(i.Stdout, "%s", got)
return nil
}),
}
inv := cmd.Invoke()
stdio := fakeIO(inv)
fn(t, inv)
err := inv.Run()
require.NoError(t, err)
require.Equal(t, want, stdio.Stdout.String())
})
}
test("DefaultOverNothing", "def.com", func(t *testing.T, inv *clibase.Invocation) {})
test("FlagOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
inv.Args = []string{"--url", "good.com"}
})
test("EnvOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
inv.Environ.Set("URL", "good.com")
})
test("FlagOverEnv", "good.com", func(t *testing.T, inv *clibase.Invocation) {
inv.Environ.Set("URL", "bad.com")
inv.Args = []string{"--url", "good.com"}
})
test("FlagOverYAML", "good.com", func(t *testing.T, inv *clibase.Invocation) {
fi, err := os.CreateTemp(t.TempDir(), "config.yaml")
require.NoError(t, err)
defer fi.Close()
_, err = fi.WriteString("url: bad.com")
require.NoError(t, err)
inv.Args = []string{"--config", fi.Name(), "--url", "good.com"}
})
test("YAMLOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
fi, err := os.CreateTemp(t.TempDir(), "config.yaml")
require.NoError(t, err)
defer fi.Close()
_, err = fi.WriteString("url: good.com")
require.NoError(t, err)
inv.Args = []string{"--config", fi.Name()}
})
}
-76
View File
@@ -1,76 +0,0 @@
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
@@ -1,44 +0,0 @@
package clibase_test
import (
"reflect"
"testing"
"github.com/coder/coder/v2/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)
}
})
}
}
-50
View File
@@ -1,50 +0,0 @@
package clibase
import (
"net"
"strconv"
"github.com/pion/udp"
"golang.org/x/xerrors"
)
// Net abstracts CLI commands interacting with the operating system networking.
//
// At present, it covers opening local listening sockets, since doing this
// in testing is a challenge without flakes, since it's hard to pick a port we
// know a priori will be free.
type Net interface {
// Listen has the same semantics as `net.Listen` but also supports `udp`
Listen(network, address string) (net.Listener, error)
}
// osNet is an implementation that call the real OS for networking.
type osNet struct{}
func (osNet) Listen(network, address string) (net.Listener, error) {
switch network {
case "tcp", "tcp4", "tcp6", "unix", "unixpacket":
return net.Listen(network, address)
case "udp":
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, xerrors.Errorf("split %q: %w", address, err)
}
var portInt int
portInt, err = strconv.Atoi(port)
if err != nil {
return nil, xerrors.Errorf("parse port %v from %q as int: %w", port, address, err)
}
// Use pion here so that we get a stream-style net.Conn listener, instead
// of a packet-oriented connection that can read and write to multiple
// addresses.
return udp.Listen(network, &net.UDPAddr{
IP: net.ParseIP(host),
Port: portInt,
})
default:
return nil, xerrors.Errorf("unknown listen network %q", network)
}
}
-346
View File
@@ -1,346 +0,0 @@
package clibase
import (
"bytes"
"encoding/json"
"os"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/spf13/pflag"
"golang.org/x/xerrors"
)
type ValueSource string
const (
ValueSourceNone ValueSource = ""
ValueSourceFlag ValueSource = "flag"
ValueSourceEnv ValueSource = "env"
ValueSourceYAML ValueSource = "yaml"
ValueSourceDefault ValueSource = "default"
)
// Option is a configuration option for a CLI application.
type Option struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
// Required means this value must be set by some means. It requires
// `ValueSource != ValueSourceNone`
// If `Default` is set, then `Required` is ignored.
Required bool `json:"required,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"`
ValueSource ValueSource `json:"value_source,omitempty"`
}
// optionNoMethods is just a wrapper around Option so we can defer to the
// default json.Unmarshaler behavior.
type optionNoMethods Option
func (o *Option) UnmarshalJSON(data []byte) error {
// If an option has no values, we have no idea how to unmarshal it.
// So just discard the json data.
if o.Value == nil {
o.Value = &DiscardValue
}
return json.Unmarshal(data, (*optionNoMethods)(o))
}
func (o Option) YAMLPath() string {
if o.YAML == "" {
return ""
}
var gs []string
for _, g := range o.Group.Ancestry() {
gs = append(gs, g.YAML)
}
return strings.Join(append(gs, o.YAML), ".")
}
// OptionSet is a group of options that can be applied to a command.
type OptionSet []Option
// UnmarshalJSON implements json.Unmarshaler for OptionSets. Options have an
// interface Value type that cannot handle unmarshalling because the types cannot
// be inferred. Since it is a slice, instantiating the Options first does not
// help.
//
// However, we typically do instantiate the slice to have the correct types.
// So this unmarshaller will attempt to find the named option in the existing
// set, if it cannot, the value is discarded. If the option exists, the value
// is unmarshalled into the existing option, and replaces the existing option.
//
// The value is discarded if it's type cannot be inferred. This behavior just
// feels "safer", although it should never happen if the correct option set
// is passed in. The situation where this could occur is if a client and server
// are on different versions with different options.
func (optSet *OptionSet) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewBuffer(data))
// Should be a json array, so consume the starting open bracket.
t, err := dec.Token()
if err != nil {
return xerrors.Errorf("read array open bracket: %w", err)
}
if t != json.Delim('[') {
return xerrors.Errorf("expected array open bracket, got %q", t)
}
// As long as json elements exist, consume them. The counter is used for
// better errors.
var i int
OptionSetDecodeLoop:
for dec.More() {
var opt Option
// jValue is a placeholder value that allows us to capture the
// raw json for the value to attempt to unmarshal later.
var jValue jsonValue
opt.Value = &jValue
err := dec.Decode(&opt)
if err != nil {
return xerrors.Errorf("decode %d option: %w", i, err)
}
// This counter is used to contextualize errors to show which element of
// the array we failed to decode. It is only used in the error above, as
// if the above works, we can instead use the Option.Name which is more
// descriptive and useful. So increment here for the next decode.
i++
// Try to see if the option already exists in the option set.
// If it does, just update the existing option.
for optIndex, have := range *optSet {
if have.Name == opt.Name {
if jValue != nil {
err := json.Unmarshal(jValue, &(*optSet)[optIndex].Value)
if err != nil {
return xerrors.Errorf("decode option %q value: %w", have.Name, err)
}
// Set the opt's value
opt.Value = (*optSet)[optIndex].Value
} else {
// Hopefully the user passed empty values in the option set. There is no easy way
// to tell, and if we do not do this, it breaks json.Marshal if we do it again on
// this new option set.
opt.Value = (*optSet)[optIndex].Value
}
// Override the existing.
(*optSet)[optIndex] = opt
// Go to the next option to decode.
continue OptionSetDecodeLoop
}
}
// If the option doesn't exist, the value will be discarded.
// We do this because we cannot infer the type of the value.
opt.Value = DiscardValue
*optSet = append(*optSet, opt)
}
t, err = dec.Token()
if err != nil {
return xerrors.Errorf("read array close bracket: %w", err)
}
if t != json.Delim(']') {
return xerrors.Errorf("expected array close bracket, got %q", t)
}
return nil
}
// Add adds the given Options to the OptionSet.
func (optSet *OptionSet) Add(opts ...Option) {
*optSet = append(*optSet, opts...)
}
// Filter will only return options that match the given filter. (return true)
func (optSet OptionSet) Filter(filter func(opt Option) bool) OptionSet {
cpy := make(OptionSet, 0)
for _, opt := range optSet {
if filter(opt) {
cpy = append(cpy, opt)
}
}
return cpy
}
// FlagSet returns a pflag.FlagSet for the OptionSet.
func (optSet *OptionSet) FlagSet() *pflag.FlagSet {
if optSet == nil {
return &pflag.FlagSet{}
}
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
for _, opt := range *optSet {
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 (optSet *OptionSet) ParseEnv(vs []EnvVar) error {
if optSet == 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 *optSet {
if opt.Env == "" {
continue
}
envVal, ok := envs[opt.Env]
if !ok {
// Homebrew strips all environment variables that do not start with `HOMEBREW_`.
// This prevented using brew to invoke the Coder agent, because the environment
// variables to not get passed down.
//
// A customer wanted to use their custom tap inside a workspace, which was failing
// because the agent lacked the environment variables to authenticate with Git.
envVal, ok = envs[`HOMEBREW_`+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
}
(*optSet)[i].ValueSource = ValueSourceEnv
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 already have a value source.
func (optSet *OptionSet) SetDefaults() error {
if optSet == nil {
return nil
}
var merr *multierror.Error
for i, opt := range *optSet {
// Skip values that may have already been set by the user.
if opt.ValueSource != ValueSourceNone {
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
}
(*optSet)[i].ValueSource = ValueSourceDefault
if err := opt.Value.Set(opt.Default); err != nil {
merr = multierror.Append(
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
)
}
}
return merr.ErrorOrNil()
}
// ByName returns the Option with the given name, or nil if no such option
// exists.
func (optSet *OptionSet) ByName(name string) *Option {
for i := range *optSet {
opt := &(*optSet)[i]
if opt.Name == name {
return opt
}
}
return nil
}
-391
View File
@@ -1,391 +0,0 @@
package clibase_test
import (
"bytes"
"encoding/json"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
)
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()
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)
})
t.Run("RegexValid", func(t *testing.T) {
t.Parallel()
var regexpString clibase.Regexp
os := clibase.OptionSet{
clibase.Option{
Name: "RegexpString",
Value: &regexpString,
Flag: "regexp-string",
},
}
err := os.FlagSet().Parse([]string{"--regexp-string", "$test^"})
require.NoError(t, err)
})
t.Run("RegexInvalid", func(t *testing.T) {
t.Parallel()
var regexpString clibase.Regexp
os := clibase.OptionSet{
clibase.Option{
Name: "RegexpString",
Value: &regexpString,
Flag: "regexp-string",
},
}
err := os.FlagSet().Parse([]string{"--regexp-string", "(("})
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()
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()
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()
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)
})
t.Run("Homebrew", func(t *testing.T) {
t.Parallel()
var agentToken clibase.String
os := clibase.OptionSet{
clibase.Option{
Name: "Agent Token",
Value: &agentToken,
Env: "AGENT_TOKEN",
},
}
err := os.ParseEnv([]clibase.EnvVar{
{Name: "HOMEBREW_AGENT_TOKEN", Value: "foo"},
})
require.NoError(t, err)
require.EqualValues(t, "foo", agentToken)
})
}
func TestOptionSet_JsonMarshal(t *testing.T) {
t.Parallel()
// This unit test ensures if the source optionset is missing the option
// and cannot determine the type, it will not panic. The unmarshal will
// succeed with a best effort.
t.Run("MissingSrcOption", func(t *testing.T) {
t.Parallel()
var str clibase.String = "something"
var arr clibase.StringArray = []string{"foo", "bar"}
opts := clibase.OptionSet{
clibase.Option{
Name: "StringOpt",
Value: &str,
},
clibase.Option{
Name: "ArrayOpt",
Value: &arr,
},
}
data, err := json.Marshal(opts)
require.NoError(t, err, "marshal option set")
tgt := clibase.OptionSet{}
err = json.Unmarshal(data, &tgt)
require.NoError(t, err, "unmarshal option set")
for i := range opts {
compareOptionsExceptValues(t, opts[i], tgt[i])
require.Empty(t, tgt[i].Value.String(), "unknown value types are empty")
}
})
t.Run("RegexCase", func(t *testing.T) {
t.Parallel()
val := clibase.Regexp(*regexp.MustCompile(".*"))
opts := clibase.OptionSet{
clibase.Option{
Name: "Regex",
Value: &val,
Default: ".*",
},
}
data, err := json.Marshal(opts)
require.NoError(t, err, "marshal option set")
var foundVal clibase.Regexp
newOpts := clibase.OptionSet{
clibase.Option{
Name: "Regex",
Value: &foundVal,
},
}
err = json.Unmarshal(data, &newOpts)
require.NoError(t, err, "unmarshal option set")
require.EqualValues(t, opts[0].Value.String(), newOpts[0].Value.String())
})
t.Run("AllValues", func(t *testing.T) {
t.Parallel()
vals := coderdtest.DeploymentValues(t)
opts := vals.Options()
sources := []clibase.ValueSource{
clibase.ValueSourceNone,
clibase.ValueSourceFlag,
clibase.ValueSourceEnv,
clibase.ValueSourceYAML,
clibase.ValueSourceDefault,
}
for i := range opts {
opts[i].ValueSource = sources[i%len(sources)]
}
data, err := json.Marshal(opts)
require.NoError(t, err, "marshal option set")
newOpts := (&codersdk.DeploymentValues{}).Options()
err = json.Unmarshal(data, &newOpts)
require.NoError(t, err, "unmarshal option set")
for i := range opts {
exp := opts[i]
found := newOpts[i]
compareOptionsExceptValues(t, exp, found)
compareValues(t, exp, found)
}
thirdOpts := (&codersdk.DeploymentValues{}).Options()
data, err = json.Marshal(newOpts)
require.NoError(t, err, "marshal option set")
err = json.Unmarshal(data, &thirdOpts)
require.NoError(t, err, "unmarshal option set")
// Compare to the original opts again
for i := range opts {
exp := opts[i]
found := thirdOpts[i]
compareOptionsExceptValues(t, exp, found)
compareValues(t, exp, found)
}
})
}
func compareOptionsExceptValues(t *testing.T, exp, found clibase.Option) {
t.Helper()
require.Equalf(t, exp.Name, found.Name, "option name %q", exp.Name)
require.Equalf(t, exp.Description, found.Description, "option description %q", exp.Name)
require.Equalf(t, exp.Required, found.Required, "option required %q", exp.Name)
require.Equalf(t, exp.Flag, found.Flag, "option flag %q", exp.Name)
require.Equalf(t, exp.FlagShorthand, found.FlagShorthand, "option flag shorthand %q", exp.Name)
require.Equalf(t, exp.Env, found.Env, "option env %q", exp.Name)
require.Equalf(t, exp.YAML, found.YAML, "option yaml %q", exp.Name)
require.Equalf(t, exp.Default, found.Default, "option default %q", exp.Name)
require.Equalf(t, exp.ValueSource, found.ValueSource, "option value source %q", exp.Name)
require.Equalf(t, exp.Hidden, found.Hidden, "option hidden %q", exp.Name)
require.Equalf(t, exp.Annotations, found.Annotations, "option annotations %q", exp.Name)
require.Equalf(t, exp.Group, found.Group, "option group %q", exp.Name)
// UseInstead is the same comparison problem, just check the length
require.Equalf(t, len(exp.UseInstead), len(found.UseInstead), "option use instead %q", exp.Name)
}
func compareValues(t *testing.T, exp, found clibase.Option) {
t.Helper()
if (exp.Value == nil || found.Value == nil) || (exp.Value.String() != found.Value.String() && found.Value.String() == "") {
// If the string values are different, this can be a "nil" issue.
// So only run this case if the found string is the empty string.
// We use MarshalYAML for struct strings, and it will return an
// empty string '""' for nil slices/maps/etc.
// So use json to compare.
expJSON, err := json.Marshal(exp.Value)
require.NoError(t, err, "marshal")
foundJSON, err := json.Marshal(found.Value)
require.NoError(t, err, "marshal")
expJSON = normalizeJSON(expJSON)
foundJSON = normalizeJSON(foundJSON)
assert.Equalf(t, string(expJSON), string(foundJSON), "option value %q", exp.Name)
} else {
assert.Equal(t,
exp.Value.String(),
found.Value.String(),
"option value %q", exp.Name)
}
}
// normalizeJSON handles the fact that an empty map/slice is not the same
// as a nil empty/slice. For our purposes, they are the same.
func normalizeJSON(data []byte) []byte {
if bytes.Equal(data, []byte("[]")) || bytes.Equal(data, []byte("{}")) {
return []byte("null")
}
return data
}
-593
View File
@@ -1,593 +0,0 @@
package clibase
import (
"encoding/csv"
"encoding/json"
"fmt"
"net"
"net/url"
"reflect"
"regexp"
"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
}
// Validator is a wrapper around a pflag.Value that allows for validation
// of the value after or before it has been set.
type Validator[T pflag.Value] struct {
Value T
// validate is called after the value is set.
validate func(T) error
}
func Validate[T pflag.Value](opt T, validate func(value T) error) *Validator[T] {
return &Validator[T]{Value: opt, validate: validate}
}
func (i *Validator[T]) String() string {
return i.Value.String()
}
func (i *Validator[T]) Set(input string) error {
err := i.Value.Set(input)
if err != nil {
return err
}
if i.validate != nil {
err = i.validate(i.Value)
if err != nil {
return err
}
}
return nil
}
func (i *Validator[T]) Type() string {
return i.Value.Type()
}
func (i *Validator[T]) MarshalYAML() (interface{}, error) {
m, ok := any(i.Value).(yaml.Marshaler)
if !ok {
return i.Value, nil
}
return m.MarshalYAML()
}
func (i *Validator[T]) UnmarshalYAML(n *yaml.Node) error {
return n.Decode(i.Value)
}
func (i *Validator[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(i.Value)
}
func (i *Validator[T]) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, i.Value)
}
func (i *Validator[T]) Underlying() pflag.Value { return i.Value }
// 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"
}
func (d *Duration) MarshalYAML() (interface{}, error) {
return yaml.Node{
Kind: yaml.ScalarNode,
Value: d.String(),
}, nil
}
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
return d.Set(n.Value)
}
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) MarshalYAML() (interface{}, error) {
return yaml.Node{
Kind: yaml.ScalarNode,
Value: u.String(),
}, nil
}
func (u *URL) UnmarshalYAML(n *yaml.Node) error {
return u.Set(n.Value)
}
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 (hp *HostPort) MarshalYAML() (interface{}, error) {
return yaml.Node{
Kind: yaml.ScalarNode,
Value: hp.String(),
}, nil
}
func (hp *HostPort) UnmarshalYAML(n *yaml.Node) error {
return hp.Set(n.Value)
}
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
}
//nolint:revive
func (s *Struct[T]) Set(v string) error {
return yaml.Unmarshal([]byte(v), &s.Value)
}
//nolint:revive
func (s *Struct[T]) String() string {
byt, err := yaml.Marshal(s.Value)
if err != nil {
return "decode failed: " + err.Error()
}
return string(byt)
}
// nolint:revive
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
}
// nolint:revive
func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
// HACK: for compatibility with flags, we use nil slices instead of empty
// slices. In most cases, nil slices and empty slices are treated
// the same, so this behavior may be removed at some point.
if typ := reflect.TypeOf(s.Value); typ.Kind() == reflect.Slice && len(n.Content) == 0 {
reflect.ValueOf(&s.Value).Elem().Set(reflect.Zero(typ))
return nil
}
return n.Decode(&s.Value)
}
//nolint:revive
func (s *Struct[T]) Type() string {
return fmt.Sprintf("struct[%T]", s.Value)
}
// nolint:revive
func (s *Struct[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(s.Value)
}
// nolint:revive
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"
}
func (discardValue) UnmarshalJSON([]byte) error {
return nil
}
// jsonValue is intentionally not exported. It is just used to store the raw JSON
// data for a value to defer it's unmarshal. It implements the pflag.Value to be
// usable in an Option.
type jsonValue json.RawMessage
func (jsonValue) Set(string) error {
return xerrors.Errorf("json value is read-only")
}
func (jsonValue) String() string {
return ""
}
func (jsonValue) Type() string {
return "json"
}
func (j *jsonValue) UnmarshalJSON(data []byte) error {
if j == nil {
return xerrors.New("json.RawMessage: UnmarshalJSON on nil pointer")
}
*j = append((*j)[0:0], data...)
return nil
}
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
}
type Regexp regexp.Regexp
func (r *Regexp) MarshalJSON() ([]byte, error) {
return json.Marshal(r.String())
}
func (r *Regexp) UnmarshalJSON(data []byte) error {
var source string
err := json.Unmarshal(data, &source)
if err != nil {
return err
}
exp, err := regexp.Compile(source)
if err != nil {
return xerrors.Errorf("invalid regex expression: %w", err)
}
*r = Regexp(*exp)
return nil
}
func (r *Regexp) MarshalYAML() (interface{}, error) {
return yaml.Node{
Kind: yaml.ScalarNode,
Value: r.String(),
}, nil
}
func (r *Regexp) UnmarshalYAML(n *yaml.Node) error {
return r.Set(n.Value)
}
func (r *Regexp) Set(v string) error {
exp, err := regexp.Compile(v)
if err != nil {
return xerrors.Errorf("invalid regex expression: %w", err)
}
*r = Regexp(*exp)
return nil
}
func (r Regexp) String() string {
return r.Value().String()
}
func (r *Regexp) Value() *regexp.Regexp {
if r == nil {
return nil
}
return (*regexp.Regexp)(r)
}
func (Regexp) Type() string {
return "regexp"
}
var _ pflag.Value = (*YAMLConfigPath)(nil)
// YAMLConfigPath is a special value type that encodes a path to a YAML
// configuration file where options are read from.
type YAMLConfigPath string
func (p *YAMLConfigPath) Set(v string) error {
*p = YAMLConfigPath(v)
return nil
}
func (p *YAMLConfigPath) String() string {
return string(*p)
}
func (*YAMLConfigPath) Type() string {
return "yaml-config-path"
}
-299
View File
@@ -1,299 +0,0 @@
package clibase
import (
"errors"
"fmt"
"strings"
"github.com/mitchellh/go-wordwrap"
"github.com/spf13/pflag"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
var (
_ yaml.Marshaler = new(OptionSet)
_ yaml.Unmarshaler = new(OptionSet)
)
// 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)
}
// MarshalYAML 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.
//
// It is isomorphic with FromYAML.
func (optSet *OptionSet) MarshalYAML() (any, error) {
root := yaml.Node{
Kind: yaml.MappingNode,
}
for _, opt := range *optSet {
if opt.YAML == "" {
continue
}
defValue := opt.Default
if defValue == "" {
defValue = "<unset>"
}
comment := wordwrap.WrapString(
fmt.Sprintf("%s\n(default: %s, type: %s)", opt.Description, defValue, opt.Value.Type()),
80,
)
nameNode := yaml.Node{
Kind: yaml.ScalarNode,
Value: opt.YAML,
HeadComment: comment,
}
_, isValidator := opt.Value.(interface{ Underlying() pflag.Value })
var valueNode yaml.Node
if opt.Value == nil {
valueNode = yaml.Node{
Kind: yaml.ScalarNode,
Value: "null",
}
} else if m, ok := opt.Value.(yaml.Marshaler); ok && !isValidator {
// Validators do a wrap, and should be handled by the else statement.
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 {
// The all-other types case.
//
// A bit of a hack, we marshal and then unmarshal to get
// the underlying node.
byt, err := yaml.Marshal(opt.Value)
if err != nil {
return nil, xerrors.Errorf(
"marshal %q: %w", opt.Name, err,
)
}
var docNode yaml.Node
err = yaml.Unmarshal(byt, &docNode)
if err != nil {
return nil, xerrors.Errorf(
"unmarshal %q: %w", opt.Name, err,
)
}
if len(docNode.Content) != 1 {
return nil, xerrors.Errorf(
"unmarshal %q: expected one node, got %d",
opt.Name, len(docNode.Content),
)
}
valueNode = *docNode.Content[0]
}
var group []string
for _, g := range opt.Group.Ancestry() {
if g.YAML == "" {
return nil, xerrors.Errorf(
"group yaml name is empty for %q, groups: %+v",
opt.Name,
opt.Group,
)
}
group = append(group, g.YAML)
}
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
}
// mapYAMLNodes converts parent into a map with keys of form "group.subgroup.option"
// and values as the corresponding YAML nodes.
func mapYAMLNodes(parent *yaml.Node) (map[string]*yaml.Node, error) {
if parent.Kind != yaml.MappingNode {
return nil, xerrors.Errorf("expected mapping node, got type %v", parent.Kind)
}
if len(parent.Content)%2 != 0 {
return nil, xerrors.Errorf("expected an even number of k/v pairs, got %d", len(parent.Content))
}
var (
key string
m = make(map[string]*yaml.Node, len(parent.Content)/2)
merr error
)
for i, child := range parent.Content {
if i%2 == 0 {
if child.Kind != yaml.ScalarNode {
// We immediately because the rest of the code is bound to fail
// if we don't know to expect a key or a value.
return nil, xerrors.Errorf("expected scalar node for key, got type %v", child.Kind)
}
key = child.Value
continue
}
// We don't know if this is a grouped simple option or complex option,
// so we store both "key" and "group.key". Since we're storing pointers,
// the additional memory is of little concern.
m[key] = child
if child.Kind != yaml.MappingNode {
continue
}
sub, err := mapYAMLNodes(child)
if err != nil {
merr = errors.Join(merr, xerrors.Errorf("mapping node %q: %w", key, err))
continue
}
for k, v := range sub {
m[key+"."+k] = v
}
}
return m, nil
}
func (o *Option) setFromYAMLNode(n *yaml.Node) error {
o.ValueSource = ValueSourceYAML
if um, ok := o.Value.(yaml.Unmarshaler); ok {
return um.UnmarshalYAML(n)
}
switch n.Kind {
case yaml.ScalarNode:
return o.Value.Set(n.Value)
case yaml.SequenceNode:
// We treat empty values as nil for consistency with other option
// mechanisms.
if len(n.Content) == 0 {
o.Value = nil
return nil
}
return n.Decode(o.Value)
case yaml.MappingNode:
return xerrors.Errorf("mapping nodes must implement yaml.Unmarshaler")
default:
return xerrors.Errorf("unexpected node kind %v", n.Kind)
}
}
// UnmarshalYAML converts the given YAML node into the option set.
// It is isomorphic with ToYAML.
func (optSet *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error {
// The rootNode will be a DocumentNode if it's read from a file. We do
// not support multiple documents in a single file.
if rootNode.Kind == yaml.DocumentNode {
if len(rootNode.Content) != 1 {
return xerrors.Errorf("expected one node in document, got %d", len(rootNode.Content))
}
rootNode = rootNode.Content[0]
}
yamlNodes, err := mapYAMLNodes(rootNode)
if err != nil {
return xerrors.Errorf("mapping nodes: %w", err)
}
matchedNodes := make(map[string]*yaml.Node, len(yamlNodes))
var merr error
for i := range *optSet {
opt := &(*optSet)[i]
if opt.YAML == "" {
continue
}
var group []string
for _, g := range opt.Group.Ancestry() {
if g.YAML == "" {
return xerrors.Errorf(
"group yaml name is empty for %q, groups: %+v",
opt.Name,
opt.Group,
)
}
group = append(group, g.YAML)
delete(yamlNodes, strings.Join(group, "."))
}
key := strings.Join(append(group, opt.YAML), ".")
node, ok := yamlNodes[key]
if !ok {
continue
}
matchedNodes[key] = node
if opt.ValueSource != ValueSourceNone {
continue
}
if err := opt.setFromYAMLNode(node); err != nil {
merr = errors.Join(merr, xerrors.Errorf("setting %q: %w", opt.YAML, err))
}
}
// Remove all matched nodes and their descendants from yamlNodes so we
// can accurately report unknown options.
for k := range yamlNodes {
var key string
for _, part := range strings.Split(k, ".") {
if key != "" {
key += "."
}
key += part
if _, ok := matchedNodes[key]; ok {
delete(yamlNodes, k)
}
}
}
for k := range yamlNodes {
merr = errors.Join(merr, xerrors.Errorf("unknown option %q", k))
}
return merr
}
-202
View File
@@ -1,202 +0,0 @@
package clibase_test
import (
"testing"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"gopkg.in/yaml.v3"
"github.com/coder/coder/v2/cli/clibase"
)
func TestOptionSet_YAML(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.MarshalYAML()
require.NoError(t, err)
require.Len(t, node.(*yaml.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{YAML: "names"},
YAML: "workspaceName",
},
}
err := os.SetDefaults()
require.NoError(t, err)
n, err := os.MarshalYAML()
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))
})
}
func TestOptionSet_YAMLUnknownOptions(t *testing.T) {
t.Parallel()
os := clibase.OptionSet{
{
Name: "Workspace Name",
Default: "billie",
Description: "The workspace's name.",
YAML: "workspaceName",
Value: new(clibase.String),
},
}
const yamlDoc = `something: else`
err := yaml.Unmarshal([]byte(yamlDoc), &os)
require.Error(t, err)
require.Empty(t, os[0].Value.String())
os[0].YAML = "something"
err = yaml.Unmarshal([]byte(yamlDoc), &os)
require.NoError(t, err)
require.Equal(t, "else", os[0].Value.String())
}
// TestOptionSet_YAMLIsomorphism tests that the YAML representations of an
// OptionSet converts to the same OptionSet when read back in.
func TestOptionSet_YAMLIsomorphism(t *testing.T) {
t.Parallel()
// This is used to form a generic.
//nolint:unused
type kid struct {
Name string `yaml:"name"`
Age int `yaml:"age"`
}
for _, tc := range []struct {
name string
os clibase.OptionSet
zeroValue func() pflag.Value
}{
{
name: "SimpleString",
os: clibase.OptionSet{
{
Name: "Workspace Name",
Default: "billie",
Description: "The workspace's name.",
Group: &clibase.Group{YAML: "names"},
YAML: "workspaceName",
},
},
zeroValue: func() pflag.Value {
return clibase.StringOf(new(string))
},
},
{
name: "Array",
os: clibase.OptionSet{
{
YAML: "names",
Default: "jill,jack,joan",
},
},
zeroValue: func() pflag.Value {
return clibase.StringArrayOf(&[]string{})
},
},
{
name: "ComplexObject",
os: clibase.OptionSet{
{
YAML: "kids",
Default: `- name: jill
age: 12
- name: jack
age: 13`,
},
},
zeroValue: func() pflag.Value {
return &clibase.Struct[[]kid]{}
},
},
{
name: "DeepGroup",
os: clibase.OptionSet{
{
YAML: "names",
Default: "jill,jack,joan",
Group: &clibase.Group{YAML: "kids", Parent: &clibase.Group{YAML: "family"}},
},
},
zeroValue: func() pflag.Value {
return clibase.StringArrayOf(&[]string{})
},
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Set initial values.
for i := range tc.os {
tc.os[i].Value = tc.zeroValue()
}
err := tc.os.SetDefaults()
require.NoError(t, err)
y, err := tc.os.MarshalYAML()
require.NoError(t, err)
toByt, err := yaml.Marshal(y)
require.NoError(t, err)
t.Logf("Raw YAML:\n%s", string(toByt))
var y2 yaml.Node
err = yaml.Unmarshal(toByt, &y2)
require.NoError(t, err)
os2 := slices.Clone(tc.os)
for i := range os2 {
os2[i].Value = tc.zeroValue()
os2[i].ValueSource = clibase.ValueSourceNone
}
// os2 values should be zeroed whereas tc.os should be
// set to defaults.
// This check makes sure we aren't mixing pointers.
require.NotEqual(t, tc.os, os2)
err = os2.UnmarshalYAML(&y2)
require.NoError(t, err)
want := tc.os
for i := range want {
want[i].ValueSource = clibase.ValueSourceYAML
}
require.Equal(t, tc.os, os2)
})
}
}
+2 -2
View File
@@ -14,9 +14,9 @@ import (
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogjson"
"cdr.dev/slog/sloggers/slogstackdriver"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
type (
@@ -86,7 +86,7 @@ func FromDeploymentValues(vals *codersdk.DeploymentValues) Option {
}
}
func (b *Builder) Build(inv *clibase.Invocation) (log slog.Logger, closeLog func(), err error) {
func (b *Builder) Build(inv *serpent.Invocation) (log slog.Logger, closeLog func(), err error) {
var (
sinks = []slog.Sink{}
closers = []func() error{}
+14 -14
View File
@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -23,7 +23,7 @@ func TestBuilder(t *testing.T) {
t.Run("NoConfiguration", func(t *testing.T) {
t.Parallel()
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t),
}
@@ -35,7 +35,7 @@ func TestBuilder(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t,
clilog.WithHuman(tempFile),
@@ -51,7 +51,7 @@ func TestBuilder(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t,
clilog.WithHuman(tempFile),
@@ -68,7 +68,7 @@ func TestBuilder(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.WithHuman(tempFile)),
}
@@ -81,7 +81,7 @@ func TestBuilder(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.WithJSON(tempFile), clilog.WithVerbose()),
}
@@ -107,7 +107,7 @@ func TestBuilder(t *testing.T) {
// Use the default deployment values.
dv := coderdtest.DeploymentValues(t)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.FromDeploymentValues(dv)),
}
@@ -127,15 +127,15 @@ func TestBuilder(t *testing.T) {
dv := &codersdk.DeploymentValues{
Logging: codersdk.LoggingConfig{
Filter: []string{"foo", "baz"},
Human: clibase.String(tempFile),
JSON: clibase.String(tempJSON),
Human: serpent.String(tempFile),
JSON: serpent.String(tempJSON),
},
Verbose: true,
Trace: codersdk.TraceConfig{
Enable: true,
},
}
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.FromDeploymentValues(dv)),
}
@@ -150,9 +150,9 @@ func TestBuilder(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "doesnotexist", "test.log")
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "test",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
logger, closeLog, err := clilog.New(
clilog.WithFilter("foo", "baz"),
clilog.WithHuman(tempFile),
@@ -181,10 +181,10 @@ var (
filterLog = "this is an important debug message you want to see"
)
func testHandler(t testing.TB, opts ...clilog.Option) clibase.HandlerFunc {
func testHandler(t testing.TB, opts ...clilog.Option) serpent.HandlerFunc {
t.Helper()
return func(inv *clibase.Invocation) error {
return func(inv *serpent.Invocation) error {
logger, closeLog, err := clilog.New(opts...).Build(inv)
if err != nil {
return err
+9 -9
View File
@@ -20,16 +20,16 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
func New(t testing.TB, args ...string) (*clibase.Invocation, config.Root) {
func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
var root cli.RootCmd
cmd, err := root.Command(root.AGPL())
@@ -56,15 +56,15 @@ func (l *logWriter) Write(p []byte) (n int, err error) {
}
func NewWithCommand(
t testing.TB, cmd *clibase.Cmd, args ...string,
) (*clibase.Invocation, config.Root) {
t testing.TB, cmd *serpent.Command, args ...string,
) (*serpent.Invocation, config.Root) {
configDir := config.Root(t.TempDir())
// I really would like to fail test on error logs, but realistically, turning on by default
// in all our CLI tests is going to create a lot of flaky noise.
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
Leveled(slog.LevelDebug).
Named("cli")
i := &clibase.Invocation{
i := &serpent.Invocation{
Command: cmd,
Args: append([]string{"--global-config", string(configDir)}, args...),
Stdin: io.LimitReader(nil, 0),
@@ -140,11 +140,11 @@ 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) {
func Start(t *testing.T, inv *serpent.Invocation) {
StartWithAssert(t, inv, nil)
}
func StartWithAssert(t *testing.T, inv *clibase.Invocation, assertCallback func(t *testing.T, err error)) { //nolint:revive
func StartWithAssert(t *testing.T, inv *serpent.Invocation, assertCallback func(t *testing.T, err error)) { //nolint:revive
t.Helper()
closeCh := make(chan struct{})
@@ -175,7 +175,7 @@ func StartWithAssert(t *testing.T, inv *clibase.Invocation, assertCallback func(
}
// Run runs the command and asserts that there is no error.
func Run(t *testing.T, inv *clibase.Invocation) {
func Run(t *testing.T, inv *serpent.Invocation) {
t.Helper()
err := inv.Run()
@@ -228,7 +228,7 @@ func (w *ErrorWaiter) RequireAs(want interface{}) {
// 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 {
func StartWithWaiter(t *testing.T, inv *serpent.Invocation) *ErrorWaiter {
t.Helper()
var (
+36 -31
View File
@@ -13,12 +13,12 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
// UpdateGoldenFiles indicates golden files should be updated.
@@ -48,7 +48,7 @@ func DefaultCases() []CommandHelpCase {
// TestCommandHelp will test the help output of the given commands
// using golden files.
func TestCommandHelp(t *testing.T, getRoot func(t *testing.T) *clibase.Cmd, cases []CommandHelpCase) {
func TestCommandHelp(t *testing.T, getRoot func(t *testing.T) *serpent.Command, cases []CommandHelpCase) {
t.Parallel()
rootClient, replacements := prepareTestData(t)
@@ -87,40 +87,45 @@ ExtractCommandPathsLoop:
StartWithWaiter(t, inv.WithContext(ctx)).RequireSuccess()
actual := outBuf.Bytes()
if len(actual) == 0 {
t.Fatal("no output")
}
for k, v := range replacements {
actual = bytes.ReplaceAll(actual, []byte(k), []byte(v))
}
actual = NormalizeGoldenFile(t, actual)
goldenPath := filepath.Join("testdata", strings.Replace(tt.Name, " ", "_", -1)+".golden")
if *UpdateGoldenFiles {
t.Logf("update golden file for: %q: %s", tt.Name, goldenPath)
err := os.WriteFile(goldenPath, actual, 0o600)
require.NoError(t, err, "update golden file")
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "read golden file, run \"make update-golden-files\" and commit the changes")
expected = NormalizeGoldenFile(t, expected)
require.Equal(
t, string(expected), string(actual),
"golden file mismatch: %s, run \"make update-golden-files\", verify and commit the changes",
goldenPath,
)
TestGoldenFile(t, tt.Name, outBuf.Bytes(), replacements)
})
}
}
// NormalizeGoldenFile replaces any strings that are system or timing dependent
// TestGoldenFile will test the given bytes slice input against the
// golden file with the given file name, optionally using the given replacements.
func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements map[string]string) {
if len(actual) == 0 {
t.Fatal("no output")
}
for k, v := range replacements {
actual = bytes.ReplaceAll(actual, []byte(k), []byte(v))
}
actual = normalizeGoldenFile(t, actual)
goldenPath := filepath.Join("testdata", strings.ReplaceAll(fileName, " ", "_")+".golden")
if *UpdateGoldenFiles {
t.Logf("update golden file for: %q: %s", fileName, goldenPath)
err := os.WriteFile(goldenPath, actual, 0o600)
require.NoError(t, err, "update golden file")
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "read golden file, run \"make update-golden-files\" and commit the changes")
expected = normalizeGoldenFile(t, expected)
require.Equal(
t, string(expected), string(actual),
"golden file mismatch: %s, run \"make update-golden-files\", verify and commit the changes",
goldenPath,
)
}
// normalizeGoldenFile replaces any strings that are system or timing dependent
// with a placeholder so that the golden files can be compared with a simple
// equality check.
func NormalizeGoldenFile(t *testing.T, byt []byte) []byte {
func normalizeGoldenFile(t *testing.T, byt []byte) []byte {
// Replace any timestamps with a placeholder.
byt = timestampRegex.ReplaceAll(byt, []byte("[timestamp]"))
@@ -148,7 +153,7 @@ func NormalizeGoldenFile(t *testing.T, byt []byte) []byte {
return byt
}
func extractVisibleCommandPaths(cmdPath []string, cmds []*clibase.Cmd) [][]string {
func extractVisibleCommandPaths(cmdPath []string, cmds []*serpent.Command) [][]string {
var cmdPaths [][]string
for _, c := range cmds {
if c.Hidden {
+4 -4
View File
@@ -3,7 +3,7 @@ package clitest
import (
"testing"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/serpent"
)
// HandlersOK asserts that all commands have a handler.
@@ -11,11 +11,11 @@ import (
// non-root commands (like 'groups' or 'users'), a handler is required.
// These handlers are likely just the 'help' handler, but this must be
// explicitly set.
func HandlersOK(t *testing.T, cmd *clibase.Cmd) {
cmd.Walk(func(cmd *clibase.Cmd) {
func HandlersOK(t *testing.T, cmd *serpent.Command) {
cmd.Walk(func(cmd *serpent.Command) {
if cmd.Handler == nil {
// If you see this error, make the Handler a helper invoker.
// Handler: func(inv *clibase.Invocation) error {
// Handler: func(inv *serpent.Invocation) error {
// return inv.Command.HelpHandler(inv)
// },
t.Errorf("command %q has no handler, change to a helper invoker using: 'inv.Command.HelpHandler(inv)'", cmd.Name())
+3 -3
View File
@@ -212,13 +212,13 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
// Use zero time (omitted) to separate these from the startup logs.
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.")
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#startup-script-exited-with-an-error"))
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-exited-with-an-error"))
default:
switch {
case agent.LifecycleState.Starting():
// Use zero time (omitted) to separate these from the startup logs.
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.")
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#your-workspace-may-be-incomplete"))
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#your-workspace-may-be-incomplete"))
// Note: We don't complete or fail the stage here, it's
// intentionally left open to indicate this stage didn't
// complete.
@@ -240,7 +240,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
stage := "The workspace agent lost connection"
sw.Start(stage)
sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.")
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#agent-connection-issues"))
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#agent-connection-issues"))
disconnectedAt := agent.DisconnectedAt
for agent.Status == codersdk.WorkspaceAgentDisconnected {
+5 -5
View File
@@ -18,13 +18,13 @@ import (
"golang.org/x/xerrors"
"tailscale.com/tailcfg"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestAgent(t *testing.T) {
@@ -382,8 +382,8 @@ func TestAgent(t *testing.T) {
output := make(chan string, 100) // Buffered to avoid blocking, overflow is discarded.
logs := make(chan []codersdk.WorkspaceAgentLog, 1)
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
tc.opts.Fetch = func(_ context.Context, _ uuid.UUID) (codersdk.WorkspaceAgent, error) {
t.Log("iter", len(tc.iter))
var err error
@@ -450,8 +450,8 @@ func TestAgent(t *testing.T) {
t.Parallel()
var fetchCalled uint64
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
buf := bytes.Buffer{}
err := cliui.Agent(inv.Context(), &buf, uuid.Nil, cliui.AgentOptions{
FetchInterval: 10 * time.Millisecond,
+4 -4
View File
@@ -3,13 +3,13 @@ package cliui
import (
"fmt"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func DeprecationWarning(message string) clibase.MiddlewareFunc {
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return func(i *clibase.Invocation) error {
func DeprecationWarning(message string) serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(i *serpent.Invocation) error {
_, _ = fmt.Fprintln(i.Stdout, "\n"+pretty.Sprint(DefaultStyles.Wrap,
pretty.Sprint(
DefaultStyles.Warn,
+3 -3
View File
@@ -8,11 +8,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestExternalAuth(t *testing.T) {
@@ -22,8 +22,8 @@ func TestExternalAuth(t *testing.T) {
defer cancel()
ptty := ptytest.New(t)
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
var fetched atomic.Bool
return cliui.ExternalAuth(inv.Context(), inv.Stdout, cliui.ExternalAuthOptions{
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
+8 -8
View File
@@ -1,8 +1,8 @@
package cliui
import (
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
var defaultQuery = "owner:me"
@@ -11,12 +11,12 @@ var defaultQuery = "owner:me"
// and allows easy integration to a CLI command.
// Example usage:
//
// func (r *RootCmd) MyCmd() *clibase.Cmd {
// func (r *RootCmd) MyCmd() *serpent.Command {
// var (
// filter cliui.WorkspaceFilter
// ...
// )
// cmd := &clibase.Cmd{
// cmd := &serpent.Command{
// ...
// }
// filter.AttachOptions(&cmd.Options)
@@ -44,20 +44,20 @@ func (w *WorkspaceFilter) Filter() codersdk.WorkspaceFilter {
return f
}
func (w *WorkspaceFilter) AttachOptions(opts *clibase.OptionSet) {
func (w *WorkspaceFilter) AttachOptions(opts *serpent.OptionSet) {
*opts = append(*opts,
clibase.Option{
serpent.Option{
Flag: "all",
FlagShorthand: "a",
Description: "Specifies whether all workspaces will be listed or not.",
Value: clibase.BoolOf(&w.all),
Value: serpent.BoolOf(&w.all),
},
clibase.Option{
serpent.Option{
Flag: "search",
Description: "Search for a workspace with a query.",
Default: defaultQuery,
Value: clibase.StringOf(&w.searchQuery),
Value: serpent.StringOf(&w.searchQuery),
},
)
}
+11 -11
View File
@@ -9,12 +9,12 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/serpent"
)
type OutputFormat interface {
ID() string
AttachOptions(opts *clibase.OptionSet)
AttachOptions(opts *serpent.OptionSet)
Format(ctx context.Context, data any) (string, error)
}
@@ -49,7 +49,7 @@ func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter {
// 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) {
func (f *OutputFormatter) AttachOptions(opts *serpent.OptionSet) {
for _, format := range f.formats {
format.AttachOptions(opts)
}
@@ -60,11 +60,11 @@ func (f *OutputFormatter) AttachOptions(opts *clibase.OptionSet) {
}
*opts = append(*opts,
clibase.Option{
serpent.Option{
Flag: "output",
FlagShorthand: "o",
Default: f.formats[0].ID(),
Value: clibase.StringOf(&f.formatID),
Value: serpent.StringOf(&f.formatID),
Description: "Output format. Available formats: " + strings.Join(formatNames, ", ") + ".",
},
)
@@ -129,13 +129,13 @@ func (*tableFormat) ID() string {
}
// AttachOptions implements OutputFormat.
func (f *tableFormat) AttachOptions(opts *clibase.OptionSet) {
func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
*opts = append(*opts,
clibase.Option{
serpent.Option{
Flag: "column",
FlagShorthand: "c",
Default: strings.Join(f.defaultColumns, ","),
Value: clibase.StringArrayOf(&f.columns),
Value: serpent.StringArrayOf(&f.columns),
Description: "Columns to display in table output. Available columns: " + strings.Join(f.allColumns, ", ") + ".",
},
)
@@ -161,7 +161,7 @@ func (jsonFormat) ID() string {
}
// AttachOptions implements OutputFormat.
func (jsonFormat) AttachOptions(_ *clibase.OptionSet) {}
func (jsonFormat) AttachOptions(_ *serpent.OptionSet) {}
// Format implements OutputFormat.
func (jsonFormat) Format(_ context.Context, data any) (string, error) {
@@ -187,7 +187,7 @@ func (textFormat) ID() string {
return "text"
}
func (textFormat) AttachOptions(_ *clibase.OptionSet) {}
func (textFormat) AttachOptions(_ *serpent.OptionSet) {}
func (textFormat) Format(_ context.Context, data any) (string, error) {
return fmt.Sprintf("%s", data), nil
@@ -213,7 +213,7 @@ func (d *DataChangeFormat) ID() string {
return d.format.ID()
}
func (d *DataChangeFormat) AttachOptions(opts *clibase.OptionSet) {
func (d *DataChangeFormat) AttachOptions(opts *serpent.OptionSet) {
d.format.AttachOptions(opts)
}
+7 -7
View File
@@ -8,13 +8,13 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
type format struct {
id string
attachOptionsFn func(opts *clibase.OptionSet)
attachOptionsFn func(opts *serpent.OptionSet)
formatFn func(ctx context.Context, data any) (string, error)
}
@@ -24,7 +24,7 @@ func (f *format) ID() string {
return f.id
}
func (f *format) AttachOptions(opts *clibase.OptionSet) {
func (f *format) AttachOptions(opts *serpent.OptionSet) {
if f.attachOptionsFn != nil {
f.attachOptionsFn(opts)
}
@@ -85,12 +85,12 @@ func Test_OutputFormatter(t *testing.T) {
cliui.JSONFormat(),
&format{
id: "foo",
attachOptionsFn: func(opts *clibase.OptionSet) {
opts.Add(clibase.Option{
attachOptionsFn: func(opts *serpent.OptionSet) {
opts.Add(serpent.Option{
Name: "foo",
Flag: "foo",
FlagShorthand: "f",
Value: clibase.DiscardValue,
Value: serpent.DiscardValue,
Description: "foo flag 1234",
})
},
@@ -101,7 +101,7 @@ func Test_OutputFormatter(t *testing.T) {
},
)
cmd := &clibase.Cmd{}
cmd := &serpent.Command{}
f.AttachOptions(&cmd.Options)
fs := cmd.Options.FlagSet()
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"fmt"
"strings"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
label := templateVersionParameter.Name
if templateVersionParameter.DisplayName != "" {
label = templateVersionParameter.DisplayName
+5 -5
View File
@@ -13,8 +13,8 @@ import (
"github.com/mattn/go-isatty"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
// PromptOptions supply a set of options to the prompt.
@@ -30,13 +30,13 @@ const skipPromptFlag = "yes"
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
// prompts.
func SkipPromptOption() clibase.Option {
return clibase.Option{
func SkipPromptOption() serpent.Option {
return serpent.Option{
Flag: skipPromptFlag,
FlagShorthand: "y",
Description: "Bypass prompts.",
// Discard
Value: clibase.BoolOf(new(bool)),
Value: serpent.BoolOf(new(bool)),
}
}
@@ -46,7 +46,7 @@ const (
)
// Prompt asks the user for input.
func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
func Prompt(inv *serpent.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.
+7 -7
View File
@@ -11,11 +11,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestPrompt(t *testing.T) {
@@ -77,7 +77,7 @@ func TestPrompt(t *testing.T) {
resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "ShouldNotSeeThis",
IsConfirm: true,
}, func(inv *clibase.Invocation) {
}, func(inv *serpent.Invocation) {
inv.Command.Options = append(inv.Command.Options, cliui.SkipPromptOption())
inv.Args = []string{"-y"}
})
@@ -145,10 +145,10 @@ func TestPrompt(t *testing.T) {
})
}
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *clibase.Invocation)) (string, error) {
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
value := ""
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
var err error
value, err = cliui.Prompt(inv, opts)
return err
@@ -210,8 +210,8 @@ func TestPasswordTerminalState(t *testing.T) {
// nolint:unused
func passwordHelper() {
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
cliui.Prompt(inv, cliui.PromptOptions{
Text: "Password:",
Secret: true,
+35 -5
View File
@@ -54,6 +54,11 @@ func (err *ProvisionerJobError) Error() string {
return err.Message
}
const (
ProvisioningStateQueued = "Queued"
ProvisioningStateRunning = "Running"
)
// ProvisionerJob renders a provisioner job with interactive cancellation.
func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error {
if opts.FetchInterval == 0 {
@@ -63,8 +68,9 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
defer cancelFunc()
var (
currentStage = "Queued"
currentStage = ProvisioningStateQueued
currentStageStartedAt = time.Now().UTC()
currentQueuePos = -1
errChan = make(chan error, 1)
job codersdk.ProvisionerJob
@@ -74,7 +80,20 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent}
printStage := func() {
sw.Start(currentStage)
out := currentStage
if currentStage == ProvisioningStateQueued && currentQueuePos > 0 {
var queuePos string
if currentQueuePos == 1 {
queuePos = "next"
} else {
queuePos = fmt.Sprintf("position: %d", currentQueuePos)
}
out = pretty.Sprintf(DefaultStyles.Warn, "%s (%s)", currentStage, queuePos)
}
sw.Start(out)
}
updateStage := func(stage string, startedAt time.Time) {
@@ -103,15 +122,26 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
errChan <- xerrors.Errorf("fetch: %w", err)
return
}
if job.QueuePosition != currentQueuePos {
initialState := currentQueuePos == -1
currentQueuePos = job.QueuePosition
// Print an update when the queue position changes, but:
// - not initially, because the stage is printed at startup
// - not when we're first in the queue, because it's redundant
if !initialState && currentQueuePos != 0 {
printStage()
}
}
if job.StartedAt == nil {
return
}
if currentStage != "Queued" {
if currentStage != ProvisioningStateQueued {
// If another stage is already running, there's no need
// for us to notify the user we're running!
return
}
updateStage("Running", *job.StartedAt)
updateStage(ProvisioningStateRunning, *job.StartedAt)
}
if opts.Cancel != nil {
@@ -143,8 +173,8 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
}
// The initial stage needs to print after the signal handler has been registered.
printStage()
updateJob()
printStage()
logs, closer, err := opts.Logs()
if err != nil {
+120 -26
View File
@@ -2,8 +2,10 @@ package cliui_test
import (
"context"
"fmt"
"io"
"os"
"regexp"
"runtime"
"sync"
"testing"
@@ -11,11 +13,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/serpent"
)
// This cannot be ran in parallel because it uses a signal.
@@ -25,7 +29,11 @@ func TestProvisionerJob(t *testing.T) {
t.Parallel()
test := newProvisionerJob(t)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
testutil.Go(t, func() {
<-test.Next
test.JobMutex.Lock()
test.Job.Status = codersdk.ProvisionerJobRunning
@@ -39,20 +47,26 @@ func TestProvisionerJob(t *testing.T) {
test.Job.CompletedAt = &now
close(test.Logs)
test.JobMutex.Unlock()
}()
test.PTY.ExpectMatch("Queued")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Queued")
test.PTY.ExpectMatch("Running")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Running")
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
t.Run("Stages", func(t *testing.T) {
t.Parallel()
test := newProvisionerJob(t)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
testutil.Go(t, func() {
<-test.Next
test.JobMutex.Lock()
test.Job.Status = codersdk.ProvisionerJobRunning
@@ -70,13 +84,86 @@ func TestProvisionerJob(t *testing.T) {
test.Job.CompletedAt = &now
close(test.Logs)
test.JobMutex.Unlock()
}()
test.PTY.ExpectMatch("Queued")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Queued")
test.PTY.ExpectMatch("Something")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Something")
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch("Something")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Something")
return true
}, testutil.IntervalFast)
})
t.Run("Queue Position", func(t *testing.T) {
t.Parallel()
stage := cliui.ProvisioningStateQueued
tests := []struct {
name string
queuePos int
expected string
}{
{
name: "first",
queuePos: 0,
expected: fmt.Sprintf("%s$", stage),
},
{
name: "next",
queuePos: 1,
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(next)")),
},
{
name: "other",
queuePos: 4,
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(position: 4)")),
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
test := newProvisionerJob(t)
test.JobMutex.Lock()
test.Job.QueuePosition = tc.queuePos
test.Job.QueueSize = tc.queuePos
test.JobMutex.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
testutil.Go(t, func() {
<-test.Next
test.JobMutex.Lock()
test.Job.Status = codersdk.ProvisionerJobRunning
now := dbtime.Now()
test.Job.StartedAt = &now
test.JobMutex.Unlock()
<-test.Next
test.JobMutex.Lock()
test.Job.Status = codersdk.ProvisionerJobSucceeded
now = dbtime.Now()
test.Job.CompletedAt = &now
close(test.Logs)
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectRegexMatch(tc.expected)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued) // step completed
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
}
})
// This cannot be ran in parallel because it uses a signal.
@@ -90,7 +177,11 @@ func TestProvisionerJob(t *testing.T) {
}
test := newProvisionerJob(t)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
testutil.Go(t, func() {
<-test.Next
currentProcess, err := os.FindProcess(os.Getpid())
assert.NoError(t, err)
@@ -103,12 +194,15 @@ func TestProvisionerJob(t *testing.T) {
test.Job.CompletedAt = &now
close(test.Logs)
test.JobMutex.Unlock()
}()
test.PTY.ExpectMatch("Queued")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Gracefully canceling")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Queued")
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch("Gracefully canceling")
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
return true
}, testutil.IntervalFast)
})
}
@@ -127,8 +221,8 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
}
jobLock := sync.Mutex{}
logs := make(chan codersdk.ProvisionerJobLog, 1)
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
return cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
FetchInterval: time.Millisecond,
Fetch: func() (codersdk.ProvisionerJob, error) {
+4 -4
View File
@@ -10,8 +10,8 @@ import (
"github.com/AlecAivazis/survey/v2/terminal"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func init() {
@@ -68,7 +68,7 @@ type RichSelectOptions struct {
}
// RichSelect displays a list of user options including name and description.
func RichSelect(inv *clibase.Invocation, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
opts := make([]string, len(richOptions.Options))
var defaultOpt string
for i, option := range richOptions.Options {
@@ -102,7 +102,7 @@ func RichSelect(inv *clibase.Invocation, richOptions RichSelectOptions) (*coders
}
// Select displays a list of user options.
func Select(inv *clibase.Invocation, opts SelectOptions) (string, error) {
func Select(inv *serpent.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
@@ -138,7 +138,7 @@ func Select(inv *clibase.Invocation, opts SelectOptions) (string, error) {
return value, err
}
func MultiSelect(inv *clibase.Invocation, items []string) ([]string, error) {
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
// Similar hack is applied to Select()
if flag.Lookup("test.v") != nil {
return items, nil
+7 -7
View File
@@ -6,10 +6,10 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/serpent"
)
func TestSelect(t *testing.T) {
@@ -31,8 +31,8 @@ func TestSelect(t *testing.T) {
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
value := ""
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
var err error
value, err = cliui.Select(inv, opts)
return err
@@ -72,8 +72,8 @@ func TestRichSelect(t *testing.T) {
func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, error) {
value := ""
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
richOption, err := cliui.RichSelect(inv, opts)
if err == nil {
value = richOption.Value
@@ -105,8 +105,8 @@ func TestMultiSelect(t *testing.T) {
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
var values []string
cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error {
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
selectedItems, err := cliui.MultiSelect(inv, items)
if err == nil {
values = selectedItems
+17 -17
View File
@@ -24,10 +24,10 @@ import (
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const (
@@ -215,7 +215,7 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r
}
}
func (r *RootCmd) configSSH() *clibase.Cmd {
func (r *RootCmd) configSSH() *serpent.Command {
var (
sshConfigFile string
sshConfigOpts sshConfigOptions
@@ -226,7 +226,7 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
coderCliPath string
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "config-ssh",
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
@@ -240,11 +240,11 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
Command: "coder config-ssh --dry-run",
},
),
Middleware: clibase.Chain(
clibase.RequireNArgs(0),
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
if sshConfigOpts.waitEnum != "auto" && skipProxyCommand {
// The wait option is applied to the ProxyCommand. If the user
// specifies skip-proxy-command, then wait cannot be applied.
@@ -538,13 +538,13 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "ssh-config-file",
Env: "CODER_SSH_CONFIG_FILE",
Default: sshDefaultConfigFileName,
Description: "Specifies the path to an SSH config.",
Value: clibase.StringOf(&sshConfigFile),
Value: serpent.StringOf(&sshConfigFile),
},
{
Flag: "coder-binary-path",
@@ -552,7 +552,7 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
Default: "",
Description: "Optionally specify the absolute path to the coder binary used in ProxyCommand. " +
"By default, the binary invoking this command ('config ssh') is used.",
Value: clibase.Validate(clibase.StringOf(&coderCliPath), func(value *clibase.String) error {
Value: serpent.Validate(serpent.StringOf(&coderCliPath), func(value *serpent.String) error {
if runtime.GOOS == goosWindows {
// For some reason filepath.IsAbs() does not work on windows.
return nil
@@ -569,46 +569,46 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
FlagShorthand: "o",
Env: "CODER_SSH_CONFIG_OPTS",
Description: "Specifies additional SSH options to embed in each host stanza.",
Value: clibase.StringArrayOf(&sshConfigOpts.sshOptions),
Value: serpent.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),
Value: serpent.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),
Value: serpent.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),
Value: serpent.BoolOf(&usePreviousOpts),
},
{
Flag: "ssh-host-prefix",
Env: "CODER_CONFIGSSH_SSH_HOST_PREFIX",
Description: "Override the default host prefix.",
Value: clibase.StringOf(&sshConfigOpts.userHostPrefix),
Value: serpent.StringOf(&sshConfigOpts.userHostPrefix),
},
{
Flag: "wait",
Env: "CODER_CONFIGSSH_WAIT", // Not to be mixed with CODER_SSH_WAIT.
Description: "Specifies whether or not to wait for the startup script to finish executing. Auto means that the agent startup script behavior configured in the workspace template is used.",
Default: "auto",
Value: clibase.EnumOf(&sshConfigOpts.waitEnum, "yes", "no", "auto"),
Value: serpent.EnumOf(&sshConfigOpts.waitEnum, "yes", "no", "auto"),
},
{
Flag: "disable-autostart",
Description: "Disable starting the workspace automatically when connecting via SSH.",
Env: "CODER_CONFIGSSH_DISABLE_AUTOSTART",
Value: clibase.BoolOf(&sshConfigOpts.disableAutostart),
Value: serpent.BoolOf(&sshConfigOpts.disableAutostart),
Default: "false",
},
{
@@ -617,7 +617,7 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
Description: "By default, 'config-ssh' uses the os path separator when writing the ssh config. " +
"This might be an issue in Windows machine that use a unix-like shell. " +
"This flag forces the use of unix file paths (the forward slash '/').",
Value: clibase.BoolOf(&forceUnixSeparators),
Value: serpent.BoolOf(&forceUnixSeparators),
// On non-windows showing this command is useless because it is a noop.
// Hide vs disable it though so if a command is copied from a Windows
// machine to a unix machine it will still work and not throw an
+3 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
@@ -83,7 +84,8 @@ func TestConfigSSH(t *testing.T) {
}).WithAgent().Do()
_ = agenttest.New(t, client.URL, r.AgentToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
agentConn, err := workspacesdk.New(client).
DialAgent(context.Background(), resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer agentConn.Close()
+16 -16
View File
@@ -12,14 +12,14 @@ import (
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) create() *clibase.Cmd {
func (r *RootCmd) create() *serpent.Command {
var (
templateName string
startAt string
@@ -31,7 +31,7 @@ func (r *RootCmd) create() *clibase.Cmd {
copyParametersFrom string
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "create [name]",
Short: "Create a workspace",
@@ -41,8 +41,8 @@ func (r *RootCmd) create() *clibase.Cmd {
Command: "coder create <username>/<workspace_name>",
},
),
Middleware: clibase.Chain(r.InitClient(client)),
Handler: func(inv *clibase.Invocation) error {
Middleware: serpent.Chain(r.InitClient(client)),
Handler: func(inv *serpent.Invocation) error {
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
@@ -227,37 +227,37 @@ func (r *RootCmd) create() *clibase.Cmd {
},
}
cmd.Options = append(cmd.Options,
clibase.Option{
serpent.Option{
Flag: "template",
FlagShorthand: "t",
Env: "CODER_TEMPLATE_NAME",
Description: "Specify a template name.",
Value: clibase.StringOf(&templateName),
Value: serpent.StringOf(&templateName),
},
clibase.Option{
serpent.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),
Value: serpent.StringOf(&startAt),
},
clibase.Option{
serpent.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),
Value: serpent.DurationOf(&stopAfter),
},
clibase.Option{
serpent.Option{
Flag: "automatic-updates",
Env: "CODER_WORKSPACE_AUTOMATIC_UPDATES",
Description: "Specify automatic updates setting for the workspace (accepts 'always' or 'never').",
Default: string(codersdk.AutomaticUpdatesNever),
Value: clibase.StringOf(&autoUpdates),
Value: serpent.StringOf(&autoUpdates),
},
clibase.Option{
serpent.Option{
Flag: "copy-parameters-from",
Env: "CODER_WORKSPACE_COPY_PARAMETERS_FROM",
Description: "Specify the source workspace name to copy parameters from.",
Value: clibase.StringOf(&copyParametersFrom),
Value: serpent.StringOf(&copyParametersFrom),
},
cliui.SkipPromptOption(),
)
@@ -283,7 +283,7 @@ type prepWorkspaceBuildArgs 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 rich parameters.
func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
ctx := inv.Context()
templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID)
+8 -8
View File
@@ -4,24 +4,24 @@ import (
"fmt"
"time"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
// nolint
func (r *RootCmd) deleteWorkspace() *clibase.Cmd {
func (r *RootCmd) deleteWorkspace() *serpent.Command {
var orphan bool
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "delete <workspace>",
Short: "Delete a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return err
@@ -62,12 +62,12 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd {
return nil
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.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),
Value: serpent.BoolOf(&orphan),
},
cliui.SkipPromptOption(),
}
+10 -10
View File
@@ -15,18 +15,18 @@ import (
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
func (r *RootCmd) dotfiles() *clibase.Cmd {
func (r *RootCmd) dotfiles() *serpent.Command {
var symlinkDir string
var gitbranch string
var dotfilesRepoDir string
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "dotfiles <git_repo_url>",
Middleware: clibase.RequireNArgs(1),
Middleware: serpent.RequireNArgs(1),
Short: "Personalize your workspace by applying a canonical dotfiles repository",
Long: formatExamples(
example{
@@ -34,7 +34,7 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
Command: "coder dotfiles --yes git@github.com:example/dotfiles.git",
},
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
var (
gitRepo = inv.Args[0]
cfg = r.createConfig()
@@ -276,26 +276,26 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
return nil
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.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),
Value: serpent.StringOf(&symlinkDir),
},
{
Flag: "branch",
FlagShorthand: "b",
Description: "Specifies which branch to clone. " +
"If empty, will default to cloning the default branch or using the existing branch in the cloned repo on disk.",
Value: clibase.StringOf(&gitbranch),
Value: serpent.StringOf(&gitbranch),
},
{
Flag: "repo-dir",
Default: "dotfiles",
Env: "CODER_DOTFILES_REPO_DIR",
Description: "Specifies the directory for the dotfiles repository, relative to global config directory.",
Value: clibase.StringOf(&dotfilesRepoDir),
Value: serpent.StringOf(&dotfilesRepoDir),
},
cliui.SkipPromptOption(),
}
@@ -308,7 +308,7 @@ type ensureCorrectGitBranchParams struct {
gitBranch string
}
func ensureCorrectGitBranch(baseInv *clibase.Invocation, params ensureCorrectGitBranchParams) error {
func ensureCorrectGitBranch(baseInv *serpent.Invocation, params ensureCorrectGitBranchParams) error {
dotfileCmd := func(cmd string, args ...string) *exec.Cmd {
c := exec.CommandContext(baseInv.Context(), cmd, args...)
c.Dir = params.repoDir
+23 -27
View File
@@ -5,19 +5,18 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (RootCmd) errorExample() *clibase.Cmd {
errorCmd := func(use string, err error) *clibase.Cmd {
return &clibase.Cmd{
func (RootCmd) errorExample() *serpent.Command {
errorCmd := func(use string, err error) *serpent.Command {
return &serpent.Command{
Use: use,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return err
},
}
@@ -50,18 +49,18 @@ func (RootCmd) errorExample() *clibase.Cmd {
apiErrorNoHelper.Helper = ""
// Some flags
var magicWord clibase.String
var magicWord serpent.String
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "example-error",
Short: "Shows what different error messages look like",
Long: "This command is pretty pointless, but without it testing errors is" +
"difficult to visually inspect. Error message formatting is inherently" +
"visual, so we need a way to quickly see what they look like.",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
// Typical codersdk api error
errorCmd("api", apiError),
@@ -71,7 +70,7 @@ func (RootCmd) errorExample() *clibase.Cmd {
// A multi-error
{
Use: "multi-error",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return xerrors.Errorf("wrapped: %w", errors.Join(
xerrors.Errorf("first error: %w", errorWithStackTrace()),
xerrors.Errorf("second error: %w", errorWithStackTrace()),
@@ -82,44 +81,41 @@ func (RootCmd) errorExample() *clibase.Cmd {
{
Use: "multi-multi-error",
Short: "This is a multi error inside a multi error",
Handler: func(inv *clibase.Invocation) error {
// Closing the stdin file descriptor will cause the next close
// to fail. This is joined to the returned Command error.
if f, ok := inv.Stdin.(*os.File); ok {
_ = f.Close()
}
Handler: func(inv *serpent.Invocation) error {
return errors.Join(
xerrors.Errorf("first error: %w", errorWithStackTrace()),
xerrors.Errorf("second error: %w", errorWithStackTrace()),
xerrors.Errorf("parent error: %w", errorWithStackTrace()),
errors.Join(
xerrors.Errorf("child first error: %w", errorWithStackTrace()),
xerrors.Errorf("child second error: %w", errorWithStackTrace()),
),
)
},
},
{
Use: "validation",
Options: clibase.OptionSet{
clibase.Option{
Options: serpent.OptionSet{
serpent.Option{
Name: "magic-word",
Description: "Take a good guess.",
Required: true,
Flag: "magic-word",
Default: "",
Value: clibase.Validate(&magicWord, func(value *clibase.String) error {
Value: serpent.Validate(&magicWord, func(value *serpent.String) error {
return xerrors.Errorf("magic word is incorrect")
}),
},
},
Handler: func(i *clibase.Invocation) error {
Handler: func(i *serpent.Invocation) error {
_, _ = fmt.Fprint(i.Stdout, "Try setting the --magic-word flag\n")
return nil
},
},
{
Use: "arg-required <required>",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Handler: func(i *clibase.Invocation) error {
Handler: func(i *serpent.Invocation) error {
_, _ = fmt.Fprint(i.Stdout, "Try running this without an argument\n")
return nil
},
+93
View File
@@ -0,0 +1,93 @@
package cli_test
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/serpent"
)
type commandErrorCase struct {
Name string
Cmd []string
}
// TestErrorExamples will test the help output of the
// coder exp example-error using golden files.
func TestErrorExamples(t *testing.T) {
t.Parallel()
coderRootCmd := getRoot(t)
var exampleErrorRootCmd *serpent.Command
coderRootCmd.Walk(func(command *serpent.Command) {
if command.Name() == "example-error" {
// cannot abort early, but list is small
exampleErrorRootCmd = command
}
})
require.NotNil(t, exampleErrorRootCmd, "example-error command not found")
var cases []commandErrorCase
ExtractCommandPathsLoop:
for _, cp := range extractCommandPaths(nil, exampleErrorRootCmd.Children) {
cmd := append([]string{"exp", "example-error"}, cp...)
name := fmt.Sprintf("coder %s", strings.Join(cmd, " "))
for _, tt := range cases {
if tt.Name == name {
continue ExtractCommandPathsLoop
}
}
cases = append(cases, commandErrorCase{Name: name, Cmd: cmd})
}
for _, tt := range cases {
tt := tt
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
var outBuf bytes.Buffer
coderRootCmd := getRoot(t)
inv, _ := clitest.NewWithCommand(t, coderRootCmd, tt.Cmd...)
inv.Stderr = &outBuf
inv.Stdout = &outBuf
err := inv.Run()
errFormatter := cli.NewPrettyErrorFormatter(&outBuf, false)
errFormatter.Format(err)
clitest.TestGoldenFile(t, tt.Name, outBuf.Bytes(), nil)
})
}
}
func extractCommandPaths(cmdPath []string, cmds []*serpent.Command) [][]string {
var cmdPaths [][]string
for _, c := range cmds {
cmdPath := append(cmdPath, c.Name())
cmdPaths = append(cmdPaths, cmdPath)
cmdPaths = append(cmdPaths, extractCommandPaths(cmdPath, c.Children)...)
}
return cmdPaths
}
// Must return a fresh instance of cmds each time.
func getRoot(t *testing.T) *serpent.Command {
t.Helper()
var root cli.RootCmd
rootCmd, err := root.Command(root.AGPL())
require.NoError(t, err)
return rootCmd
}
+5 -5
View File
@@ -1,16 +1,16 @@
package cli
import "github.com/coder/coder/v2/cli/clibase"
import "github.com/coder/serpent"
func (r *RootCmd) expCmd() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) expCmd() *serpent.Command {
cmd := &serpent.Command{
Use: "exp",
Short: "Internal commands for testing and experimentation. These are prone to breaking changes with no notice.",
Handler: func(i *clibase.Invocation) error {
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Hidden: true,
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.scaletestCmd(),
r.errorExample(),
},
+79 -78
View File
@@ -27,11 +27,11 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/scaletest/agentconn"
"github.com/coder/coder/v2/scaletest/createworkspaces"
@@ -40,18 +40,19 @@ import (
"github.com/coder/coder/v2/scaletest/reconnectingpty"
"github.com/coder/coder/v2/scaletest/workspacebuild"
"github.com/coder/coder/v2/scaletest/workspacetraffic"
"github.com/coder/serpent"
)
const scaletestTracerName = "coder_scaletest"
func (r *RootCmd) scaletestCmd() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) scaletestCmd() *serpent.Command {
cmd := &serpent.Command{
Use: "scaletest",
Short: "Run a scale test against the Coder API",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.scaletestCleanup(),
r.scaletestDashboard(),
r.scaletestCreateWorkspaces(),
@@ -69,32 +70,32 @@ type scaletestTracingFlags struct {
tracePropagate bool
}
func (s *scaletestTracingFlags) attach(opts *clibase.OptionSet) {
func (s *scaletestTracingFlags) attach(opts *serpent.OptionSet) {
*opts = append(
*opts,
clibase.Option{
serpent.Option{
Flag: "trace",
Env: "CODER_SCALETEST_TRACE",
Description: "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.",
Value: clibase.BoolOf(&s.traceEnable),
Value: serpent.BoolOf(&s.traceEnable),
},
clibase.Option{
serpent.Option{
Flag: "trace-coder",
Env: "CODER_SCALETEST_TRACE_CODER",
Description: "Whether opentelemetry traces are sent to Coder. We recommend keeping this disabled unless we advise you to enable it.",
Value: clibase.BoolOf(&s.traceCoder),
Value: serpent.BoolOf(&s.traceCoder),
},
clibase.Option{
serpent.Option{
Flag: "trace-honeycomb-api-key",
Env: "CODER_SCALETEST_TRACE_HONEYCOMB_API_KEY",
Description: "Enables trace exporting to Honeycomb.io using the provided API key.",
Value: clibase.StringOf(&s.traceHoneycombAPIKey),
Value: serpent.StringOf(&s.traceHoneycombAPIKey),
},
clibase.Option{
serpent.Option{
Flag: "trace-propagate",
Env: "CODER_SCALETEST_TRACE_PROPAGATE",
Description: "Enables trace propagation to the Coder backend, which will be used to correlate server-side spans with client-side spans. Only enable this if the server is configured with the exact same tracing configuration as the client.",
Value: clibase.BoolOf(&s.tracePropagate),
Value: serpent.BoolOf(&s.tracePropagate),
},
)
}
@@ -137,7 +138,7 @@ type scaletestStrategyFlags struct {
timeoutPerJob time.Duration
}
func (s *scaletestStrategyFlags) attach(opts *clibase.OptionSet) {
func (s *scaletestStrategyFlags) attach(opts *serpent.OptionSet) {
concurrencyLong, concurrencyEnv, concurrencyDescription := "concurrency", "CODER_SCALETEST_CONCURRENCY", "Number of concurrent jobs to run. 0 means unlimited."
timeoutLong, timeoutEnv, timeoutDescription := "timeout", "CODER_SCALETEST_TIMEOUT", "Timeout for the entire test run. 0 means unlimited."
jobTimeoutLong, jobTimeoutEnv, jobTimeoutDescription := "job-timeout", "CODER_SCALETEST_JOB_TIMEOUT", "Timeout per job. Jobs may take longer to complete under higher concurrency limits."
@@ -149,26 +150,26 @@ func (s *scaletestStrategyFlags) attach(opts *clibase.OptionSet) {
*opts = append(
*opts,
clibase.Option{
serpent.Option{
Flag: concurrencyLong,
Env: concurrencyEnv,
Description: concurrencyDescription,
Default: "1",
Value: clibase.Int64Of(&s.concurrency),
Value: serpent.Int64Of(&s.concurrency),
},
clibase.Option{
serpent.Option{
Flag: timeoutLong,
Env: timeoutEnv,
Description: timeoutDescription,
Default: "30m",
Value: clibase.DurationOf(&s.timeout),
Value: serpent.DurationOf(&s.timeout),
},
clibase.Option{
serpent.Option{
Flag: jobTimeoutLong,
Env: jobTimeoutEnv,
Description: jobTimeoutDescription,
Default: "5m",
Value: clibase.DurationOf(&s.timeoutPerJob),
Value: serpent.DurationOf(&s.timeoutPerJob),
},
)
}
@@ -268,13 +269,13 @@ type scaletestOutputFlags struct {
outputSpecs []string
}
func (s *scaletestOutputFlags) attach(opts *clibase.OptionSet) {
*opts = append(*opts, clibase.Option{
func (s *scaletestOutputFlags) attach(opts *serpent.OptionSet) {
*opts = append(*opts, serpent.Option{
Flag: "output",
Env: "CODER_SCALETEST_OUTPUTS",
Description: `Output format specs in the format "<format>[:<path>]". Not specifying a path will default to stdout. Available formats: text, json.`,
Default: "text",
Value: clibase.StringArrayOf(&s.outputSpecs),
Value: serpent.StringArrayOf(&s.outputSpecs),
})
}
@@ -331,21 +332,21 @@ type scaletestPrometheusFlags struct {
Wait time.Duration
}
func (s *scaletestPrometheusFlags) attach(opts *clibase.OptionSet) {
func (s *scaletestPrometheusFlags) attach(opts *serpent.OptionSet) {
*opts = append(*opts,
clibase.Option{
serpent.Option{
Flag: "scaletest-prometheus-address",
Env: "CODER_SCALETEST_PROMETHEUS_ADDRESS",
Default: "0.0.0.0:21112",
Description: "Address on which to expose scaletest Prometheus metrics.",
Value: clibase.StringOf(&s.Address),
Value: serpent.StringOf(&s.Address),
},
clibase.Option{
serpent.Option{
Flag: "scaletest-prometheus-wait",
Env: "CODER_SCALETEST_PROMETHEUS_WAIT",
Default: "15s",
Description: "How long to wait before exiting in order to allow Prometheus metrics to be scraped.",
Value: clibase.DurationOf(&s.Wait),
Value: serpent.DurationOf(&s.Wait),
},
)
}
@@ -398,20 +399,20 @@ func (r *userCleanupRunner) Run(ctx context.Context, _ string, _ io.Writer) erro
return nil
}
func (r *RootCmd) scaletestCleanup() *clibase.Cmd {
func (r *RootCmd) scaletestCleanup() *serpent.Command {
var template string
cleanupStrategy := &scaletestStrategyFlags{cleanup: true}
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "cleanup",
Short: "Cleanup scaletest workspaces, then cleanup scaletest users.",
Long: "The strategy flags will apply to each stage of the cleanup process.",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
me, err := requireAdmin(ctx, client)
@@ -508,12 +509,12 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "template",
Env: "CODER_SCALETEST_CLEANUP_TEMPLATE",
Description: "Name or ID of the template. Only delete workspaces created from the given template.",
Value: clibase.StringOf(&template),
Value: serpent.StringOf(&template),
},
}
@@ -521,7 +522,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd {
return cmd
}
func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
func (r *RootCmd) scaletestCreateWorkspaces() *serpent.Command {
var (
count int64
retry int64
@@ -558,12 +559,12 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "create-workspaces",
Short: "Creates many users, then creates a workspace for each user and waits for them finish building and fully come online. Optionally runs a command inside each workspace, and connects to the workspace over WireGuard.",
Long: `It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.`,
Middleware: r.InitClient(client),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
me, err := requireAdmin(ctx, client)
@@ -667,7 +668,7 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
if runCommand != "" {
config.ReconnectingPTY = &reconnectingpty.Config{
// AgentID is set by the test automatically.
Init: codersdk.WorkspaceAgentReconnectingPTYInit{
Init: workspacesdk.AgentReconnectingPTYInit{
ID: uuid.Nil,
Height: 24,
Width: 80,
@@ -746,98 +747,98 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "count",
FlagShorthand: "c",
Env: "CODER_SCALETEST_COUNT",
Default: "1",
Description: "Required: Number of workspaces to create.",
Value: clibase.Int64Of(&count),
Value: serpent.Int64Of(&count),
},
{
Flag: "retry",
Env: "CODER_SCALETEST_RETRY",
Default: "0",
Description: "Number of tries to create and bring up the workspace.",
Value: clibase.Int64Of(&retry),
Value: serpent.Int64Of(&retry),
},
{
Flag: "template",
FlagShorthand: "t",
Env: "CODER_SCALETEST_TEMPLATE",
Description: "Required: Name or ID of the template to use for workspaces.",
Value: clibase.StringOf(&template),
Value: serpent.StringOf(&template),
},
{
Flag: "no-cleanup",
Env: "CODER_SCALETEST_NO_CLEANUP",
Description: "Do not clean up resources after the test completes. You can cleanup manually using coder scaletest cleanup.",
Value: clibase.BoolOf(&noCleanup),
Value: serpent.BoolOf(&noCleanup),
},
{
Flag: "no-wait-for-agents",
Env: "CODER_SCALETEST_NO_WAIT_FOR_AGENTS",
Description: `Do not wait for agents to start before marking the test as succeeded. This can be useful if you are running the test against a template that does not start the agent quickly.`,
Value: clibase.BoolOf(&noWaitForAgents),
Value: serpent.BoolOf(&noWaitForAgents),
},
{
Flag: "run-command",
Env: "CODER_SCALETEST_RUN_COMMAND",
Description: "Command to run inside each workspace using reconnecting-pty (i.e. web terminal protocol). " + "If not specified, no command will be run.",
Value: clibase.StringOf(&runCommand),
Value: serpent.StringOf(&runCommand),
},
{
Flag: "run-timeout",
Env: "CODER_SCALETEST_RUN_TIMEOUT",
Default: "5s",
Description: "Timeout for the command to complete.",
Value: clibase.DurationOf(&runTimeout),
Value: serpent.DurationOf(&runTimeout),
},
{
Flag: "run-expect-timeout",
Env: "CODER_SCALETEST_RUN_EXPECT_TIMEOUT",
Description: "Expect the command to timeout." + " If the command does not finish within the given --run-timeout, it will be marked as succeeded." + " If the command finishes before the timeout, it will be marked as failed.",
Value: clibase.BoolOf(&runExpectTimeout),
Value: serpent.BoolOf(&runExpectTimeout),
},
{
Flag: "run-expect-output",
Env: "CODER_SCALETEST_RUN_EXPECT_OUTPUT",
Description: "Expect the command to output the given string (on a single line). " + "If the command does not output the given string, it will be marked as failed.",
Value: clibase.StringOf(&runExpectOutput),
Value: serpent.StringOf(&runExpectOutput),
},
{
Flag: "run-log-output",
Env: "CODER_SCALETEST_RUN_LOG_OUTPUT",
Description: "Log the output of the command to the test logs. " + "This should be left off unless you expect small amounts of output. " + "Large amounts of output will cause high memory usage.",
Value: clibase.BoolOf(&runLogOutput),
Value: serpent.BoolOf(&runLogOutput),
},
{
Flag: "connect-url",
Env: "CODER_SCALETEST_CONNECT_URL",
Description: "URL to connect to inside the the workspace over WireGuard. " + "If not specified, no connections will be made over WireGuard.",
Value: clibase.StringOf(&connectURL),
Value: serpent.StringOf(&connectURL),
},
{
Flag: "connect-mode",
Env: "CODER_SCALETEST_CONNECT_MODE",
Default: "derp",
Description: "Mode to use for connecting to the workspace.",
Value: clibase.EnumOf(&connectMode, "derp", "direct"),
Value: serpent.EnumOf(&connectMode, "derp", "direct"),
},
{
Flag: "connect-hold",
Env: "CODER_SCALETEST_CONNECT_HOLD",
Default: "30s",
Description: "How long to hold the WireGuard connection open for.",
Value: clibase.DurationOf(&connectHold),
Value: serpent.DurationOf(&connectHold),
},
{
Flag: "connect-interval",
Env: "CODER_SCALETEST_CONNECT_INTERVAL",
Default: "1s",
Value: clibase.DurationOf(&connectInterval),
Value: serpent.DurationOf(&connectInterval),
Description: "How long to wait between making requests to the --connect-url once the connection is established.",
},
{
@@ -845,14 +846,14 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
Env: "CODER_SCALETEST_CONNECT_TIMEOUT",
Default: "5s",
Description: "Timeout for each request to the --connect-url.",
Value: clibase.DurationOf(&connectTimeout),
Value: serpent.DurationOf(&connectTimeout),
},
{
Flag: "use-host-login",
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
Default: "false",
Description: "Use the user logged in on the host machine, instead of creating users.",
Value: clibase.BoolOf(&useHostUser),
Value: serpent.BoolOf(&useHostUser),
},
}
@@ -864,7 +865,7 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
return cmd
}
func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd {
func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
var (
tickInterval time.Duration
bytesPerTick int64
@@ -881,16 +882,16 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd {
prometheusFlags = &scaletestPrometheusFlags{}
)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "workspace-traffic",
Short: "Generate traffic to scaletest workspaces through coderd",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) (err error) {
Handler: func(inv *serpent.Invocation) (err error) {
ctx := inv.Context()
notifyCtx, stop := signal.NotifyContext(ctx, InterruptSignals...) // Checked later.
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...) // Checked later.
defer stop()
ctx = notifyCtx
@@ -1056,47 +1057,47 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd {
},
}
cmd.Options = []clibase.Option{
cmd.Options = []serpent.Option{
{
Flag: "template",
FlagShorthand: "t",
Env: "CODER_SCALETEST_TEMPLATE",
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
Value: clibase.StringOf(&template),
Value: serpent.StringOf(&template),
},
{
Flag: "target-workspaces",
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
Value: clibase.StringOf(&targetWorkspaces),
Value: serpent.StringOf(&targetWorkspaces),
},
{
Flag: "bytes-per-tick",
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK",
Default: "1024",
Description: "How much traffic to generate per tick.",
Value: clibase.Int64Of(&bytesPerTick),
Value: serpent.Int64Of(&bytesPerTick),
},
{
Flag: "tick-interval",
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_TICK_INTERVAL",
Default: "100ms",
Description: "How often to send traffic.",
Value: clibase.DurationOf(&tickInterval),
Value: serpent.DurationOf(&tickInterval),
},
{
Flag: "ssh",
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_SSH",
Default: "",
Description: "Send traffic over SSH, cannot be used with --app.",
Value: clibase.BoolOf(&ssh),
Value: serpent.BoolOf(&ssh),
},
{
Flag: "app",
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_APP",
Default: "",
Description: "Send WebSocket traffic to a workspace app (proxied via coderd), cannot be used with --ssh.",
Value: clibase.StringOf(&app),
Value: serpent.StringOf(&app),
},
}
@@ -1109,7 +1110,7 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd {
return cmd
}
func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
func (r *RootCmd) scaletestDashboard() *serpent.Command {
var (
interval time.Duration
jitter time.Duration
@@ -1125,13 +1126,13 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
prometheusFlags = &scaletestPrometheusFlags{}
)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "dashboard",
Short: "Generate traffic to the HTTP API to simulate use of the dashboard.",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
if !(interval > 0) {
return xerrors.Errorf("--interval must be greater than zero")
}
@@ -1261,40 +1262,40 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
},
}
cmd.Options = []clibase.Option{
cmd.Options = []serpent.Option{
{
Flag: "target-users",
Env: "CODER_SCALETEST_DASHBOARD_TARGET_USERS",
Description: "Target a specific range of users in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted users (0-9).",
Value: clibase.StringOf(&targetUsers),
Value: serpent.StringOf(&targetUsers),
},
{
Flag: "interval",
Env: "CODER_SCALETEST_DASHBOARD_INTERVAL",
Default: "10s",
Description: "Interval between actions.",
Value: clibase.DurationOf(&interval),
Value: serpent.DurationOf(&interval),
},
{
Flag: "jitter",
Env: "CODER_SCALETEST_DASHBOARD_JITTER",
Default: "5s",
Description: "Jitter between actions.",
Value: clibase.DurationOf(&jitter),
Value: serpent.DurationOf(&jitter),
},
{
Flag: "headless",
Env: "CODER_SCALETEST_DASHBOARD_HEADLESS",
Default: "true",
Description: "Controls headless mode. Setting to false is useful for debugging.",
Value: clibase.BoolOf(&headless),
Value: serpent.BoolOf(&headless),
},
{
Flag: "rand-seed",
Env: "CODER_SCALETEST_DASHBOARD_RAND_SEED",
Default: "0",
Description: "Seed for the random number generator.",
Value: clibase.Int64Of(&randSeed),
Value: serpent.Int64Of(&randSeed),
},
}
+4 -4
View File
@@ -2,13 +2,13 @@
package cli
import "github.com/coder/coder/v2/cli/clibase"
import "github.com/coder/serpent"
func (r *RootCmd) scaletestCmd() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) scaletestCmd() *serpent.Command {
cmd := &serpent.Command{
Use: "scaletest",
Short: "Run a scale test against the Coder API",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
SlimUnsupported(inv.Stderr, "exp scaletest")
return nil
},
+20 -13
View File
@@ -2,33 +2,35 @@ package cli
import (
"encoding/json"
"fmt"
"golang.org/x/xerrors"
"github.com/tidwall/gjson"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) externalAuth() *clibase.Cmd {
return &clibase.Cmd{
func (r *RootCmd) externalAuth() *serpent.Command {
return &serpent.Command{
Use: "external-auth",
Short: "Manage external authentication",
Long: "Authenticate with external services inside of a workspace.",
Handler: func(i *clibase.Invocation) error {
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.externalAuthAccessToken(),
},
}
}
func (r *RootCmd) externalAuthAccessToken() *clibase.Cmd {
func (r *RootCmd) externalAuthAccessToken() *serpent.Command {
var extra string
return &clibase.Cmd{
return &serpent.Command{
Use: "access-token <provider>",
Short: "Print auth for an external provider",
Long: "Print an access-token for an external auth provider. " +
@@ -52,22 +54,27 @@ fi
Command: "coder external-auth access-token slack --extra \"authed_user.id\"",
},
),
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: clibase.OptionSet{{
Options: serpent.OptionSet{{
Name: "Extra",
Flag: "extra",
Description: "Extract a field from the \"extra\" properties of the OAuth token.",
Value: clibase.StringOf(&extra),
Value: serpent.StringOf(&extra),
}},
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
ctx, stop := inv.SignalNotifyContext(ctx, InterruptSignals...)
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
if r.agentToken == "" {
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprintf(headLineStyle(), "No agent token found, this command must be run from inside a running workspace.\n"))
return xerrors.Errorf("agent token not found")
}
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
+4 -4
View File
@@ -24,7 +24,7 @@ func TestExternalAuth(t *testing.T) {
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
waiter := clitest.StartWithWaiter(t, inv)
@@ -40,7 +40,7 @@ func TestExternalAuth(t *testing.T) {
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
@@ -55,7 +55,7 @@ func TestExternalAuth(t *testing.T) {
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token")
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token")
watier := clitest.StartWithWaiter(t, inv)
watier.RequireContains("wanted 1 args but got 0")
})
@@ -71,7 +71,7 @@ func TestExternalAuth(t *testing.T) {
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github", "--extra", "hey")
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github", "--extra", "hey")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
+11 -11
View File
@@ -5,22 +5,22 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) favorite() *clibase.Cmd {
func (r *RootCmd) favorite() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Aliases: []string{"fav", "favou" + "rite"},
Annotations: workspaceCommand,
Use: "favorite <workspace>",
Short: "Add a workspace to your favorites",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
@@ -36,18 +36,18 @@ func (r *RootCmd) favorite() *clibase.Cmd {
return cmd
}
func (r *RootCmd) unfavorite() *clibase.Cmd {
func (r *RootCmd) unfavorite() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Aliases: []string{"unfav", "unfavou" + "rite"},
Annotations: workspaceCommand,
Use: "unfavorite <workspace>",
Short: "Remove a workspace from your favorites",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
+5 -5
View File
@@ -8,24 +8,24 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/retry"
"github.com/coder/serpent"
)
// gitAskpass is used by the Coder agent to automatically authenticate
// with Git providers based on a hostname.
func (r *RootCmd) gitAskpass() *clibase.Cmd {
return &clibase.Cmd{
func (r *RootCmd) gitAskpass() *serpent.Command {
return &serpent.Command{
Use: "gitaskpass",
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
ctx, stop := inv.SignalNotifyContext(ctx, InterruptSignals...)
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
user, host, err := gitauth.ParseAskpass(inv.Args[0])
+5 -5
View File
@@ -13,23 +13,23 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) gitssh() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) gitssh() *serpent.Command {
cmd := &serpent.Command{
Use: "gitssh",
Hidden: true,
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
env := os.Environ()
// Catch interrupt signals to ensure the temporary private
// key file is cleaned up on most cases.
ctx, stop := inv.SignalNotifyContext(ctx, InterruptSignals...)
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
// Early check so errors are reported immediately.
+37 -19
View File
@@ -4,7 +4,9 @@ import (
"bufio"
_ "embed"
"fmt"
"os"
"regexp"
"slices"
"sort"
"strings"
"text/tabwriter"
@@ -15,9 +17,9 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
//go:embed help.tpl
@@ -26,7 +28,7 @@ var helpTemplateRaw string
type optionGroup struct {
Name string
Description string
Options clibase.OptionSet
Options serpent.OptionSet
}
func ttyWidth() int {
@@ -75,9 +77,9 @@ var usageTemplate = func() *template.Template {
headerFg.Format(txt)
return txt.String()
},
"typeHelper": func(opt *clibase.Option) string {
"typeHelper": func(opt *serpent.Option) string {
switch v := opt.Value.(type) {
case *clibase.Enum:
case *serpent.Enum:
return strings.Join(v.Choices, "|")
default:
return v.Type()
@@ -107,7 +109,7 @@ var usageTemplate = func() *template.Template {
}
return sb.String()
},
"formatSubcommand": func(cmd *clibase.Cmd) string {
"formatSubcommand": func(cmd *serpent.Command) string {
// Minimize padding by finding the longest neighboring name.
maxNameLength := len(cmd.Name())
if parent := cmd.Parent; parent != nil {
@@ -142,23 +144,23 @@ var usageTemplate = func() *template.Template {
return sb.String()
},
"envName": func(opt clibase.Option) string {
"envName": func(opt serpent.Option) string {
if opt.Env == "" {
return ""
}
return opt.Env
},
"flagName": func(opt clibase.Option) string {
"flagName": func(opt serpent.Option) string {
return opt.Flag
},
"isEnterprise": func(opt clibase.Option) bool {
"isEnterprise": func(opt serpent.Option) bool {
return opt.Annotations.IsSet("enterprise")
},
"isDeprecated": func(opt clibase.Option) bool {
"isDeprecated": func(opt serpent.Option) bool {
return len(opt.UseInstead) > 0
},
"useInstead": func(opt clibase.Option) string {
"useInstead": func(opt serpent.Option) string {
var sb strings.Builder
for i, s := range opt.UseInstead {
if i > 0 {
@@ -189,12 +191,12 @@ var usageTemplate = func() *template.Template {
s = wrapTTY(s)
return s
},
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
"visibleChildren": func(cmd *serpent.Command) []*serpent.Command {
return filterSlice(cmd.Children, func(c *serpent.Command) bool {
return !c.Hidden
})
},
"optionGroups": func(cmd *clibase.Cmd) []optionGroup {
"optionGroups": func(cmd *serpent.Command) []optionGroup {
groups := []optionGroup{{
// Default group.
Name: "",
@@ -240,7 +242,7 @@ var usageTemplate = func() *template.Template {
groups = append(groups, optionGroup{
Name: groupName,
Description: opt.Group.Description,
Options: clibase.OptionSet{opt},
Options: serpent.OptionSet{opt},
})
}
sort.Slice(groups, func(i, j int) bool {
@@ -318,8 +320,27 @@ 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 {
func helpFn() serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
// Check for invalid subcommands before printing help.
if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) {
_, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unrecognized subcommand %q\n", inv.Args[0])
}
if len(inv.Args) > 0 {
// Return an error so that exit status is non-zero when
// a subcommand is not found.
err := xerrors.Errorf("unrecognized subcommand %q", strings.Join(inv.Args, " "))
if slices.Contains(os.Args, "--help") {
// Subcommand error is not wrapped in RunCommandErr if command
// is invoked with --help with no HelpHandler
return &serpent.RunCommandError{
Cmd: inv.Command,
Err: err,
}
}
return err
}
// We use stdout for help and not stderr since there's no straightforward
// way to distinguish between a user error and a help request.
//
@@ -340,9 +361,6 @@ func helpFn() clibase.HandlerFunc {
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
}
}
+6 -6
View File
@@ -8,10 +8,10 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
// workspaceListRow is the type provided to the OutputFormatter. This is a bit
@@ -70,7 +70,7 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace)
}
}
func (r *RootCmd) list() *clibase.Cmd {
func (r *RootCmd) list() *serpent.Command {
var (
filter cliui.WorkspaceFilter
formatter = cliui.NewOutputFormatter(
@@ -92,16 +92,16 @@ func (r *RootCmd) list() *clibase.Cmd {
)
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "list",
Short: "List workspaces",
Aliases: []string{"ls"},
Middleware: clibase.Chain(
clibase.RequireNArgs(0),
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace)
if err != nil {
return err
+16 -27
View File
@@ -18,11 +18,10 @@ import (
"github.com/coder/pretty"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const (
@@ -40,7 +39,7 @@ func init() {
browser.Stdout = io.Discard
}
func promptFirstUsername(inv *clibase.Invocation) (string, error) {
func promptFirstUsername(inv *serpent.Invocation) (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", xerrors.Errorf("get current user: %w", err)
@@ -59,7 +58,7 @@ func promptFirstUsername(inv *clibase.Invocation) (string, error) {
return username, nil
}
func promptFirstPassword(inv *clibase.Invocation) (string, error) {
func promptFirstPassword(inv *serpent.Invocation) (string, error) {
retry:
password, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":",
@@ -89,7 +88,7 @@ retry:
}
func (r *RootCmd) loginWithPassword(
inv *clibase.Invocation,
inv *serpent.Invocation,
client *codersdk.Client,
email, password string,
) error {
@@ -125,7 +124,7 @@ func (r *RootCmd) loginWithPassword(
return nil
}
func (r *RootCmd) login() *clibase.Cmd {
func (r *RootCmd) login() *serpent.Command {
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
var (
@@ -135,11 +134,11 @@ func (r *RootCmd) login() *clibase.Cmd {
trial bool
useTokenForSession bool
)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "login [<url>]",
Short: "Authenticate with Coder deployment",
Middleware: clibase.RequireRangeArgs(0, 1),
Handler: func(inv *clibase.Invocation) error {
Middleware: serpent.RequireRangeArgs(0, 1),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
rawURL := ""
var urlSource string
@@ -180,21 +179,11 @@ func (r *RootCmd) login() *clibase.Cmd {
serverURL.Scheme = "https"
}
client, err := r.createUnauthenticatedClient(ctx, serverURL)
client, err := r.createUnauthenticatedClient(ctx, serverURL, inv)
if err != nil {
return err
}
// 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 = r.checkVersions(inv, client, buildinfo.Version())
if err != nil {
// Checking versions isn't a fatal error so we print a warning
// and proceed.
_, _ = fmt.Fprintln(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, err.Error()))
}
hasFirstUser, err := client.HasFirstUser(ctx)
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)
@@ -350,35 +339,35 @@ func (r *RootCmd) login() *clibase.Cmd {
return nil
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.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),
Value: serpent.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),
Value: serpent.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),
Value: serpent.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),
Value: serpent.BoolOf(&trial),
},
{
Flag: "use-token-as-session",
Description: "By default, the CLI will generate a new session token when logging in. This flag will instead use the provided token as the session token.",
Value: clibase.BoolOf(&useTokenForSession),
Value: serpent.BoolOf(&useTokenForSession),
},
}
return cmd
@@ -397,7 +386,7 @@ func isWSL() (bool, error) {
}
// openURL opens the provided URL via user's default browser
func openURL(inv *clibase.Invocation, urlToOpen string) error {
func openURL(inv *serpent.Invocation, urlToOpen string) error {
noOpen, err := inv.ParsedFlags().GetBool(varNoOpen)
if err != nil {
panic(err)
+5 -5
View File
@@ -7,20 +7,20 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) logout() *clibase.Cmd {
func (r *RootCmd) logout() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "logout",
Short: "Unauthenticate your local session",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
var errors []error
config := r.createConfig()
-27
View File
@@ -97,33 +97,6 @@ func TestLogout(t *testing.T) {
<-logoutChan
})
t.Run("NoSessionFile", func(t *testing.T) {
t.Parallel()
pty := ptytest.New(t)
config := login(t, pty)
// Ensure session files exist.
require.FileExists(t, string(config.URL()))
require.FileExists(t, string(config.Session()))
err := os.Remove(string(config.Session()))
require.NoError(t, err)
logoutChan := make(chan struct{})
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
logout.Stdin = pty.Input()
logout.Stdout = pty.Output()
go func() {
defer close(logoutChan)
err = logout.Run()
assert.ErrorContains(t, err, "You are not logged in. Try logging in using 'coder login'.")
}()
<-logoutChan
})
t.Run("CannotDeleteFiles", func(t *testing.T) {
t.Parallel()
+8 -7
View File
@@ -8,25 +8,26 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
)
func (r *RootCmd) netcheck() *clibase.Cmd {
func (r *RootCmd) netcheck() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "netcheck",
Short: "Print network debug information for DERP and STUN",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithTimeout(inv.Context(), 30*time.Second)
defer cancel()
connInfo, err := client.WorkspaceAgentConnectionInfoGeneric(ctx)
connInfo, err := workspacesdk.New(client).AgentConnectionInfoGeneric(ctx)
if err != nil {
return err
}
@@ -56,6 +57,6 @@ func (r *RootCmd) netcheck() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{}
cmd.Options = serpent.OptionSet{}
return cmd
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/pty/ptytest"
)
@@ -27,7 +27,7 @@ func TestNetcheck(t *testing.T) {
b := out.Bytes()
t.Log(string(b))
var report codersdk.DERPHealthReport
var report healthsdk.DERPHealthReport
require.NoError(t, json.Unmarshal(b, &report))
assert.True(t, report.Healthy)
+13 -13
View File
@@ -12,19 +12,19 @@ import (
"github.com/skratchdot/open-golang/open"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) open() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) open() *serpent.Command {
cmd := &serpent.Command{
Use: "open",
Short: "Open a workspace",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.openVSCode(),
},
}
@@ -33,22 +33,22 @@ func (r *RootCmd) open() *clibase.Cmd {
const vscodeDesktopName = "VS Code Desktop"
func (r *RootCmd) openVSCode() *clibase.Cmd {
func (r *RootCmd) openVSCode() *serpent.Command {
var (
generateToken bool
testOpenError bool
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "vscode <workspace> [<directory in workspace>]",
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
Middleware: clibase.Chain(
clibase.RequireRangeArgs(1, 2),
Middleware: serpent.Chain(
serpent.RequireRangeArgs(1, 2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
@@ -186,7 +186,7 @@ func (r *RootCmd) openVSCode() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "generate-token",
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN",
@@ -195,12 +195,12 @@ func (r *RootCmd) openVSCode() *clibase.Cmd {
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
vscodeDesktopName,
),
Value: clibase.BoolOf(&generateToken),
Value: serpent.BoolOf(&generateToken),
},
{
Flag: "test.open-error",
Description: "Don't run the open command.",
Value: clibase.BoolOf(&testOpenError),
Value: serpent.BoolOf(&testOpenError),
Hidden: true, // This is for testing!
},
}
+20 -20
View File
@@ -9,38 +9,38 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) organizations() *clibase.Cmd {
cmd := &clibase.Cmd{
func (r *RootCmd) organizations() *serpent.Command {
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "organizations [subcommand]",
Short: "Organization related commands",
Aliases: []string{"organization", "org", "orgs"},
Hidden: true, // Hidden until these commands are complete.
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.currentOrganization(),
r.switchOrganization(),
r.createOrganization(),
},
}
cmd.Options = clibase.OptionSet{}
cmd.Options = serpent.OptionSet{}
return cmd
}
func (r *RootCmd) switchOrganization() *clibase.Cmd {
func (r *RootCmd) switchOrganization() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "set <organization name | ID>",
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + formatExamples(
@@ -53,12 +53,12 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd {
Command: "coder organizations set my-org",
},
),
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
clibase.RequireRangeArgs(0, 1),
serpent.RequireRangeArgs(0, 1),
),
Options: clibase.OptionSet{},
Handler: func(inv *clibase.Invocation) error {
Options: serpent.OptionSet{},
Handler: func(inv *serpent.Invocation) error {
conf := r.createConfig()
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
@@ -138,7 +138,7 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd {
// promptUserSelectOrg will prompt the user to select an organization from a list
// of their organizations.
func promptUserSelectOrg(inv *clibase.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
func promptUserSelectOrg(inv *serpent.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
// Default choice
var defaultOrg string
// Comes from config file
@@ -206,7 +206,7 @@ func orgNames(orgs []codersdk.Organization) []string {
return names
}
func (r *RootCmd) currentOrganization() *clibase.Cmd {
func (r *RootCmd) currentOrganization() *serpent.Command {
var (
stringFormat func(orgs []codersdk.Organization) (string, error)
client = new(codersdk.Client)
@@ -224,23 +224,23 @@ func (r *RootCmd) currentOrganization() *clibase.Cmd {
)
onlyID = false
)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "show [current|me|uuid]",
Short: "Show the organization, if no argument is given, the organization currently in use will be shown.",
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
clibase.RequireRangeArgs(0, 1),
serpent.RequireRangeArgs(0, 1),
),
Options: clibase.OptionSet{
Options: serpent.OptionSet{
{
Name: "only-id",
Description: "Only print the organization ID.",
Required: false,
Flag: "only-id",
Value: clibase.BoolOf(&onlyID),
Value: serpent.BoolOf(&onlyID),
},
},
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
orgArg := "current"
if len(inv.Args) >= 1 {
orgArg = inv.Args[0]
+45
View File
@@ -1,8 +1,14 @@
package cli_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
@@ -16,6 +22,38 @@ import (
func TestCurrentOrganization(t *testing.T) {
t.Parallel()
// This test emulates 2 cases:
// 1. The user is not a part of the default organization, but only belongs to one.
// 2. The user is connecting to an older Coder instance.
t.Run("no-default", func(t *testing.T) {
t.Parallel()
orgID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]codersdk.Organization{
{
ID: orgID,
Name: "not-default",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
IsDefault: false,
},
})
}))
defer srv.Close()
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "organizations", "show", "current")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(orgID.String())
})
t.Run("OnlyID", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
@@ -108,3 +146,10 @@ func TestOrganizationSwitch(t *testing.T) {
pty.ExpectMatch(exp.ID.String())
})
}
func must[V any](v V, err error) V {
if err != nil {
panic(err)
}
return v
}
+7 -7
View File
@@ -6,28 +6,28 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) createOrganization() *clibase.Cmd {
func (r *RootCmd) createOrganization() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "create <organization name>",
Short: "Create a new organization.",
// This action is currently irreversible, so it's hidden until we have a way to delete organizations.
Hidden: true,
Middleware: clibase.Chain(
Middleware: serpent.Chain(
r.InitClient(client),
clibase.RequireNArgs(1),
serpent.RequireNArgs(1),
),
Options: clibase.OptionSet{
Options: serpent.OptionSet{
cliui.SkipPromptOption(),
},
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
orgName := inv.Args[0]
// This check is not perfect since not all users can read all organizations.
+15 -15
View File
@@ -9,8 +9,8 @@ import (
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
// workspaceParameterFlags are used by commands processing rich parameters and/or build options.
@@ -24,49 +24,49 @@ type workspaceParameterFlags struct {
promptRichParameters bool
}
func (wpf *workspaceParameterFlags) allOptions() []clibase.Option {
func (wpf *workspaceParameterFlags) allOptions() []serpent.Option {
options := append(wpf.cliBuildOptions(), wpf.cliParameters()...)
return append(options, wpf.alwaysPrompt())
}
func (wpf *workspaceParameterFlags) cliBuildOptions() []clibase.Option {
return clibase.OptionSet{
func (wpf *workspaceParameterFlags) cliBuildOptions() []serpent.Option {
return serpent.OptionSet{
{
Flag: "build-option",
Env: "CODER_BUILD_OPTION",
Description: `Build option value in the format "name=value".`,
Value: clibase.StringArrayOf(&wpf.buildOptions),
Value: serpent.StringArrayOf(&wpf.buildOptions),
},
{
Flag: "build-options",
Description: "Prompt for one-time build options defined with ephemeral parameters.",
Value: clibase.BoolOf(&wpf.promptBuildOptions),
Value: serpent.BoolOf(&wpf.promptBuildOptions),
},
}
}
func (wpf *workspaceParameterFlags) cliParameters() []clibase.Option {
return clibase.OptionSet{
clibase.Option{
func (wpf *workspaceParameterFlags) cliParameters() []serpent.Option {
return serpent.OptionSet{
serpent.Option{
Flag: "parameter",
Env: "CODER_RICH_PARAMETER",
Description: `Rich parameter value in the format "name=value".`,
Value: clibase.StringArrayOf(&wpf.richParameters),
Value: serpent.StringArrayOf(&wpf.richParameters),
},
clibase.Option{
serpent.Option{
Flag: "rich-parameter-file",
Env: "CODER_RICH_PARAMETER_FILE",
Description: "Specify a file path with values for rich parameters defined in the template.",
Value: clibase.StringOf(&wpf.richParameterFile),
Value: serpent.StringOf(&wpf.richParameterFile),
},
}
}
func (wpf *workspaceParameterFlags) alwaysPrompt() clibase.Option {
return clibase.Option{
func (wpf *workspaceParameterFlags) alwaysPrompt() serpent.Option {
return serpent.Option{
Flag: "always-prompt",
Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.",
Value: clibase.BoolOf(&wpf.promptRichParameters),
Value: serpent.BoolOf(&wpf.promptRichParameters),
}
}
+3 -3
View File
@@ -6,11 +6,11 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil/levenshtein"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
type WorkspaceCLIAction int
@@ -69,7 +69,7 @@ func (pr *ParameterResolver) WithPromptBuildOptions(promptBuildOptions bool) *Pa
return pr
}
func (pr *ParameterResolver) Resolve(inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
var staged []codersdk.WorkspaceBuildParameter
var err error
@@ -209,7 +209,7 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil
return nil
}
func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
for _, tvp := range templateVersionParameters {
p := findWorkspaceBuildParameter(tvp.Name, resolved)
if p != nil {
+16 -14
View File
@@ -12,12 +12,13 @@ import (
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
)
func (r *RootCmd) ping() *clibase.Cmd {
func (r *RootCmd) ping() *serpent.Command {
var (
pingNum int64
pingTimeout time.Duration
@@ -25,15 +26,15 @@ func (r *RootCmd) ping() *clibase.Cmd {
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "ping <workspace>",
Short: "Ping a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
@@ -55,10 +56,11 @@ func (r *RootCmd) ping() *clibase.Cmd {
if r.disableDirect {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
}
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{
Logger: logger,
BlockEndpoints: r.disableDirect,
})
conn, err := workspacesdk.New(client).
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
Logger: logger,
BlockEndpoints: r.disableDirect,
})
if err != nil {
return err
}
@@ -143,26 +145,26 @@ func (r *RootCmd) ping() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: "wait",
Description: "Specifies how long to wait between pings.",
Default: "1s",
Value: clibase.DurationOf(&pingWait),
Value: serpent.DurationOf(&pingWait),
},
{
Flag: "timeout",
FlagShorthand: "t",
Default: "5s",
Description: "Specifies how long to wait for a ping to complete.",
Value: clibase.DurationOf(&pingTimeout),
Value: serpent.DurationOf(&pingTimeout),
},
{
Flag: "num",
FlagShorthand: "n",
Default: "10",
Description: "Specifies the number of pings to perform.",
Value: clibase.Int64Of(&pingNum),
Value: serpent.Int64Of(&pingNum),
},
}
return cmd
+21 -20
View File
@@ -18,19 +18,20 @@ import (
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
)
func (r *RootCmd) portForward() *clibase.Cmd {
func (r *RootCmd) portForward() *serpent.Command {
var (
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
disableAutostart bool
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "port-forward <workspace>",
Short: `Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R".`,
Aliases: []string{"tunnel"},
@@ -56,11 +57,11 @@ func (r *RootCmd) portForward() *clibase.Cmd {
Command: "coder port-forward <workspace> --tcp 1.2.3.4:8080:8080",
},
),
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
@@ -69,10 +70,6 @@ func (r *RootCmd) portForward() *clibase.Cmd {
return xerrors.Errorf("parse port-forward specs: %w", err)
}
if len(specs) == 0 {
err = inv.Command.HelpHandler(inv)
if err != nil {
return xerrors.Errorf("generate help output: %w", err)
}
return xerrors.New("no port-forwards requested")
}
@@ -106,10 +103,11 @@ func (r *RootCmd) portForward() *clibase.Cmd {
if r.disableDirect {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
}
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{
Logger: logger,
BlockEndpoints: r.disableDirect,
})
conn, err := workspacesdk.New(client).
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
Logger: logger,
BlockEndpoints: r.disableDirect,
})
if err != nil {
return err
}
@@ -140,6 +138,8 @@ func (r *RootCmd) portForward() *clibase.Cmd {
listeners[i] = l
}
stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID)
// Wait for the context to be canceled or for a signal and close
// all listeners.
var closeErr error
@@ -160,6 +160,7 @@ func (r *RootCmd) portForward() *clibase.Cmd {
}
cancel()
stopUpdating()
closeAllListeners()
}()
@@ -171,21 +172,21 @@ func (r *RootCmd) portForward() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.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),
Value: serpent.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),
Value: serpent.StringArrayOf(&udpForwards),
},
sshDisableAutostartOption(clibase.BoolOf(&disableAutostart)),
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
}
return cmd
@@ -193,8 +194,8 @@ func (r *RootCmd) portForward() *clibase.Cmd {
func listenAndPortForward(
ctx context.Context,
inv *clibase.Invocation,
conn *codersdk.WorkspaceAgentConn,
inv *serpent.Invocation,
conn *workspacesdk.AgentConn,
wg *sync.WaitGroup,
spec portForwardSpec,
logger slog.Logger,
+28 -6
View File
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
@@ -35,15 +36,10 @@ func TestPortForward_None(t *testing.T) {
inv, root := clitest.New(t, "port-forward", "blah")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
err := inv.Run()
require.Error(t, err)
require.ErrorContains(t, err, "no port-forwards")
// Check that the help was printed.
pty.ExpectMatch("port-forward <workspace>")
}
func TestPortForward(t *testing.T) {
@@ -101,7 +97,12 @@ func TestPortForward(t *testing.T) {
// Setup agent once to be shared between test-cases (avoid expensive
// non-parallel setup).
var (
client, db = coderdtest.NewWithDatabase(t, nil)
wuTick = make(chan time.Time)
wuFlush = make(chan int, 1)
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
WorkspaceUsageTrackerTick: wuTick,
WorkspaceUsageTrackerFlush: wuFlush,
})
admin = coderdtest.CreateFirstUser(t, client)
member, memberUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace = runAgent(t, client, memberUser.ID, db)
@@ -153,6 +154,13 @@ func TestPortForward(t *testing.T) {
cancel()
err = <-errC
require.ErrorIs(t, err, context.Canceled)
flushCtx := testutil.Context(t, testutil.WaitShort)
testutil.RequireSendCtx(flushCtx, t, wuTick, dbtime.Now())
_ = testutil.RequireRecvCtx(flushCtx, t, wuFlush)
updated, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
require.Greater(t, updated.LastUsedAt, workspace.LastUsedAt)
})
t.Run(c.name+"_TwoPorts", func(t *testing.T) {
@@ -201,6 +209,13 @@ func TestPortForward(t *testing.T) {
cancel()
err = <-errC
require.ErrorIs(t, err, context.Canceled)
flushCtx := testutil.Context(t, testutil.WaitShort)
testutil.RequireSendCtx(flushCtx, t, wuTick, dbtime.Now())
_ = testutil.RequireRecvCtx(flushCtx, t, wuFlush)
updated, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
require.Greater(t, updated.LastUsedAt, workspace.LastUsedAt)
})
}
@@ -262,6 +277,13 @@ func TestPortForward(t *testing.T) {
cancel()
err := <-errC
require.ErrorIs(t, err, context.Canceled)
flushCtx := testutil.Context(t, testutil.WaitShort)
testutil.RequireSendCtx(flushCtx, t, wuTick, dbtime.Now())
_ = testutil.RequireRecvCtx(flushCtx, t, wuFlush)
updated, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
require.Greater(t, updated.LastUsedAt, workspace.LastUsedAt)
})
}
+6 -6
View File
@@ -6,21 +6,21 @@ import (
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) publickey() *clibase.Cmd {
func (r *RootCmd) publickey() *serpent.Command {
var reset bool
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
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 {
Handler: func(inv *serpent.Invocation) error {
if reset {
// Confirm prompt if using --reset. We don't want to accidentally
// reset our public key.
@@ -58,11 +58,11 @@ func (r *RootCmd) publickey() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.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),
Value: serpent.BoolOf(&reset),
},
cliui.SkipPromptOption(),
}
+6 -6
View File
@@ -6,23 +6,23 @@ import (
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) rename() *clibase.Cmd {
func (r *RootCmd) rename() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "rename <workspace> <new name>",
Short: "Rename a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
Middleware: serpent.Chain(
serpent.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
+7 -7
View File
@@ -9,22 +9,22 @@ import (
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/migrations"
"github.com/coder/coder/v2/coderd/userpassword"
)
func (*RootCmd) resetPassword() *clibase.Cmd {
func (*RootCmd) resetPassword() *serpent.Command {
var postgresURL string
root := &clibase.Cmd{
root := &serpent.Command{
Use: "reset-password <username>",
Short: "Directly connect to the database to reset a user's password",
Middleware: clibase.RequireNArgs(1),
Handler: func(inv *clibase.Invocation) error {
Middleware: serpent.RequireNArgs(1),
Handler: func(inv *serpent.Invocation) error {
username := inv.Args[0]
sqlDB, err := sql.Open("postgres", postgresURL)
@@ -90,12 +90,12 @@ func (*RootCmd) resetPassword() *clibase.Cmd {
},
}
root.Options = clibase.OptionSet{
root.Options = serpent.OptionSet{
{
Flag: "postgres-url",
Description: "URL of a PostgreSQL database to connect to.",
Env: "CODER_PG_CONNECTION_URL",
Value: clibase.StringOf(&postgresURL),
Value: serpent.StringOf(&postgresURL),
},
}
+4 -6
View File
@@ -2,18 +2,16 @@
package cli
import (
"github.com/coder/coder/v2/cli/clibase"
)
import "github.com/coder/serpent"
func (*RootCmd) resetPassword() *clibase.Cmd {
root := &clibase.Cmd{
func (*RootCmd) resetPassword() *serpent.Command {
root := &serpent.Command{
Use: "reset-password <username>",
Short: "Directly connect to the database to reset a user's password",
// We accept RawArgs so all commands and flags are accepted.
RawArgs: true,
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
SlimUnsupported(inv.Stderr, "reset-password")
return nil
},
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/database/postgres"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
@@ -18,7 +18,7 @@ import (
// nolint:paralleltest
func TestResetPassword(t *testing.T) {
// postgres.Open() seems to be creating race conditions when run in parallel.
// dbtestutil.Open() seems to be creating race conditions when run in parallel.
// t.Parallel()
if runtime.GOOS != "linux" || testing.Short() {
@@ -32,7 +32,7 @@ func TestResetPassword(t *testing.T) {
const newPassword = "MyNewPassword!"
// start postgres and coder server processes
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
ctx, cancelFunc := context.WithCancel(context.Background())
+7 -7
View File
@@ -7,26 +7,26 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) restart() *clibase.Cmd {
func (r *RootCmd) restart() *serpent.Command {
var parameterFlags workspaceParameterFlags
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "restart <workspace>",
Short: "Restart a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Options: clibase.OptionSet{cliui.SkipPromptOption()},
Handler: func(inv *clibase.Invocation) error {
Options: serpent.OptionSet{cliui.SkipPromptOption()},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
out := inv.Stdout
+246 -272
View File
@@ -9,8 +9,6 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"os"
@@ -18,7 +16,9 @@ import (
"os/signal"
"path/filepath"
"runtime"
"runtime/trace"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
@@ -26,18 +26,19 @@ import (
"github.com/mattn/go-isatty"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/exp/slices"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/serpent"
)
var (
@@ -66,8 +67,7 @@ const (
varOrganizationSelect = "organization"
varDisableDirect = "disable-direct-connections"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
notLoggedInURLSavedMessage = "You are not logged in. Try logging in using 'coder login'."
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
@@ -79,14 +79,9 @@ const (
envURL = "CODER_URL"
)
var (
errUnauthenticated = xerrors.New(notLoggedInMessage)
errUnauthenticatedURLSaved = xerrors.New(notLoggedInURLSavedMessage)
)
func (r *RootCmd) Core() []*clibase.Cmd {
func (r *RootCmd) CoreSubcommands() []*serpent.Command {
// Please re-sort this list alphabetically if you change it!
return []*clibase.Cmd{
return []*serpent.Command{
r.dotfiles(),
r.externalAuth(),
r.login(),
@@ -132,14 +127,29 @@ func (r *RootCmd) Core() []*clibase.Cmd {
}
}
func (r *RootCmd) AGPL() []*clibase.Cmd {
all := append(r.Core(), r.Server( /* Do not import coderd here. */ nil))
func (r *RootCmd) AGPL() []*serpent.Command {
all := append(r.CoreSubcommands(), r.Server( /* Do not import coderd here. */ nil))
return all
}
// Main is the entrypoint for the Coder CLI.
func (r *RootCmd) RunMain(subcommands []*clibase.Cmd) {
rand.Seed(time.Now().UnixMicro())
// RunWithSubcommands runs the root command with the given subcommands.
// It is abstracted to enable the Enterprise code to add commands.
func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) {
// This configuration is not available as a standard option because we
// want to trace the entire program, including Options parsing.
goTraceFilePath, ok := os.LookupEnv("CODER_GO_TRACE")
if ok {
traceFile, err := os.OpenFile(goTraceFilePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
panic(fmt.Sprintf("failed to open trace file: %v", err))
}
defer traceFile.Close()
if err := trace.Start(traceFile); err != nil {
panic(fmt.Sprintf("failed to start trace: %v", err))
}
defer trace.Stop()
}
cmd, err := r.Command(subcommands)
if err != nil {
@@ -157,19 +167,19 @@ func (r *RootCmd) RunMain(subcommands []*clibase.Cmd) {
//nolint:revive
os.Exit(code)
}
f := prettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
f := PrettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
if err != nil {
f.format(err)
f.Format(err)
}
//nolint:revive
os.Exit(code)
}
}
func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, error) {
fmtLong := `Coder %s A tool for provisioning self-hosted development environments with Terraform.
`
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "coder [global-flags] <subcommand>",
Long: fmt.Sprintf(fmtLong, buildinfo.Version()) + formatExamples(
example{
@@ -181,7 +191,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Command: "coder templates init",
},
),
Handler: func(i *clibase.Invocation) error {
Handler: func(i *serpent.Invocation) error {
if r.versionFlag {
return r.version(defaultVersionInfo).Handler(i)
}
@@ -200,7 +210,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
cmd.AddSubcommands(subcommands...)
// Set default help handler for all commands.
cmd.Walk(func(c *clibase.Cmd) {
cmd.Walk(func(c *serpent.Command) {
if c.HelpHandler == nil {
c.HelpHandler = helpFn()
}
@@ -208,7 +218,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
var merr error
// Add [flags] to usage when appropriate.
cmd.Walk(func(cmd *clibase.Cmd) {
cmd.Walk(func(cmd *serpent.Command) {
const flags = "[flags]"
if strings.Contains(cmd.Use, flags) {
merr = errors.Join(
@@ -244,7 +254,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
})
// Add alises when appropriate.
cmd.Walk(func(cmd *clibase.Cmd) {
cmd.Walk(func(cmd *serpent.Command) {
// TODO: we should really be consistent about naming.
if cmd.Name() == "delete" || cmd.Name() == "remove" {
if slices.Contains(cmd.Aliases, "rm") {
@@ -259,7 +269,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
})
// Sanity-check command options.
cmd.Walk(func(cmd *clibase.Cmd) {
cmd.Walk(func(cmd *serpent.Command) {
for _, opt := range cmd.Options {
// Verify that every option is configurable.
if opt.Flag == "" && opt.Env == "" {
@@ -282,7 +292,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
var debugOptions bool
// Add a wrapper to every command to enable debugging options.
cmd.Walk(func(cmd *clibase.Cmd) {
cmd.Walk(func(cmd *serpent.Command) {
h := cmd.Handler
if h == nil {
// We should never have a nil handler, but if we do, do not
@@ -291,12 +301,12 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
// is required for a command such as command grouping (e.g. `users'
// and 'groups'), then the handler should be set to the helper
// function.
// func(inv *clibase.Invocation) error {
// func(inv *serpent.Invocation) error {
// return inv.Command.HelpHandler(inv)
// }
return
}
cmd.Handler = func(i *clibase.Invocation) error {
cmd.Handler = func(i *serpent.Invocation) error {
if !debugOptions {
return h(i)
}
@@ -318,36 +328,36 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
r.clientURL = new(url.URL)
}
globalGroup := &clibase.Group{
globalGroup := &serpent.Group{
Name: "Global",
Description: `Global options are applied to all commands. They can be set using environment variables or flags.`,
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Flag: varURL,
Env: envURL,
Description: "URL to a deployment.",
Value: clibase.URLOf(r.clientURL),
Value: serpent.URLOf(r.clientURL),
Group: globalGroup,
},
{
Flag: "debug-options",
Description: "Print all options, how they're set, then exit.",
Value: clibase.BoolOf(&debugOptions),
Value: serpent.BoolOf(&debugOptions),
Group: globalGroup,
},
{
Flag: varToken,
Env: envSessionToken,
Description: fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken),
Value: clibase.StringOf(&r.token),
Value: serpent.StringOf(&r.token),
Group: globalGroup,
},
{
Flag: varAgentToken,
Env: envAgentToken,
Description: "An agent authentication token.",
Value: clibase.StringOf(&r.agentToken),
Value: serpent.StringOf(&r.agentToken),
Hidden: true,
Group: globalGroup,
},
@@ -355,7 +365,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Flag: varAgentTokenFile,
Env: envAgentTokenFile,
Description: "A file containing an agent authentication token.",
Value: clibase.StringOf(&r.agentTokenFile),
Value: serpent.StringOf(&r.agentTokenFile),
Hidden: true,
Group: globalGroup,
},
@@ -363,7 +373,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Flag: varAgentURL,
Env: "CODER_AGENT_URL",
Description: "URL for an agent to access your deployment.",
Value: clibase.URLOf(r.agentURL),
Value: serpent.URLOf(r.agentURL),
Hidden: true,
Group: globalGroup,
},
@@ -371,35 +381,35 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Flag: varNoVersionCheck,
Env: envNoVersionCheck,
Description: "Suppress warning when client and server versions do not match.",
Value: clibase.BoolOf(&r.noVersionCheck),
Value: serpent.BoolOf(&r.noVersionCheck),
Group: globalGroup,
},
{
Flag: varNoFeatureWarning,
Env: envNoFeatureWarning,
Description: "Suppress warnings about unlicensed features.",
Value: clibase.BoolOf(&r.noFeatureWarning),
Value: serpent.BoolOf(&r.noFeatureWarning),
Group: globalGroup,
},
{
Flag: varHeader,
Env: "CODER_HEADER",
Description: "Additional HTTP headers added to all requests. Provide as " + `key=value` + ". Can be specified multiple times.",
Value: clibase.StringArrayOf(&r.header),
Value: serpent.StringArrayOf(&r.header),
Group: globalGroup,
},
{
Flag: varHeaderCommand,
Env: "CODER_HEADER_COMMAND",
Description: "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line.",
Value: clibase.StringOf(&r.headerCommand),
Value: serpent.StringOf(&r.headerCommand),
Group: globalGroup,
},
{
Flag: varNoOpen,
Env: "CODER_NO_OPEN",
Description: "Suppress opening the browser after logging in.",
Value: clibase.BoolOf(&r.noOpen),
Value: serpent.BoolOf(&r.noOpen),
Hidden: true,
Group: globalGroup,
},
@@ -408,7 +418,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Env: "CODER_FORCE_TTY",
Hidden: true,
Description: "Force the use of a TTY.",
Value: clibase.BoolOf(&r.forceTTY),
Value: serpent.BoolOf(&r.forceTTY),
Group: globalGroup,
},
{
@@ -416,20 +426,20 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
FlagShorthand: "v",
Env: "CODER_VERBOSE",
Description: "Enable verbose output.",
Value: clibase.BoolOf(&r.verbose),
Value: serpent.BoolOf(&r.verbose),
Group: globalGroup,
},
{
Flag: varDisableDirect,
Env: "CODER_DISABLE_DIRECT_CONNECTIONS",
Description: "Disable direct (P2P) connections to workspaces.",
Value: clibase.BoolOf(&r.disableDirect),
Value: serpent.BoolOf(&r.disableDirect),
Group: globalGroup,
},
{
Flag: "debug-http",
Description: "Debug codersdk HTTP requests.",
Value: clibase.BoolOf(&r.debugHTTP),
Value: serpent.BoolOf(&r.debugHTTP),
Group: globalGroup,
Hidden: true,
},
@@ -438,7 +448,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
Env: "CODER_CONFIG_DIR",
Description: "Path to the global `coder` config directory.",
Default: config.DefaultDir(),
Value: clibase.StringOf(&r.globalConfig),
Value: serpent.StringOf(&r.globalConfig),
Group: globalGroup,
},
{
@@ -446,7 +456,8 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
FlagShorthand: "z",
Env: "CODER_ORGANIZATION",
Description: "Select which organization (uuid or name) to use This overrides what is present in the config file.",
Value: clibase.StringOf(&r.organizationSelect),
Value: serpent.StringOf(&r.organizationSelect),
Hidden: true,
Group: globalGroup,
},
{
@@ -455,16 +466,11 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
// They have two Coder CLIs, and want to tell the difference by running
// the same base command.
Description: "Run the version command. Useful for v1 customers migrating to v2.",
Value: clibase.BoolOf(&r.versionFlag),
Value: serpent.BoolOf(&r.versionFlag),
Hidden: true,
},
}
err := cmd.PrepareAll()
if err != nil {
return nil, err
}
return cmd, nil
}
@@ -490,79 +496,20 @@ type RootCmd struct {
noFeatureWarning bool
}
func addTelemetryHeader(client *codersdk.Client, inv *clibase.Invocation) {
transport, ok := client.HTTPClient.Transport.(*codersdk.HeaderTransport)
if !ok {
transport = &codersdk.HeaderTransport{
Transport: client.HTTPClient.Transport,
Header: http.Header{},
}
client.HTTPClient.Transport = transport
}
var topts []telemetry.Option
for _, opt := range inv.Command.FullOptions() {
if opt.ValueSource == clibase.ValueSourceNone || opt.ValueSource == clibase.ValueSourceDefault {
continue
}
topts = append(topts, telemetry.Option{
Name: opt.Name,
ValueSource: string(opt.ValueSource),
})
}
ti := telemetry.Invocation{
Command: inv.Command.FullName(),
Options: topts,
InvokedAt: time.Now(),
}
byt, err := json.Marshal(ti)
if err != nil {
// Should be impossible
panic(err)
}
// Per https://stackoverflow.com/questions/686217/maximum-on-http-header-values,
// we don't want to send headers that are too long.
s := base64.StdEncoding.EncodeToString(byt)
if len(s) > 4096 {
return
}
transport.Header.Add(codersdk.CLITelemetryHeader, s)
}
// InitClient sets client to a new client.
// It reads from global configuration files if flags are not set.
func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc {
return clibase.Chain(
r.initClientInternal(client, false),
// By default, we should print warnings in addition to initializing the client
r.PrintWarnings(client),
)
}
func (r *RootCmd) InitClientMissingTokenOK(client *codersdk.Client) clibase.MiddlewareFunc {
return r.initClientInternal(client, true)
}
// nolint: revive
func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing bool) clibase.MiddlewareFunc {
if client == nil {
panic("client is nil")
}
if r == nil {
panic("root is nil")
}
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return func(inv *clibase.Invocation) error {
// InitClient authenticates the client with files from disk
// and injects header middlewares for telemetry, authentication,
// and version checks.
func (r *RootCmd) InitClient(client *codersdk.Client) serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
conf := r.createConfig()
var err error
// Read the client URL stored on disk.
if r.clientURL == nil || r.clientURL.String() == "" {
rawURL, err := conf.URL().Read()
// If the configuration files are absent, the user is logged out
if os.IsNotExist(err) {
return errUnauthenticated
return xerrors.New(notLoggedInMessage)
}
if err != nil {
return err
@@ -573,25 +520,20 @@ func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing
return err
}
}
// Read the token stored on disk.
if r.token == "" {
r.token, err = conf.Session().Read()
// If the configuration files are absent, the user is logged out
if os.IsNotExist(err) {
if !allowTokenMissing {
return errUnauthenticatedURLSaved
}
} else if err != nil {
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !os.IsNotExist(err) {
return err
}
}
err = r.setClient(inv.Context(), client, r.clientURL)
err = r.configureClient(inv.Context(), client, r.clientURL, inv)
if err != nil {
return err
}
addTelemetryHeader(client, inv)
client.SetSessionToken(r.token)
if r.debugHTTP {
@@ -604,48 +546,8 @@ func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing
}
}
func (r *RootCmd) PrintWarnings(client *codersdk.Client) clibase.MiddlewareFunc {
if client == nil {
panic("client is nil")
}
if r == nil {
panic("root is nil")
}
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return func(inv *clibase.Invocation) error {
// We send these requests in parallel to minimize latency.
var (
versionErr = make(chan error)
warningErr = make(chan error)
)
go func() {
versionErr <- r.checkVersions(inv, client, buildinfo.Version())
close(versionErr)
}()
go func() {
warningErr <- r.checkWarnings(inv, client)
close(warningErr)
}()
if err := <-versionErr; err != nil {
// Just log the error here. We never want to fail a command
// due to a pre-run.
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Warn, "check versions error: %s", err)
_, _ = fmt.Fprintln(inv.Stderr)
}
if err := <-warningErr; err != nil {
// Same as above
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Warn, "check entitlement warnings error: %s", err)
_, _ = fmt.Fprintln(inv.Stderr)
}
return next(inv)
}
}
}
// HeaderTransport creates a new transport that executes `--header-command`
// if it is set to add headers for all outbound requests.
func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*codersdk.HeaderTransport, error) {
transport := &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
@@ -687,22 +589,38 @@ func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*cod
return transport, nil
}
func (r *RootCmd) setClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL) error {
transport, err := r.HeaderTransport(ctx, serverURL)
func (r *RootCmd) configureClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL, inv *serpent.Invocation) error {
transport := http.DefaultTransport
transport = wrapTransportWithTelemetryHeader(transport, inv)
if !r.noVersionCheck {
transport = wrapTransportWithVersionMismatchCheck(transport, inv, buildinfo.Version(), func(ctx context.Context) (codersdk.BuildInfoResponse, error) {
// Create a new client without any wrapped transport
// otherwise it creates an infinite loop!
basicClient := codersdk.New(serverURL)
return basicClient.BuildInfo(ctx)
})
}
if !r.noFeatureWarning {
transport = wrapTransportWithEntitlementsCheck(transport, inv.Stderr)
}
headerTransport, err := r.HeaderTransport(ctx, serverURL)
if err != nil {
return xerrors.Errorf("create header transport: %w", err)
}
client.URL = serverURL
// The header transport has to come last.
// codersdk checks for the header transport to get headers
// to clone on the DERP client.
headerTransport.Transport = transport
client.HTTPClient = &http.Client{
Transport: transport,
Transport: headerTransport,
}
client.URL = serverURL
return nil
}
func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL) (*codersdk.Client, error) {
func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*codersdk.Client, error) {
var client codersdk.Client
err := r.setClient(ctx, &client, serverURL)
err := r.configureClient(ctx, &client, serverURL, inv)
return &client, err
}
@@ -715,7 +633,7 @@ func (r *RootCmd) createAgentClient() (*agentsdk.Client, error) {
}
// CurrentOrganization returns the currently active organization for the authenticated user.
func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
func CurrentOrganization(r *RootCmd, inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
conf := r.createConfig()
selected := r.organizationSelect
if selected == "" && conf.Organization().Exists() {
@@ -749,7 +667,14 @@ func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.C
return org.IsDefault
})
if index < 0 {
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder set <org>' to select an organization to use")
if len(orgs) == 1 {
// If there is no "isDefault", but only 1 org is present. We can just
// assume the single organization is correct. This is mainly a helper
// for cli hitting an old instance, or a user that belongs to a single
// org that is not the default.
return orgs[0], nil
}
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder org set <org>' to select an organization to use")
}
return orgs[index], nil
@@ -788,7 +713,7 @@ func (r *RootCmd) createConfig() config.Root {
}
// isTTY returns whether the passed reader is a TTY or not.
func isTTY(inv *clibase.Invocation) bool {
func isTTY(inv *serpent.Invocation) bool {
// If the `--force-tty` command is available, and set,
// assume we're in a tty. This is primarily for cases on Windows
// where we may not be able to reliably detect this automatically (ie, tests)
@@ -804,16 +729,16 @@ func isTTY(inv *clibase.Invocation) bool {
}
// isTTYOut returns whether the passed reader is a TTY or not.
func isTTYOut(inv *clibase.Invocation) bool {
func isTTYOut(inv *serpent.Invocation) bool {
return isTTYWriter(inv, inv.Stdout)
}
// isTTYErr returns whether the passed reader is a TTY or not.
func isTTYErr(inv *clibase.Invocation) bool {
func isTTYErr(inv *serpent.Invocation) bool {
return isTTYWriter(inv, inv.Stderr)
}
func isTTYWriter(inv *clibase.Invocation, writer io.Writer) bool {
func isTTYWriter(inv *serpent.Invocation, writer io.Writer) bool {
// If the `--force-tty` command is available, and set,
// assume we're in a tty. This is primarily for cases on Windows
// where we may not be able to reliably detect this automatically (ie, tests)
@@ -859,72 +784,8 @@ func formatExamples(examples ...example) string {
return sb.String()
}
// checkVersions checks to see if there's a version mismatch between the client
// and server and prints a message nudging the user to upgrade if a mismatch
// is detected. forceCheck is a test flag and should always be false in production.
//
//nolint:revive
func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client, clientVersion string) error {
if r.noVersionCheck {
return nil
}
ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second)
defer cancel()
serverInfo, err := client.BuildInfo(ctx)
// Avoid printing errors that are connection-related.
if isConnectionError(err) {
return nil
}
if err != nil {
return xerrors.Errorf("build info: %w", err)
}
if !buildinfo.VersionsMatch(clientVersion, serverInfo.Version) {
upgradeMessage := defaultUpgradeMessage(serverInfo.CanonicalVersion())
if serverInfo.UpgradeMessage != "" {
upgradeMessage = serverInfo.UpgradeMessage
}
fmtWarningText := "version mismatch: client %s, server %s\n%s"
fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
warning := fmt.Sprintf(fmtWarn, clientVersion, serverInfo.Version, upgradeMessage)
_, _ = fmt.Fprint(i.Stderr, warning)
_, _ = fmt.Fprintln(i.Stderr)
}
return nil
}
func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) error {
if r.noFeatureWarning {
return nil
}
ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second)
defer cancel()
user, err := client.User(ctx, codersdk.Me)
if err != nil {
return xerrors.Errorf("get user me: %w", err)
}
entitlements, err := client.Entitlements(ctx)
if err == nil {
// Don't show warning to regular users.
if len(user.Roles) > 0 {
for _, w := range entitlements.Warnings {
_, _ = fmt.Fprintln(i.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, w))
}
}
}
return nil
}
// Verbosef logs a message if verbose mode is enabled.
func (r *RootCmd) Verbosef(inv *clibase.Invocation, fmtStr string, args ...interface{}) {
func (r *RootCmd) Verbosef(inv *serpent.Invocation, fmtStr string, args ...interface{}) {
if r.verbose {
cliui.Infof(inv.Stdout, fmtStr, args...)
}
@@ -1048,28 +909,23 @@ func ExitError(code int, err error) error {
return &exitError{code: code, err: err}
}
// IiConnectionErr is a convenience function for checking if the source of an
// error is due to a 'connection refused', 'no such host', etc.
func isConnectionError(err error) bool {
var (
// E.g. no such host
dnsErr *net.DNSError
// Eg. connection refused
opErr *net.OpError
)
return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr)
// NewPrettyErrorFormatter creates a new PrettyErrorFormatter.
func NewPrettyErrorFormatter(w io.Writer, verbose bool) *PrettyErrorFormatter {
return &PrettyErrorFormatter{
w: w,
verbose: verbose,
}
}
type prettyErrorFormatter struct {
type PrettyErrorFormatter struct {
w io.Writer
// verbose turns on more detailed error logs, such as stack traces.
verbose bool
}
// format formats the error to the console. This error should be human
// readable.
func (p *prettyErrorFormatter) format(err error) {
// Format formats the error to the writer in PrettyErrorFormatter.
// This error should be human readable.
func (p *PrettyErrorFormatter) Format(err error) {
output, _ := cliHumanFormatError("", err, &formatOpts{
Verbose: p.verbose,
})
@@ -1120,7 +976,7 @@ func cliHumanFormatError(from string, err error, opts *formatOpts) (string, bool
return formatCoderSDKError(from, sdkError, opts), true
}
if cmdErr, ok := err.(*clibase.RunCommandError); ok {
if cmdErr, ok := err.(*serpent.RunCommandError); ok {
// no need to pass the "from" context to this since it is always
// top level. We care about what is below this.
return formatRunCommandError(cmdErr, opts), true
@@ -1192,12 +1048,15 @@ func formatMultiError(from string, multi []error, opts *formatOpts) string {
// broad, as it contains all errors that occur when running a command.
// If you know the error is something else, like a codersdk.Error, make a new
// formatter and add it to cliHumanFormatError function.
func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) string {
func formatRunCommandError(err *serpent.RunCommandError, opts *formatOpts) string {
var str strings.Builder
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Encountered an error running %q", err.Cmd.FullName())))
_, _ = str.WriteString(pretty.Sprint(headLineStyle(),
fmt.Sprintf(
`Encountered an error running %q, see "%s --help" for more information`,
err.Cmd.FullName(), err.Cmd.FullName())))
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), "\nerror: "))
msgString, special := cliHumanFormatError("", err.Err, opts)
_, _ = str.WriteString("\n")
if special {
_, _ = str.WriteString(msgString)
} else {
@@ -1225,10 +1084,23 @@ func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) str
_, _ = str.WriteString("\n")
}
// The main error message
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Message))
// Validation errors.
if len(err.Validations) > 0 {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), fmt.Sprintf("%d validation error(s) found", len(err.Validations))))
for _, e := range err.Validations {
_, _ = str.WriteString("\n\t")
_, _ = str.WriteString(pretty.Sprint(cliui.DefaultStyles.Field, e.Field))
_, _ = str.WriteString(pretty.Sprintf(cliui.DefaultStyles.Warn, ": %s", e.Detail))
}
}
if err.Helper != "" {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Helper))
_, _ = str.WriteString(pretty.Sprintf(tailLineStyle(), "Suggestion: %s", err.Helper))
}
// By default we do not show the Detail with the helper.
if opts.Verbose || (err.Helper == "" && err.Detail != "") {
@@ -1282,3 +1154,105 @@ func defaultUpgradeMessage(version string) string {
}
return fmt.Sprintf("download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'", version)
}
// wrapTransportWithEntitlementsCheck adds a middleware to the HTTP transport
// that checks for entitlement warnings and prints them to the user.
func wrapTransportWithEntitlementsCheck(rt http.RoundTripper, w io.Writer) http.RoundTripper {
var once sync.Once
return roundTripper(func(req *http.Request) (*http.Response, error) {
res, err := rt.RoundTrip(req)
if err != nil {
return res, err
}
once.Do(func() {
for _, warning := range res.Header.Values(codersdk.EntitlementsWarningHeader) {
_, _ = fmt.Fprintln(w, pretty.Sprint(cliui.DefaultStyles.Warn, warning))
}
})
return res, err
})
}
// wrapTransportWithVersionMismatchCheck adds a middleware to the HTTP transport
// that checks for version mismatches between the client and server. If a mismatch
// is detected, a warning is printed to the user.
func wrapTransportWithVersionMismatchCheck(rt http.RoundTripper, inv *serpent.Invocation, clientVersion string, getBuildInfo func(ctx context.Context) (codersdk.BuildInfoResponse, error)) http.RoundTripper {
var once sync.Once
return roundTripper(func(req *http.Request) (*http.Response, error) {
res, err := rt.RoundTrip(req)
if err != nil {
return res, err
}
once.Do(func() {
serverVersion := res.Header.Get(codersdk.BuildVersionHeader)
if serverVersion == "" {
return
}
if buildinfo.VersionsMatch(clientVersion, serverVersion) {
return
}
upgradeMessage := defaultUpgradeMessage(semver.Canonical(serverVersion))
serverInfo, err := getBuildInfo(inv.Context())
if err == nil && serverInfo.UpgradeMessage != "" {
upgradeMessage = serverInfo.UpgradeMessage
}
fmtWarningText := "version mismatch: client %s, server %s\n%s"
fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
warning := fmt.Sprintf(fmtWarn, clientVersion, serverVersion, upgradeMessage)
_, _ = fmt.Fprintln(inv.Stderr, warning)
})
return res, err
})
}
// wrapTransportWithTelemetryHeader adds telemetry headers to report command usage
// to an HTTP transport.
func wrapTransportWithTelemetryHeader(transport http.RoundTripper, inv *serpent.Invocation) http.RoundTripper {
var (
value string
once sync.Once
)
return roundTripper(func(req *http.Request) (*http.Response, error) {
once.Do(func() {
// We only want to compute this header once when a request
// first goes out, hence the complexity with locking here.
var topts []telemetry.Option
for _, opt := range inv.Command.FullOptions() {
if opt.ValueSource == serpent.ValueSourceNone || opt.ValueSource == serpent.ValueSourceDefault {
continue
}
topts = append(topts, telemetry.Option{
Name: opt.Name,
ValueSource: string(opt.ValueSource),
})
}
ti := telemetry.Invocation{
Command: inv.Command.FullName(),
Options: topts,
InvokedAt: time.Now(),
}
byt, err := json.Marshal(ti)
if err != nil {
// Should be impossible
panic(err)
}
s := base64.StdEncoding.EncodeToString(byt)
// Don't send the header if it's too long!
if len(s) <= 4096 {
value = s
}
})
if value != "" {
req.Header.Add(codersdk.CLITelemetryHeader, value)
}
return transport.RoundTrip(req)
})
}
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}
+124 -82
View File
@@ -2,10 +2,13 @@ package cli
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"testing"
@@ -13,14 +16,30 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func TestMain(m *testing.M) {
if runtime.GOOS == "windows" {
// Don't run goleak on windows tests, they're super flaky right now.
// See: https://github.com/coder/coder/issues/8954
os.Exit(m.Run())
}
goleak.VerifyTestMain(m,
// The lumberjack library is used by by agent and seems to leave
// goroutines after Close(), fails TestGitSSH tests.
// https://github.com/natefinch/lumberjack/pull/100
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).millRun"),
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).mill.func1"),
// The pq library appears to leave around a goroutine after Close().
goleak.IgnoreTopFunction("github.com/lib/pq.NewDialListener"),
)
}
func Test_formatExamples(t *testing.T) {
t.Parallel()
@@ -80,49 +99,37 @@ func Test_formatExamples(t *testing.T) {
}
}
func TestMain(m *testing.M) {
if runtime.GOOS == "windows" {
// Don't run goleak on windows tests, they're super flaky right now.
// See: https://github.com/coder/coder/issues/8954
os.Exit(m.Run())
}
goleak.VerifyTestMain(m,
// The lumberjack library is used by by agent and seems to leave
// goroutines after Close(), fails TestGitSSH tests.
// https://github.com/natefinch/lumberjack/pull/100
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).millRun"),
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).mill.func1"),
// The pq library appears to leave around a goroutine after Close().
goleak.IgnoreTopFunction("github.com/lib/pq.NewDialListener"),
)
}
func Test_checkVersions(t *testing.T) {
func Test_wrapTransportWithVersionMismatchCheck(t *testing.T) {
t.Parallel()
t.Run("NoOutput", func(t *testing.T) {
t.Parallel()
r := &RootCmd{}
cmd, err := r.Command(nil)
require.NoError(t, err)
var buf bytes.Buffer
inv := cmd.Invoke()
inv.Stderr = &buf
rt := wrapTransportWithVersionMismatchCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
// Provider a version that will not match!
codersdk.BuildVersionHeader: []string{"v2.0.0"},
},
Body: io.NopCloser(nil),
}, nil
}), inv, "v2.0.0", nil)
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
res, err := rt.RoundTrip(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, "", buf.String())
})
t.Run("CustomUpgradeMessage", func(t *testing.T) {
t.Parallel()
expectedUpgradeMessage := "My custom upgrade message"
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
// Provide a version that will not match
Version: "v1.0.0",
AgentAPIVersion: coderd.AgentAPIVersionREST,
// does not matter what the url is
DashboardURL: "https://example.com",
WorkspaceProxy: false,
UpgradeMessage: expectedUpgradeMessage,
})
}))
defer srv.Close()
surl, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(surl)
r := &RootCmd{}
cmd, err := r.Command(nil)
@@ -131,50 +138,85 @@ func Test_checkVersions(t *testing.T) {
var buf bytes.Buffer
inv := cmd.Invoke()
inv.Stderr = &buf
err = r.checkVersions(inv, client, "v2.0.0")
expectedUpgradeMessage := "My custom upgrade message"
rt := wrapTransportWithVersionMismatchCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
// Provider a version that will not match!
codersdk.BuildVersionHeader: []string{"v1.0.0"},
},
Body: io.NopCloser(nil),
}, nil
}), inv, "v2.0.0", func(ctx context.Context) (codersdk.BuildInfoResponse, error) {
return codersdk.BuildInfoResponse{
UpgradeMessage: expectedUpgradeMessage,
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
res, err := rt.RoundTrip(req)
require.NoError(t, err)
defer res.Body.Close()
// Run this twice to ensure the upgrade message is only printed once.
res, err = rt.RoundTrip(req)
require.NoError(t, err)
defer res.Body.Close()
fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", expectedUpgradeMessage)
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
require.Equal(t, expectedOutput, buf.String())
})
t.Run("DefaultUpgradeMessage", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
// Provide a version that will not match
Version: "v1.0.0",
AgentAPIVersion: coderd.AgentAPIVersionREST,
// does not matter what the url is
DashboardURL: "https://example.com",
WorkspaceProxy: false,
UpgradeMessage: "",
})
}))
defer srv.Close()
surl, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(surl)
r := &RootCmd{}
cmd, err := r.Command(nil)
require.NoError(t, err)
var buf bytes.Buffer
inv := cmd.Invoke()
inv.Stderr = &buf
err = r.checkVersions(inv, client, "v2.0.0")
require.NoError(t, err)
fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", defaultUpgradeMessage("v1.0.0"))
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
require.Equal(t, expectedOutput, buf.String())
})
}
func Test_wrapTransportWithTelemetryHeader(t *testing.T) {
t.Parallel()
rt := wrapTransportWithTelemetryHeader(roundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Body: io.NopCloser(nil),
}, nil
}), &serpent.Invocation{
Command: &serpent.Command{
Use: "test",
Options: serpent.OptionSet{{
Name: "bananas",
Description: "hey",
}},
},
})
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
res, err := rt.RoundTrip(req)
require.NoError(t, err)
defer res.Body.Close()
resp := req.Header.Get(codersdk.CLITelemetryHeader)
require.NotEmpty(t, resp)
data, err := base64.StdEncoding.DecodeString(resp)
require.NoError(t, err)
var ti telemetry.Invocation
err = json.Unmarshal(data, &ti)
require.NoError(t, err)
require.Equal(t, ti.Command, "test")
}
func Test_wrapTransportWithEntitlementsCheck(t *testing.T) {
t.Parallel()
lines := []string{"First Warning", "Second Warning"}
var buf bytes.Buffer
rt := wrapTransportWithEntitlementsCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
codersdk.EntitlementsWarningHeader: lines,
},
Body: io.NopCloser(nil),
}, nil
}), &buf)
res, err := rt.RoundTrip(httptest.NewRequest(http.MethodGet, "http://example.com", nil))
require.NoError(t, err)
defer res.Body.Close()
expectedOutput := fmt.Sprintf("%s\n%s\n", pretty.Sprint(cliui.DefaultStyles.Warn, lines[0]),
pretty.Sprint(cliui.DefaultStyles.Warn, lines[1]))
require.Equal(t, expectedOutput, buf.String())
}
+46 -3
View File
@@ -10,12 +10,12 @@ import (
"sync/atomic"
"testing"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -28,7 +28,7 @@ import (
//nolint:tparallel,paralleltest
func TestCommandHelp(t *testing.T) {
// Test with AGPL commands
getCmds := func(t *testing.T) *clibase.Cmd {
getCmds := func(t *testing.T) *serpent.Command {
// Must return a fresh instance of cmds each time.
t.Helper()
@@ -60,6 +60,49 @@ func TestCommandHelp(t *testing.T) {
func TestRoot(t *testing.T) {
t.Parallel()
t.Run("MissingRootCommand", func(t *testing.T) {
t.Parallel()
out := new(bytes.Buffer)
inv, _ := clitest.New(t, "idontexist")
inv.Stdout = out
err := inv.Run()
assert.ErrorContains(t, err,
`unrecognized subcommand "idontexist"`)
require.Empty(t, out.String())
})
t.Run("MissingSubcommand", func(t *testing.T) {
t.Parallel()
out := new(bytes.Buffer)
inv, _ := clitest.New(t, "server", "idontexist")
inv.Stdout = out
err := inv.Run()
// subcommand error only when command has subcommands
assert.ErrorContains(t, err,
`unrecognized subcommand "idontexist"`)
require.Empty(t, out.String())
})
t.Run("BadSubcommandArgs", func(t *testing.T) {
t.Parallel()
out := new(bytes.Buffer)
inv, _ := clitest.New(t, "list", "idontexist")
inv.Stdout = out
err := inv.Run()
assert.ErrorContains(t, err,
`wanted no args but got 1 [idontexist]`)
require.Empty(t, out.String())
})
t.Run("Version", func(t *testing.T) {
t.Parallel()
@@ -210,7 +253,7 @@ func TestHandlersOK(t *testing.T) {
t.Parallel()
var root cli.RootCmd
cmd, err := root.Command(root.Core())
cmd, err := root.Command(root.CoreSubcommands())
require.NoError(t, err)
clitest.HandlersOK(t, cmd)
+25 -25
View File
@@ -8,12 +8,12 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/tz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const (
@@ -53,15 +53,15 @@ When enabling scheduled stop, enter a duration in one of the following formats:
`
)
func (r *RootCmd) schedules() *clibase.Cmd {
scheduleCmd := &clibase.Cmd{
func (r *RootCmd) schedules() *serpent.Command {
scheduleCmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "schedule { show | start | stop | override } <workspace>",
Short: "Schedule automated start and stop times for workspaces",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
Children: []*serpent.Command{
r.scheduleShow(),
r.scheduleStart(),
r.scheduleStop(),
@@ -73,7 +73,7 @@ func (r *RootCmd) schedules() *clibase.Cmd {
}
// scheduleShow() is just a wrapper for list() with some different defaults.
func (r *RootCmd) scheduleShow() *clibase.Cmd {
func (r *RootCmd) scheduleShow() *serpent.Command {
var (
filter cliui.WorkspaceFilter
formatter = cliui.NewOutputFormatter(
@@ -91,15 +91,15 @@ func (r *RootCmd) scheduleShow() *clibase.Cmd {
)
)
client := new(codersdk.Client)
showCmd := &clibase.Cmd{
showCmd := &serpent.Command{
Use: "show <workspace | --search <query> | --all>",
Short: "Show workspace schedules",
Long: scheduleShowDescriptionLong,
Middleware: clibase.Chain(
clibase.RequireRangeArgs(0, 1),
Middleware: serpent.Chain(
serpent.RequireRangeArgs(0, 1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
// To preserve existing behavior, if an argument is passed we will
// only show the schedule for that workspace.
// This will clobber the search query if one is passed.
@@ -136,9 +136,9 @@ func (r *RootCmd) scheduleShow() *clibase.Cmd {
return showCmd
}
func (r *RootCmd) scheduleStart() *clibase.Cmd {
func (r *RootCmd) scheduleStart() *serpent.Command {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Use: "start <workspace-name> { <start-time> [day-of-week] [location] | manual }",
Long: scheduleStartDescriptionLong + "\n" + formatExamples(
example{
@@ -147,11 +147,11 @@ func (r *RootCmd) scheduleStart() *clibase.Cmd {
},
),
Short: "Edit workspace start schedule",
Middleware: clibase.Chain(
clibase.RequireRangeArgs(2, 4),
Middleware: serpent.Chain(
serpent.RequireRangeArgs(2, 4),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return err
@@ -185,9 +185,9 @@ func (r *RootCmd) scheduleStart() *clibase.Cmd {
return cmd
}
func (r *RootCmd) scheduleStop() *clibase.Cmd {
func (r *RootCmd) scheduleStop() *serpent.Command {
client := new(codersdk.Client)
return &clibase.Cmd{
return &serpent.Command{
Use: "stop <workspace-name> { <duration> | manual }",
Long: scheduleStopDescriptionLong + "\n" + formatExamples(
example{
@@ -195,11 +195,11 @@ func (r *RootCmd) scheduleStop() *clibase.Cmd {
},
),
Short: "Edit workspace stop schedule",
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
Middleware: serpent.Chain(
serpent.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return err
@@ -229,9 +229,9 @@ func (r *RootCmd) scheduleStop() *clibase.Cmd {
}
}
func (r *RootCmd) scheduleOverride() *clibase.Cmd {
func (r *RootCmd) scheduleOverride() *serpent.Command {
client := new(codersdk.Client)
overrideCmd := &clibase.Cmd{
overrideCmd := &serpent.Command{
Use: "override-stop <workspace-name> <duration from now>",
Short: "Override the stop time of a currently running workspace instance.",
Long: scheduleOverrideDescriptionLong + "\n" + formatExamples(
@@ -239,11 +239,11 @@ func (r *RootCmd) scheduleOverride() *clibase.Cmd {
Command: "coder schedule override-stop my-workspace 90m",
},
),
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
Middleware: serpent.Chain(
serpent.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
overrideDuration, err := parseDuration(inv.Args[1])
if err != nil {
return err
+103 -41
View File
@@ -56,7 +56,6 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
@@ -65,6 +64,7 @@ import (
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/batchstats"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/database/dbmetrics"
"github.com/coder/coder/v2/coderd/database/dbpurge"
@@ -87,6 +87,7 @@ import (
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/workspaceusage"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/drpc"
"github.com/coder/coder/v2/cryptorand"
@@ -99,6 +100,7 @@ import (
"github.com/coder/coder/v2/tailnet"
"github.com/coder/pretty"
"github.com/coder/retry"
"github.com/coder/serpent"
"github.com/coder/wgtunnel/tunnelsdk"
)
@@ -207,7 +209,7 @@ func enablePrometheus(
}
afterCtx(ctx, closeUsersFunc)
closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.PrometheusRegistry, options.Database, 0)
closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.Logger.Named("workspaces_metrics"), options.PrometheusRegistry, options.Database, 0)
if err != nil {
return nil, xerrors.Errorf("register workspaces prometheus metric: %w", err)
}
@@ -229,13 +231,13 @@ func enablePrometheus(
afterCtx(ctx, closeInsightsMetricsCollector)
if vals.Prometheus.CollectAgentStats {
closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0)
closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0, options.DeploymentValues.Prometheus.AggregateAgentStatsBy.Value())
if err != nil {
return nil, xerrors.Errorf("register agent stats prometheus metric: %w", err)
}
afterCtx(ctx, closeAgentStatsFunc)
metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0)
metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0, options.DeploymentValues.Prometheus.AggregateAgentStatsBy.Value())
if err != nil {
return nil, xerrors.Errorf("can't initialize metrics aggregator: %w", err)
}
@@ -258,7 +260,8 @@ func enablePrometheus(
), nil
}
func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd {
//nolint:gocognit // TODO(dannyk): reduce complexity of this function
func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *serpent.Command {
if newAPI == nil {
newAPI = func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) {
api := coderd.New(o)
@@ -270,16 +273,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
vals = new(codersdk.DeploymentValues)
opts = vals.Options()
)
serverCmd := &clibase.Cmd{
serverCmd := &serpent.Command{
Use: "server",
Short: "Start a Coder server",
Options: opts,
Middleware: clibase.Chain(
Middleware: serpent.Chain(
WriteConfigMW(vals),
PrintDeprecatedOptions(),
clibase.RequireNArgs(0),
serpent.RequireNArgs(0),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
// Main command context for managing cancellation of running
// services.
ctx, cancel := context.WithCancel(inv.Context())
@@ -337,7 +340,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// Register signals early on so that graceful shutdown can't
// be interrupted by additional signals. Note that we avoid
// shadowing cancel() (from above) here because notifyStop()
// shadowing cancel() (from above) here because stopCancel()
// restores default behavior for the signals. This protects
// the shutdown sequence from abruptly terminating things
// like: database migrations, provisioner work, workspace
@@ -345,8 +348,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
//
// To get out of a graceful shutdown, the user can send
// SIGQUIT with ctrl+\ or SIGKILL with `kill -9`.
notifyCtx, notifyStop := inv.SignalNotifyContext(ctx, InterruptSignals...)
defer notifyStop()
stopCtx, stopCancel := signalNotifyContext(ctx, inv, StopSignalsNoInterrupt...)
defer stopCancel()
interruptCtx, interruptCancel := signalNotifyContext(ctx, inv, InterruptSignals...)
defer interruptCancel()
cacheDir := vals.CacheDir.String()
err = os.MkdirAll(cacheDir, 0o700)
@@ -430,7 +435,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
defer tunnel.Close()
tunnelDone = tunnel.Wait()
vals.AccessURL = clibase.URL(*tunnel.URL)
vals.AccessURL = serpent.URL(*tunnel.URL)
if vals.WildcardAccessURL.String() == "" {
// Suffixed wildcard access URL.
@@ -664,12 +669,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
options.Database = dbmem.New()
options.Pubsub = pubsub.NewInMemory()
} else {
dbURL, err := escapePostgresURLUserInfo(vals.PostgresURL.String())
if err != nil {
return xerrors.Errorf("escaping postgres URL: %w", err)
}
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, dbURL)
sqlDB, dbURL, err := getPostgresDB(ctx, logger, vals.PostgresURL.String(), codersdk.PostgresAuth(vals.PostgresAuth), sqlDriver)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
@@ -792,6 +792,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return err
}
// This should be output before the logs start streaming.
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
if vals.Telemetry.Enable {
gitAuth := make([]telemetry.GitAuth, 0)
// TODO:
@@ -817,6 +820,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
Prometheus: vals.Prometheus.Enable.Value(),
STUN: len(vals.DERP.Server.STUNAddresses) != 0,
Tunnel: tunnel != nil,
Experiments: vals.Experiments.Value(),
ParseLicenseJWT: func(lic *telemetry.License) error {
// This will be nil when running in AGPL-only mode.
if options.ParseLicenseClaims == nil {
@@ -890,6 +894,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("register agents prometheus metric: %w", err)
}
defer closeAgentsFunc()
var active codersdk.Experiments
for _, exp := range options.DeploymentValues.Experiments.Value() {
active = append(active, codersdk.Experiment(exp))
}
if err = prometheusmetrics.Experiments(options.PrometheusRegistry, active); err != nil {
return xerrors.Errorf("register experiments metric: %w", err)
}
}
client := codersdk.New(localURL)
@@ -955,6 +968,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
purger := dbpurge.New(ctx, logger, options.Database)
defer purger.Close()
// Updates workspace usage
tracker := workspaceusage.New(options.Database,
workspaceusage.WithLogger(logger.Named("workspace_usage_tracker")),
)
options.WorkspaceUsageTracker = tracker
defer tracker.Close()
// Wrap the server in middleware that redirects to the access URL if
// the request is not to a local IP.
var handler http.Handler = coderAPI.RootHandler
@@ -1008,8 +1028,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
}()
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
// Updates the systemd status from activating to activated.
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
if err != nil {
@@ -1028,13 +1046,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
hangDetector.Start()
defer hangDetector.Close()
waitForProvisionerJobs := false
// Currently there is no way to ask the server to shut
// itself down, so any exit signal will result in a non-zero
// exit of the server.
var exitErr error
select {
case <-notifyCtx.Done():
exitErr = notifyCtx.Err()
case <-stopCtx.Done():
exitErr = stopCtx.Err()
waitForProvisionerJobs = true
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Stop caught, waiting for provisioner jobs to complete and gracefully exiting. Use ctrl+\\ to force quit"))
case <-interruptCtx.Done():
exitErr = interruptCtx.Err()
_, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit"))
case <-tunnelDone:
exitErr = xerrors.New("dev tunnel closed unexpectedly")
@@ -1082,7 +1105,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
defer wg.Done()
r.Verbosef(inv, "Shutting down provisioner daemon %d...", id)
err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second)
timeout := 5 * time.Second
if waitForProvisionerJobs {
// It can last for a long time...
timeout = 30 * time.Minute
}
err := shutdownWithTimeout(func(ctx context.Context) error {
// We only want to cancel active jobs if we aren't exiting gracefully.
return provisionerDaemon.Shutdown(ctx, !waitForProvisionerJobs)
}, timeout)
if err != nil {
cliui.Errorf(inv.Stderr, "Failed to shut down provisioner daemon %d: %s\n", id, err)
return
@@ -1132,10 +1164,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
var pgRawURL bool
postgresBuiltinURLCmd := &clibase.Cmd{
postgresBuiltinURLCmd := &serpent.Command{
Use: "postgres-builtin-url",
Short: "Output the connection URL for the built-in PostgreSQL deployment.",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
url, err := embeddedPostgresURL(r.createConfig())
if err != nil {
return err
@@ -1149,10 +1181,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
},
}
postgresBuiltinServeCmd := &clibase.Cmd{
postgresBuiltinServeCmd := &serpent.Command{
Use: "postgres-builtin-serve",
Short: "Run the built-in PostgreSQL deployment.",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
cfg := r.createConfig()
@@ -1183,10 +1215,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
createAdminUserCmd := r.newCreateAdminUserCommand()
rawURLOpt := clibase.Option{
rawURLOpt := serpent.Option{
Flag: "raw-url",
Value: clibase.BoolOf(&pgRawURL),
Value: serpent.BoolOf(&pgRawURL),
Description: "Output the raw connection URL instead of a psql command.",
}
createAdminUserCmd.Options.Add(rawURLOpt)
@@ -1203,9 +1235,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// printDeprecatedOptions loops through all command options, and prints
// a warning for usage of deprecated options.
func PrintDeprecatedOptions() clibase.MiddlewareFunc {
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return func(inv *clibase.Invocation) error {
func PrintDeprecatedOptions() serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
opts := inv.Command.Options
// Print deprecation warnings.
for _, opt := range opts {
@@ -1213,7 +1245,7 @@ func PrintDeprecatedOptions() clibase.MiddlewareFunc {
continue
}
if opt.ValueSource == clibase.ValueSourceNone || opt.ValueSource == clibase.ValueSourceDefault {
if opt.ValueSource == serpent.ValueSourceNone || opt.ValueSource == serpent.ValueSourceDefault {
continue
}
@@ -1239,9 +1271,9 @@ func PrintDeprecatedOptions() clibase.MiddlewareFunc {
// writeConfigMW will prevent the main command from running if the write-config
// flag is set. Instead, it will marshal the command options to YAML and write
// them to stdout.
func WriteConfigMW(cfg *codersdk.DeploymentValues) clibase.MiddlewareFunc {
return func(next clibase.HandlerFunc) clibase.HandlerFunc {
return func(inv *clibase.Invocation) error {
func WriteConfigMW(cfg *codersdk.DeploymentValues) serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
if !cfg.WriteConfig {
return next(inv)
}
@@ -1411,7 +1443,7 @@ func newProvisionerDaemon(
}
// nolint: revive
func PrintLogo(inv *clibase.Invocation, daemonTitle string) {
func PrintLogo(inv *serpent.Invocation, daemonTitle string) {
// Only print the logo in TTYs.
if !isTTYOut(inv) {
return
@@ -2226,7 +2258,7 @@ func ConfigureTraceProvider(
return tracerProvider, sqlDriver, closeTracing
}
func ConfigureHTTPServers(logger slog.Logger, inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (_ *HTTPServers, err error) {
func ConfigureHTTPServers(logger slog.Logger, inv *serpent.Invocation, cfg *codersdk.DeploymentValues) (_ *HTTPServers, err error) {
ctx := inv.Context()
httpServers := &HTTPServers{}
defer func() {
@@ -2359,7 +2391,7 @@ func ConfigureHTTPServers(logger slog.Logger, inv *clibase.Invocation, cfg *code
// Also, for a while we have been accepting the environment variable (but not the
// corresponding flag!) "CODER_TLS_REDIRECT_HTTP", and it appeared in a configuration
// example, so we keep accepting it to not break backward compat.
func redirectHTTPToHTTPSDeprecation(ctx context.Context, logger slog.Logger, inv *clibase.Invocation, cfg *codersdk.DeploymentValues) {
func redirectHTTPToHTTPSDeprecation(ctx context.Context, logger slog.Logger, inv *serpent.Invocation, cfg *codersdk.DeploymentValues) {
truthy := func(s string) bool {
b, err := strconv.ParseBool(s)
if err != nil {
@@ -2398,7 +2430,7 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
sort.Strings(environ)
var providers []codersdk.ExternalAuthConfig
for _, v := range clibase.ParseEnviron(environ, prefix) {
for _, v := range serpent.ParseEnviron(environ, prefix) {
tokens := strings.SplitN(v.Name, "_", 2)
if len(tokens) != 2 {
return nil, xerrors.Errorf("invalid env var: %s", v.Name)
@@ -2512,3 +2544,33 @@ func escapePostgresURLUserInfo(v string) (string, error) {
return v, nil
}
func signalNotifyContext(ctx context.Context, inv *serpent.Invocation, sig ...os.Signal) (context.Context, context.CancelFunc) {
// On Windows, some of our signal functions lack support.
// If we pass in no signals, we should just return the context as-is.
if len(sig) == 0 {
return context.WithCancel(ctx)
}
return inv.SignalNotifyContext(ctx, sig...)
}
func getPostgresDB(ctx context.Context, logger slog.Logger, postgresURL string, auth codersdk.PostgresAuth, sqlDriver string) (*sql.DB, string, error) {
dbURL, err := escapePostgresURLUserInfo(postgresURL)
if err != nil {
return nil, "", xerrors.Errorf("escaping postgres URL: %w", err)
}
if auth == codersdk.PostgresAuthAWSIAMRDS {
sqlDriver, err = awsiamrds.Register(ctx, sqlDriver)
if err != nil {
return nil, "", xerrors.Errorf("register aws rds iam auth: %w", err)
}
}
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, dbURL)
if err != nil {
return nil, "", xerrors.Errorf("connect to postgres: %w", err)
}
return sqlDB, dbURL, nil
}
+34 -16
View File
@@ -11,29 +11,31 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
var (
newUserDBURL string
newUserPgAuth string
newUserSSHKeygenAlgorithm string
newUserUsername string
newUserEmail string
newUserPassword string
)
createAdminUserCommand := &clibase.Cmd{
createAdminUserCommand := &serpent.Command{
Use: "create-admin-user",
Short: "Create a new admin user with the given username, email and password and adds it to every organization.",
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(newUserSSHKeygenAlgorithm)
@@ -47,7 +49,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
logger = logger.Leveled(slog.LevelDebug)
}
ctx, cancel := inv.SignalNotifyContext(ctx, InterruptSignals...)
ctx, cancel := inv.SignalNotifyContext(ctx, StopSignals...)
defer cancel()
if newUserDBURL == "" {
@@ -62,7 +64,15 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
newUserDBURL = url
}
sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL)
sqlDriver := "postgres"
if codersdk.PostgresAuth(newUserPgAuth) == codersdk.PostgresAuthAWSIAMRDS {
sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver)
if err != nil {
return xerrors.Errorf("register aws rds iam auth: %w", err)
}
}
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, newUserDBURL)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
@@ -237,36 +247,44 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
}
createAdminUserCommand.Options.Add(
clibase.Option{
serpent.Option{
Env: "CODER_PG_CONNECTION_URL",
Flag: "postgres-url",
Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).",
Value: clibase.StringOf(&newUserDBURL),
Value: serpent.StringOf(&newUserDBURL),
},
clibase.Option{
serpent.Option{
Name: "Postgres Connection Auth",
Description: "Type of auth to use when connecting to postgres.",
Flag: "postgres-connection-auth",
Env: "CODER_PG_CONNECTION_AUTH",
Default: "password",
Value: serpent.EnumOf(&newUserPgAuth, codersdk.PostgresAuthDrivers...),
},
serpent.Option{
Env: "CODER_SSH_KEYGEN_ALGORITHM",
Flag: "ssh-keygen-algorithm",
Description: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",
Default: "ed25519",
Value: clibase.StringOf(&newUserSSHKeygenAlgorithm),
Value: serpent.StringOf(&newUserSSHKeygenAlgorithm),
},
clibase.Option{
serpent.Option{
Env: "CODER_USERNAME",
Flag: "username",
Description: "The username of the new user. If not specified, you will be prompted via stdin.",
Value: clibase.StringOf(&newUserUsername),
Value: serpent.StringOf(&newUserUsername),
},
clibase.Option{
serpent.Option{
Env: "CODER_EMAIL",
Flag: "email",
Description: "The email of the new user. If not specified, you will be prompted via stdin.",
Value: clibase.StringOf(&newUserEmail),
Value: serpent.StringOf(&newUserEmail),
},
clibase.Option{
serpent.Option{
Env: "CODER_PASSWORD",
Flag: "password",
Description: "The password of the new user. If not specified, you will be prompted via stdin.",
Value: clibase.StringOf(&newUserPassword),
Value: serpent.StringOf(&newUserPassword),
},
)
+5 -5
View File
@@ -13,8 +13,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/postgres"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/pty/ptytest"
@@ -84,7 +84,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
@@ -150,7 +150,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
@@ -184,7 +184,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
@@ -224,7 +224,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
ctx, cancelFunc := context.WithCancel(context.Background())
+9 -9
View File
@@ -15,9 +15,9 @@ import (
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func Test_configureCipherSuites(t *testing.T) {
@@ -182,43 +182,43 @@ func TestRedirectHTTPToHTTPSDeprecation(t *testing.T) {
testcases := []struct {
name string
environ clibase.Environ
environ serpent.Environ
flags []string
expected bool
}{
{
name: "AllUnset",
environ: clibase.Environ{},
environ: serpent.Environ{},
flags: []string{},
expected: false,
},
{
name: "CODER_TLS_REDIRECT_HTTP=true",
environ: clibase.Environ{{Name: "CODER_TLS_REDIRECT_HTTP", Value: "true"}},
environ: serpent.Environ{{Name: "CODER_TLS_REDIRECT_HTTP", Value: "true"}},
flags: []string{},
expected: true,
},
{
name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS=true",
environ: clibase.Environ{{Name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS", Value: "true"}},
environ: serpent.Environ{{Name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS", Value: "true"}},
flags: []string{},
expected: true,
},
{
name: "CODER_TLS_REDIRECT_HTTP=false",
environ: clibase.Environ{{Name: "CODER_TLS_REDIRECT_HTTP", Value: "false"}},
environ: serpent.Environ{{Name: "CODER_TLS_REDIRECT_HTTP", Value: "false"}},
flags: []string{},
expected: false,
},
{
name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS=false",
environ: clibase.Environ{{Name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS", Value: "false"}},
environ: serpent.Environ{{Name: "CODER_TLS_REDIRECT_HTTP_TO_HTTPS", Value: "false"}},
flags: []string{},
expected: false,
},
{
name: "--tls-redirect-http-to-https",
environ: clibase.Environ{},
environ: serpent.Environ{},
flags: []string{"--tls-redirect-http-to-https"},
expected: true,
},
@@ -234,7 +234,7 @@ func TestRedirectHTTPToHTTPSDeprecation(t *testing.T) {
_ = flags.Bool("tls-redirect-http-to-https", true, "")
err := flags.Parse(tc.flags)
require.NoError(t, err)
inv := (&clibase.Invocation{Environ: tc.environ}).WithTestParsedFlags(t, flags)
inv := (&serpent.Invocation{Environ: tc.environ}).WithTestParsedFlags(t, flags)
cfg := &codersdk.DeploymentValues{}
opts := cfg.Options()
err = opts.SetDefaults()
+4 -6
View File
@@ -2,18 +2,16 @@
package cli
import (
"github.com/coder/coder/v2/cli/clibase"
)
import "github.com/coder/serpent"
func (r *RootCmd) Server(_ func()) *clibase.Cmd {
root := &clibase.Cmd{
func (r *RootCmd) Server(_ func()) *serpent.Command {
root := &serpent.Command{
Use: "server",
Short: "Start a Coder server",
// We accept RawArgs so all commands and flags are accepted.
RawArgs: true,
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
SlimUnsupported(inv.Stderr, "server")
return nil
},
+76 -25
View File
@@ -21,6 +21,7 @@ import (
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
@@ -34,6 +35,8 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"gopkg.in/yaml.v3"
"tailscale.com/derp/derphttp"
"tailscale.com/types/key"
"cdr.dev/slog/sloggers/slogtest"
@@ -42,7 +45,6 @@ import (
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/postgres"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
@@ -971,7 +973,6 @@ func TestServer(t *testing.T) {
scanner := bufio.NewScanner(res.Body)
hasActiveUsers := false
hasWorkspaces := false
for scanner.Scan() {
// This metric is manually registered to be tracked in the server. That's
// why we test it's tracked here.
@@ -979,10 +980,6 @@ func TestServer(t *testing.T) {
hasActiveUsers = true
continue
}
if strings.HasPrefix(scanner.Text(), "coderd_api_workspace_latest_build_total") {
hasWorkspaces = true
continue
}
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled")
}
@@ -990,7 +987,6 @@ func TestServer(t *testing.T) {
}
require.NoError(t, scanner.Err())
require.True(t, hasActiveUsers)
require.True(t, hasWorkspaces)
})
t.Run("DBMetricsEnabled", func(t *testing.T) {
@@ -1581,7 +1577,7 @@ func TestServer_Production(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
connectionURL, closeFunc, err := postgres.Open()
connectionURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
defer closeFunc()
@@ -1605,7 +1601,7 @@ func TestServer_Production(t *testing.T) {
}
//nolint:tparallel,paralleltest // This test cannot be run in parallel due to signal handling.
func TestServer_Shutdown(t *testing.T) {
func TestServer_InterruptShutdown(t *testing.T) {
t.Skip("This test issues an interrupt signal which will propagate to the test runner.")
if runtime.GOOS == "windows" {
@@ -1638,6 +1634,46 @@ func TestServer_Shutdown(t *testing.T) {
require.NoError(t, err)
}
func TestServer_GracefulShutdown(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
// Sending interrupt signal isn't supported on Windows!
t.SkipNow()
}
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
root, cfg := clitest.New(t,
"server",
"--in-memory",
"--http-address", ":0",
"--access-url", "http://example.com",
"--provisioner-daemons", "1",
"--cache-dir", t.TempDir(),
)
var stopFunc context.CancelFunc
root = root.WithTestSignalNotifyContext(t, func(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) {
if !reflect.DeepEqual(cli.StopSignalsNoInterrupt, signals) {
return context.WithCancel(ctx)
}
var ctx context.Context
ctx, stopFunc = context.WithCancel(parent)
return ctx, stopFunc
})
serverErr := make(chan error, 1)
pty := ptytest.New(t).Attach(root)
go func() {
serverErr <- root.WithContext(ctx).Run()
}()
_ = waitAccessURL(t, cfg)
// It's fair to assume `stopFunc` isn't nil here, because the server
// has started and access URL is propagated.
stopFunc()
pty.ExpectMatch("waiting for provisioner jobs to complete")
err := <-serverErr
require.NoError(t, err)
}
func BenchmarkServerHelp(b *testing.B) {
// server --help is a good proxy for measuring the
// constant overhead of each command.
@@ -1732,21 +1768,7 @@ func TestServerYAMLConfig(t *testing.T) {
err = enc.Encode(n)
require.NoError(t, err)
wantByt := wantBuf.Bytes()
goldenPath := filepath.Join("testdata", "server-config.yaml.golden")
wantByt = clitest.NormalizeGoldenFile(t, wantByt)
if *clitest.UpdateGoldenFiles {
require.NoError(t, os.WriteFile(goldenPath, wantByt, 0o600))
return
}
got, err := os.ReadFile(goldenPath)
require.NoError(t, err)
got = clitest.NormalizeGoldenFile(t, got)
require.Equal(t, string(wantByt), string(got))
clitest.TestGoldenFile(t, "server-config.yaml", wantBuf.Bytes(), nil)
}
func TestConnectToPostgres(t *testing.T) {
@@ -1760,7 +1782,7 @@ func TestConnectToPostgres(t *testing.T) {
log := slogtest.Make(t, nil)
dbURL, closeFunc, err := postgres.Open()
dbURL, closeFunc, err := dbtestutil.Open()
require.NoError(t, err)
t.Cleanup(closeFunc)
@@ -1790,3 +1812,32 @@ func TestServer_InvalidDERP(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "A valid DERP map is required for networking to work")
}
func TestServer_DisabledDERP(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancelFunc()
// Try to start a server with the built-in DERP server disabled and an
// external DERP map.
inv, cfg := clitest.New(t,
"server",
"--in-memory",
"--http-address", ":0",
"--access-url", "http://example.com",
"--derp-server-enable=false",
"--derp-config-url", "https://controlplane.tailscale.com/derpmap/default",
)
clitest.Start(t, inv.WithContext(ctx))
accessURL := waitAccessURL(t, cfg)
derpURL, err := accessURL.Parse("/derp")
require.NoError(t, err)
c, err := derphttp.NewClient(key.NewNode(), derpURL.String(), func(format string, args ...any) {})
require.NoError(t, err)
// DERP should fail to connect
err = c.Connect(ctx)
require.Error(t, err)
}
+6 -6
View File
@@ -3,21 +3,21 @@ package cli
import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) show() *clibase.Cmd {
func (r *RootCmd) show() *serpent.Command {
client := new(codersdk.Client)
return &clibase.Cmd{
return &serpent.Command{
Use: "show <workspace>",
Short: "Display details of a workspace's resources and agents",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
buildInfo, err := client.BuildInfo(inv.Context())
if err != nil {
return xerrors.Errorf("get server version: %w", err)
+16 -1
View File
@@ -7,8 +7,23 @@ import (
"syscall"
)
var InterruptSignals = []os.Signal{
// StopSignals is the list of signals that are used for handling
// shutdown behavior.
var StopSignals = []os.Signal{
os.Interrupt,
syscall.SIGTERM,
syscall.SIGHUP,
}
// StopSignals is the list of signals that are used for handling
// graceful shutdown behavior.
var StopSignalsNoInterrupt = []os.Signal{
syscall.SIGTERM,
syscall.SIGHUP,
}
// InterruptSignals is the list of signals that are used for handling
// immediate shutdown behavior.
var InterruptSignals = []os.Signal{
os.Interrupt,
}
+9 -1
View File
@@ -6,4 +6,12 @@ import (
"os"
)
var InterruptSignals = []os.Signal{os.Interrupt}
var StopSignals = []os.Signal{
os.Interrupt,
}
var StopSignalsNoInterrupt = []os.Signal{}
var InterruptSignals = []os.Signal{
os.Interrupt,
}
+16 -14
View File
@@ -13,12 +13,13 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
)
func (r *RootCmd) speedtest() *clibase.Cmd {
func (r *RootCmd) speedtest() *serpent.Command {
var (
direct bool
duration time.Duration
@@ -26,15 +27,15 @@ func (r *RootCmd) speedtest() *clibase.Cmd {
pcapFile string
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "speedtest <workspace>",
Short: "Run upload and download tests from your machine to a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
@@ -59,9 +60,10 @@ func (r *RootCmd) speedtest() *clibase.Cmd {
if r.disableDirect {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
}
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{
Logger: logger,
})
conn, err := workspacesdk.New(client).
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
Logger: logger,
})
if err != nil {
return err
}
@@ -142,32 +144,32 @@ func (r *RootCmd) speedtest() *clibase.Cmd {
return err
},
}
cmd.Options = clibase.OptionSet{
cmd.Options = serpent.OptionSet{
{
Description: "Specifies whether to wait for a direct connection before testing speed.",
Flag: "direct",
FlagShorthand: "d",
Value: clibase.BoolOf(&direct),
Value: serpent.BoolOf(&direct),
},
{
Description: "Specifies whether to run in reverse mode where the client receives and the server sends.",
Flag: "direction",
Default: "down",
Value: clibase.EnumOf(&direction, "up", "down"),
Value: serpent.EnumOf(&direction, "up", "down"),
},
{
Description: "Specifies the duration to monitor traffic.",
Flag: "time",
FlagShorthand: "t",
Default: tsspeedtest.DefaultDuration.String(),
Value: clibase.DurationOf(&duration),
Value: serpent.DurationOf(&duration),
},
{
Description: "Specifies a file to write a network capture to.",
Flag: "pcap-file",
Default: "",
Value: clibase.StringOf(&pcapFile),
Value: serpent.StringOf(&pcapFile),
},
}
return cmd

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