Compare commits

...

446 Commits

Author SHA1 Message Date
Steven Masley 48c0b59447 fix: Log out of legacy cookie (#4202) 2022-09-26 14:20:38 -04:00
Kyle Carberry 39cf329404 fix: Replace access URL for built-in DERP servers (#4197)
Fixes #4195.
2022-09-26 12:56:04 -05:00
Ammar Bandukwala ee4b934601 Add Users Last Seen At (#4192) 2022-09-26 15:31:03 +00:00
Kyle Carberry b8ec5c786d fix: Ensure tailnet coordinations are sent orderly (#4198) 2022-09-26 10:16:04 -05:00
Bruno Quaresma c37ecdb9ff feat: Add port forward button (#4167) 2022-09-26 14:56:17 +00:00
Kyle Carberry 413bfb8d58 fix: Retry reporting agent version (#4190)
It's possible that an agent starts before a build is reported
as complete. This ensures the version is successfully sent
before the startup completes.

Fixes #4151.
2022-09-25 11:11:36 -05:00
Kyle Carberry 112eaf80d1 fix: Add logging to Terraform install (#4191)
Fixes #4129.
2022-09-24 14:55:17 -05:00
Kyle Carberry 4054a9c7cb Fix permissions for welcome message 2022-09-24 02:27:23 +00:00
Ryan Merolle 6571e52f17 Add coder binary to Dockerfile $PATH (#4189) 2022-09-24 02:25:15 +00:00
Kyle Carberry 28428d1294 feat: Add custom version names (#4186)
Fixes #4137.
2022-09-23 20:17:36 -05:00
Kyle Carberry 3c215a83b6 feat: Allow admins to create workspaces (#4183)
Fixes #3263.

This is now possible via the API, but still isn't possible via the UI.
2022-09-23 20:17:10 -05:00
Kyle Carberry 266a3b24e7 fix: Replace getFormHelpers (#4181)
Fixes #3209.
2022-09-23 16:37:44 -05:00
Kyle Carberry f9075cab0e fix: Hide agent status when a workspace is stopped (#4185)
Fixes #4033.
2022-09-23 21:36:33 +00:00
Kyle Carberry b64f624d17 fix: Remove unused scopes from parameter computation (#4171) 2022-09-23 16:09:45 -05:00
Kyle Carberry ea115c981d fix: Make entire row clickable in responsive navbar (#4182)
Fixes #3235.
2022-09-23 20:38:24 +00:00
Kyle Carberry 1c85799be5 fix: Update Terraform to v1.3.0 (#4180)
Contributes to #3202.
2022-09-23 15:31:26 -05:00
Colin Adler 15b9a59786 chore: only trace rbac.Filter (#4177) 2022-09-23 15:21:56 -05:00
Colin Adler 95aea104c7 chore: ignore traces from (*API).workspaceAgentCoordinate after accept (#4178) 2022-09-23 15:21:44 -05:00
Garrett Delfosse 4c8be34d81 feat: add health check monitoring to workspace apps (#4114) 2022-09-23 15:51:04 -04:00
Kyle Carberry f160830226 fix: Update default cache directory (#4175)
Fixes #2534.
2022-09-23 14:26:29 -05:00
Bruno Quaresma 38e2a28ada chore: Pin site deps (#4173) 2022-09-23 16:09:35 -03:00
Bruno Quaresma 189c562826 chore: Use Vite instead of Webpack for development (#4156) 2022-09-23 15:22:48 -03:00
Joe Previte ee00a1d886 chore(site): fix material ui warning (#4161)
* chore(deps): upgrade @material-ui/core to 4.12.4

This is the latest version which includes a fix for the warning we were
seeing while running our tests about `css` function being deprecated.

* refactor: use alpha() instead of fade

`fade()` was deprecated in favor of `alpha()` in a previous version of
`@material-ui/core/styles`.

* refactor: rows -> minRows

This was deprecated in a previous version of `@material-ui/core`.

* refactor: overlap circle -> circular

overlap="circle" was deprecated in favor of overlap="circular".

* refactor: createMuiTheme -> createTheme

This was deprecated and changed to `createTheme`.

* fixup!: chore(deps): upgrade @material-ui/core to 4.12.4

* fixup!: refactor: createMuiTheme -> createTheme

* fix: add SvgIconProps on icons

I couldn't find any release notes or breaking changes related to this
but it seems `props` can no longer be inferred on `SvgIcon`s so I had to
manually add the type.

* Revert "refactor: rows -> minRows"

This reverts commit 94dae6fea8.

* chore(deps): downgrade @material-ui/core to 4.12.0

* fixup!: fix: add SvgIconProps on icons

* fix: pass {} to useStyles

Looks like we may need to pass an empty object if some components in a
file use `props` in styles and some don't.

* fix: update types in Pill.tsx

We need to use generics so that `makeStyles` correctly infers the types
for the `Pill.tsx` styles.

I also updated the types to use `PillProps` directly to make sure they
stay in sync.
2022-09-23 18:14:02 +00:00
Kyle Carberry 99013b3aed chore: Close dials in tailnet conn on close (#4174)
Fixes a race seen in: https://github.com/coder/coder/actions/runs/3114263658/jobs/5049905647
2022-09-23 12:10:47 -05:00
Kira Pilot 8cd5aeaf25 cleanup workspace machine (#4160)
* removed dead build states

* removed dead code

* removed guards

* not calling events from actions

* simplified timeline

* simplify refresh template
2022-09-23 13:06:48 -04:00
Bruno Quaresma 1214022c5a Improve DAU chart view (#4172) 2022-09-23 13:58:00 -03:00
Kyle Carberry 8738755ffc chore: Compile rego once to save CPU cycles in testing (#4169)
Compiling rego isn't very fast, so this should speed up tests in CI!
2022-09-23 16:26:04 +00:00
Mathias Fredriksson 1e1967e0db fix: Avoid using hijacked http.ResponseWriter in workspaceAgentReportStats (#4165) 2022-09-23 19:08:56 +03:00
Bruno Quaresma 7898581e50 feat: Show a full screen loader while is loading a lazy loading page (#4168) 2022-09-23 10:32:28 -05:00
Mathias Fredriksson 6b365f46f5 fix: Ensure coordinator is closed and freed in agent (#4164)
* fix: Close coordinator on context cancellation

* fix: Refactor runCoordinator so that previous is closed/freed
2022-09-23 18:08:13 +03:00
Steven Masley 2e30d0512e chore: Move scope into the same auth call (#4162)
Scopes now are enforced in the same Authorize call as the roles. 
Vs 2 `Authorize()` calls
2022-09-23 11:07:30 -04:00
Kyle Carberry 4183c5e1d0 chore: Clean up unused and outdated dependencies from go.mod (#4163) 2022-09-23 00:58:19 +00:00
Dean Sheather 6deef06ad2 feat: secure and cross-domain subdomain-based proxying (#4136)
Co-authored-by: Kyle Carberry <kyle@carberry.com>
2022-09-22 22:30:32 +00:00
Kyle Carberry 80b45f1aa1 fix: Buffer tailnet nodes from connection initialization (#4159)
* fix: Don't use StatusAbnormalClosure

This is reserved for WASM use, and might be the cause of some weird leaks.

* Add close to provisioner logs
2022-09-22 20:22:49 +00:00
Kyle Carberry a7ee8b31e0 fix: Don't use StatusAbnormalClosure (#4155) 2022-09-22 18:26:05 +00:00
Ben Potter 9e099b543f chore: revert open in coder docs for now (#4154)
* chore: revert open in coder docs for now

* remove in dogfood
2022-09-22 18:02:11 +00:00
Kira Pilot 5fd90471fc Cleanup dead states in workspace machine
* removed dead build states

* removed dead code

* removed guards
2022-09-22 13:32:40 -04:00
Colin Adler 57c84d6446 chore: add option for specifically disabling Coder tracing (#4153) 2022-09-22 11:53:08 -05:00
Kyle Carberry b77d6bdd91 fix: Panic when loading coordination override (#4152)
This was broken because of browser-only. This should fix it!

Signed-off-by: Kyle Carberry <kyle@carberry.com>

Signed-off-by: Kyle Carberry <kyle@carberry.com>
2022-09-22 11:03:49 -05:00
Joe Previte 764600003b feat: add open in coder docs, fix missing templates (#4124)
* docs: add open in coder

This adds new documentation for the "Open in Coder" button that admins
can use to get their developers up and running faster.

* fix: display error if template not found

Previously, we weren't handling a case where we tried to get a template
that returned a 404 from the backend.

Now we handle that case in our state machine and display the error
message from the API on the frontend.

* feat: support template query param in index

This adds support to navigate directly to a template from the index by
using the `?template=<name>` query  param.

* Revert "feat: support template query param in index"

This reverts commit bad7ffb677.

We decided to use the `/template/path` route instead.

* fixup!: docs: add open in coder

* docs: add open in coder to dogfood readme

* Update docs/admin/open-in-coder.md

Co-authored-by: Ben Potter <ben@coder.com>

* Update docs/admin/open-in-coder.md

Co-authored-by: Ben Potter <ben@coder.com>

* Update docs/admin/open-in-coder.md

Co-authored-by: Ben Potter <ben@coder.com>
2022-09-22 08:48:03 -07:00
Kyle Carberry 7ad4276224 feat: Add browser-only connections to Enterprise (#4135)
* feat: Add browser-only connections to Enterprise

Fixes #4131.

* Fix formatting
2022-09-22 15:14:22 +00:00
Mohammed Agboola 656dcc0050 fix: typo (#4149) 2022-09-21 17:38:51 -05:00
Colin Adler 5de6f86959 feat: trace httpapi.{Read,Write} (#4134) 2022-09-21 17:07:00 -05:00
Joe Previte 1bf2dc0cc3 chore: add explicit-length-check eslint rule (#4147)
* chore: add eslint rule explicit-length-check

* fix: add explicit-length-check
2022-09-21 15:42:10 -04:00
Kira Pilot 5698b9d706 feat: use sse for workspace page (#4122)
* added error handling

* workspace machine cleanup

* renaming callback

* general cleanup

* fixed tests

* PR comments
2022-09-21 14:32:00 -04:00
Joe Previte 3db9ea9dd2 fix: disable inspect xstate in develop (#4145) 2022-09-21 11:08:54 -07:00
Ben Potter 93475453d8 chore: sync autostart helpers+values when toggled (#4143) 2022-09-21 12:59:06 -05:00
Ben Potter ceef283bfd chore: minor changes to SSH dialog (#4142) 2022-09-20 23:02:50 -05:00
Ammar Bandukwala d30945c5c5 feat: bump workspace deadline on user activity (#4119)
Resolves #2995
2022-09-20 21:17:24 +00:00
Presley Pizzo 0899548208 feat: have user type name of thing to delete for extra safety (#4080)
* Add info and text field to delete dialog

* Format

* Use DeleteDialog for Users, nix info except for Workspaces

* Format

* Update storybook

* Add and update tests

* Fix the worst of the UsersPage test bugs

* Fix users page tests

* Fix workspace tests

* Format
2022-09-20 17:13:48 -04:00
Colin Adler eb71053e56 chore: update wireguard-go (#4139) 2022-09-20 16:02:49 -05:00
Colin Adler 5e2efb68f1 feat: add SCIM provisioning via Okta (#4132)
Co-authored-by: Ben Potter <ben@coder.com>
2022-09-20 15:16:26 -05:00
Bruno Quaresma 50321ba2aa docs: Add missing audit logs filtering fields (#4133)
* docs: Add missing audit logs filtering fields

* Update docs/admin/audit-logs.md

Co-authored-by: Ben Potter <ben@coder.com>

Co-authored-by: Ben Potter <ben@coder.com>
2022-09-20 17:44:00 +00:00
Bruno Quaresma bc47d7ce69 feat: Add extra fields to the audit filter (#4123) 2022-09-20 13:07:21 -03:00
Kyle Carberry 3618b098cb fix: Return deprecation error when using WebRTC endpoint (#4130)
Fixes #4126.
2022-09-20 09:56:19 -05:00
Mathias Fredriksson 2ca7214259 fix: Produce unknown subcommand errors for bad command names (#4089)
Fixes #1616
2022-09-20 15:31:38 +03:00
Colin Adler 8d7954b015 fix: ignore context canceled error on server (#4128) 2022-09-19 23:56:51 -05:00
Colin Adler 67230babc0 fix: properly shutdown tracers (#4127) 2022-09-19 23:35:18 -05:00
Colin Adler 3993f66997 chore: bump github.com/open-policy-agent/opa from 0.41.0 to 0.44.0 (#4094) 2022-09-20 04:16:03 +00:00
Kyle Carberry db0ba8588e chore: Refactor Enterprise code to layer on top of AGPL (#4034)
* chore: Refactor Enterprise code to layer on top of AGPL

This is an experiment to invert the import order of the Enterprise
code to layer on top of AGPL.

* Fix Garrett's comments

* Add pointer.Handle to atomically obtain references

This uses a context to ensure the same value persists through
multiple executions to `Load()`.

* Remove entitlements API from AGPL coderd

* Remove AGPL Coder entitlements endpoint test

* Fix warnings output

* Add command-line flag to toggle audit logging

* Fix hasLicense being set

* Remove features interface

* Fix audit logging default

* Add bash as a dependency

* Add comment

* Add tests for resync and pubsub, and add back previous exp backoff retry

* Separate authz code again

* Add pointer loading example from comment

* Fix duplicate test, remove pointer.Handle

* Fix expired license

* Add entitlements struct

* Fix context passing
2022-09-19 23:11:01 -05:00
Kyle Carberry 714c366d16 chore: Remove WebRTC networking (#3881)
* chore: Remove WebRTC networking

* Fix race condition

* Fix WebSocket not closing
2022-09-19 19:46:29 -05:00
Bruno Quaresma 1186e643ec feat: Add audit logs filtering to the UI (#4120) 2022-09-19 21:28:23 -03:00
Garrett Delfosse 7fe7ffea6d chore: make fmt (#4121) 2022-09-19 20:22:46 +00:00
Kyle Carberry 72d6731924 fix: Only update workspace LastUsed when the connection payload has changed (#4115)
This was causing every workspace to update last used to time.Now() when
coderd was restarted!
2022-09-19 14:11:18 -05:00
Colin Adler 153e96f574 fix: use consistent tracer name (#4117) 2022-09-19 13:46:26 -05:00
Ammar Bandukwala 794b88fab4 Fix wireguard dependency (#4116)
The old commit disappeared(?).
2022-09-19 18:23:44 +00:00
Dean Sheather 29d804e692 feat: add API key scopes and application_connect scope (#4067) 2022-09-19 17:39:02 +00:00
Bruno Quaresma adad347902 refactor: Refactor audit logs count to support filtering (#4113) 2022-09-19 17:08:25 +00:00
Kyle Carberry 6f82ad09c8 fix: Improve consistency on CLI help (#4112)
This makes the english consistent on flags, and improves
the contrast for the placeholder color on dark themes.
2022-09-19 11:36:18 -05:00
Ben Potter 353fb8724a add docs: "docker in docker" and "systemd in docker" (#4051) 2022-09-19 16:33:31 +00:00
Bruno Quaresma 3e4b67893e fix: Workspace default filter on search bar (#4111) 2022-09-19 13:27:41 -03:00
James Ottaway 9196b3978d Fix kubectl get pods command in k8s install docs (#4053) 2022-09-19 15:55:32 +00:00
Ben Potter 732bc5910c fix: docs: remove reference to fixed issue (#4104) 2022-09-19 10:23:41 -05:00
Ben Potter 64e4ea73c0 fix: docs: use diff view in Docker docs (#4110) 2022-09-19 09:43:54 -05:00
Bruno Quaresma bf8d823ae3 feat: Add audit log filters in the API (#4078) 2022-09-19 10:37:33 -03:00
Geoffrey Huntley f314f30ebc housekeeping(gitignore): update gitignore/eslintignore/prettierignore (#4108) 2022-09-19 17:16:19 +10:00
Denbeigh Stevens 36a599ea9a docs: fix ephemeral resources link (#4101)
[This link](https://coder.com/docs/coder-oss/latest/templates#persistent-and-ephemeral-resources)
directs to the top of the Templates page, we should use
[this link](https://coder.com/docs/coder-oss/latest/templates#persistent-vs-ephemeral-resources) instead.
2022-09-19 02:23:17 +00:00
Kyle Carberry 68ee82437e fix: Remove hiding Tailscale flags (#4103)
Now that Tailscale is defualt, we shouldn't be hiding these!

Fixes #4083.
2022-09-18 20:24:26 -05:00
Geoffrey Huntley d499416024 housekeeping(branding): be consistent (#4075) 2022-09-19 09:57:18 +10:00
Kyle Carberry b3d07ffd87 fix: Test race for TestPostWorkspaceBuild (#4102) 2022-09-18 16:40:24 -05:00
Garrett Delfosse 63fd4945a2 chore: watch workspace endpoint (#4060) 2022-09-16 18:54:23 +00:00
Colin Adler b340634aaa feat: add rbac tracing (#4093) 2022-09-16 18:32:15 +00:00
Joe Previte 1bca269b90 refactor: add type safety in utils.test.ts (#4091)
This makes a few changes to the typings in
site/src/components/GlobalSnackbar/utils.test.ts to more accurately
represent the types we're using. It allows us to remove from type
assertion and one eslin-disable comment..
2022-09-16 10:11:37 -07:00
Colin Adler 77acf0c340 feat: provisionerd tracing, add public trace ingestion (#4070) 2022-09-16 11:43:22 -05:00
Dean Sheather fc841898cd fix: remove path-based port proxying (#4063) 2022-09-16 16:31:08 +00:00
Dean Sheather 6e9c05f859 chore: use zstd -6 in dev (#4092) 2022-09-16 16:03:16 +00:00
Mathias Fredriksson 21664c5c58 fix: Revert change from zstd level 22 to level 6 compression (#4086) 2022-09-16 18:36:11 +03:00
Mathias Fredriksson 9e12850f38 fix: Remove TestWorkspaceBuildResources/ListRunning (#4088) 2022-09-16 16:39:57 +03:00
Colin Adler 86fdafda23 fix: data races in databasefake (#4084) 2022-09-16 00:06:39 +00:00
Kyle Carberry b2bc74e3af chore: Skip TestPortForward due to flakes (#4081)
We'll have to fix this in a future PR... it's unfortunate but
these are *really* flakey.
2022-09-15 21:05:43 +00:00
Colin Adler 87ab6ae8a0 fix: incorrect templates list test assert (#4079) 2022-09-15 15:03:29 -05:00
Joe Previte b8bd3208ca chore: update cSpell and fix isNotificationTextPrefixed (#4076)
* chore: update cSpell words

* chore: add ignorePaths for cSpell

* fix: update isNotificationTextPrefixed

This removes an eslint-disable rule and adds two new tests to ensure
isNotificationTextPrefixed is working as expected.

* fix(e2e): remove filter in workspacesPage
2022-09-15 16:59:22 -03:00
Bruno Quaresma 9e9a9e0cd2 fix: Setup redirect (#4064) 2022-09-15 13:26:24 +00:00
Bruno Quaresma 40c0fc285c refactor: Remove users redirect to active filter (#4056) 2022-09-15 10:05:33 -03:00
Bruno Quaresma b78ab9e028 Fix form tab (#4066) 2022-09-15 09:59:13 -03:00
Andrei Kondratiev 938bd7341b helm: added service annotations (#4062) 2022-09-15 00:01:40 -05:00
Eric Paulsen 45f39ba488 chore: rename AWS ECS template & fix docker template var (#4068) 2022-09-14 20:59:31 -05:00
Kyle Carberry e847e7386a fix: Resolve flake in TestPortForward (#4069) 2022-09-14 20:21:53 -05:00
Kyle Carberry ec453f01e4 fix: Wait for connections before port-forwarding (#4057)
UDP packets were being dropped if a connection was started
before the Tailscale connection has been established.
2022-09-14 21:57:42 +00:00
Joe Previte 22e49c4316 feat(cli): add error message for bad login URL (#4042) 2022-09-14 20:15:47 +00:00
Bruno Quaresma 62d97b18f4 refactor: Typography, action hover and table head colors (#4046)
* Adjust primary text color

* refactor: Typography and table head colors
2022-09-14 15:09:06 -03:00
Geoffrey Huntley a01ab27751 docs(contributing): enable contribution via devcontainer (#3970) 2022-09-14 10:30:12 -07:00
Bruno Quaresma b20ecfdf37 refactor: Minor improvements and fixes for the page headers (#4045) 2022-09-14 11:04:01 -03:00
Presley Pizzo b6712ffbee chore: add wrapper components for conditional rendering (#4047)
* Add conditional wrappers

* Use wrappers in TemplatesPageView
2022-09-14 09:55:00 -04:00
Kyle Carberry 4f0417c6ad Revert "feat: Add portforward to the UI (#3812)" (#4048)
This reverts commit 0552c36e29.
2022-09-13 17:18:27 -05:00
Kyle Carberry 0f8c2f592e feat: Use Tailscale networking by default (#4003)
* feat: Use Tailscale networking by default

Removal of WebRTC code will happen in another PR, but it
felt dangerious to default and remove in a single commit.

Ideally, we can release this version and collect final
thoughts and  feedback before a full commitment.

* Remove UNIX forwarding

Tailscale doesn't support this, and adding support
for it shouldn't block our rollout. Customers can
always forward over SSH.

* Update cli/portforward_test.go

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

Co-authored-by: Dean Sheather <dean@deansheather.com>
2022-09-13 15:55:56 -05:00
Bruno Quaresma 478d49c19c docs: Custom resource icon (#4041)
* Fix missed unresolved conflict

* docs: Custom resource icons

* Fix title

* Apply suggestions from code review

Co-authored-by: Ben Potter <ben@coder.com>

Co-authored-by: Ben Potter <ben@coder.com>
2022-09-13 20:45:17 +00:00
Bruno Quaresma 0552c36e29 feat: Add portforward to the UI (#3812)
* feat: Add portforward to the UI

* Update site/src/components/PortForwardButton/PortForwardButton.tsx

Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com>

* Add CODER_ENABLE_WILDCARD_APPS env var

* Fix portforward link

* Remove t file

Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com>
2022-09-13 17:41:40 -03:00
Steven Masley 9b5ee8f267 feat: Implement (but not enforce) CSRF for FE requests (#3786)
Future work is to enforce CSRF

Co-authored-by: Presley Pizzo <presley@coder.com>
2022-09-13 15:26:46 -04:00
Steven Masley 9ab437d6e2 feat: Add serving applications on subdomains and port-based proxying (#3753)
Co-authored-by: Dean Sheather <dean@deansheather.com>
2022-09-14 03:31:33 +10:00
Presley Pizzo 99a7a8dd22 chore: Turn predictable action arguments on (#3964)
* Turn predictable action arguments on

* Remove layout strings
2022-09-13 12:54:04 -04:00
Ben Potter f16dd5acb4 docs: explain SSH key behavior (#3990) 2022-09-13 11:36:39 -05:00
Eric Paulsen d57c181aad Delete template docs (#4029)
* add: delete template docs

* add: RBAC context

* fix: caps

Co-authored-by: Ben Potter <ben@coder.com>

* add: deletion note

Co-authored-by: Ben Potter <ben@coder.com>
2022-09-13 10:51:50 -05:00
Ben Potter 3ded910cca Add support for coder tunnel in docker-compose (#4027) 2022-09-13 14:53:41 +00:00
Bruno Quaresma 214e59452f feat: Show custom resource icons in the UI (#4020) 2022-09-13 11:32:59 -03:00
Presley Pizzo 83c35bb916 feat: display specific errors if templates page fails (#4023)
* Surface templates page errors

* Format

* Separate error messages

* Fix story

* Format

* Format

* Fix imports

* Remove unnecessary check

* Format
2022-09-13 10:26:58 -04:00
Steven Masley 21e8fb243b fix: Allow develop.sh to host docker workspaces (#3802) 2022-09-13 09:21:05 -04:00
Kyle Carberry 57c7fcf27f fix: Ignore deleted users when signing up with OAuth (#4036)
This prevented a deleted user from signing up again when they
were already linked with a previous account.
2022-09-13 07:33:35 -05:00
Christian Feldkirchner 1ee1db9664 Update docker.md (#4004)
Added a more detailed description on how to create the initial user (via the web ui)
2022-09-13 05:17:01 +00:00
Kyle Carberry a4980446c5 fix: Update Tailscale to resolve race condition (#4032)
This is being fixed upstream here: https://github.com/tailscale/tailscale/pull/5611
2022-09-13 03:32:51 +00:00
Geoffrey Huntley 708bdbc134 docs(contributing): add macos homebrew commands (#3968) 2022-09-13 13:13:30 +10:00
Kyle Carberry 850a83097c feat: Allow deleting users (#4028)
* Add deleted column to the users table

* Fix user indexes

* Add frontend

* Add test
2022-09-12 23:24:20 +00:00
Kyle Carberry a2098254cd feat: Support --header for CLI commands to support proxies (#4008)
Fixes #3527.
2022-09-12 16:22:05 -05:00
Bruno Quaresma 846dd999b7 refactor: Remove cli example from the Audit page (#4031) 2022-09-12 17:17:59 -03:00
Ammar Bandukwala 7e54413d3b docs: add networking (#4030) 2022-09-12 19:07:03 +00:00
dependabot[bot] e9efb7e253 chore: bump github.com/go-chi/httprate from 0.6.0 to 0.7.0 (#4018)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 18:51:44 +00:00
dependabot[bot] 34a2d40f27 chore: bump github.com/prometheus/client_golang from 1.12.2 to 1.13.0 (#4025)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.12.2 to 1.13.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.12.2...v1.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 18:08:48 +00:00
Bruno Quaresma 184e7dbce0 docs: Add docs about coder_metadata hide attribute (#3985) 2022-09-12 14:57:53 -03:00
dependabot[bot] 0e59cb21ce chore: bump go.uber.org/atomic from 1.9.0 to 1.10.0 (#3793)
Bumps [go.uber.org/atomic](https://github.com/uber-go/atomic) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/uber-go/atomic/releases)
- [Changelog](https://github.com/uber-go/atomic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/atomic/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: go.uber.org/atomic
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 12:47:59 -05:00
Kyle Carberry 5c0d63d31f fix: Only hold tailnet.*Conn.Close() for a short duration (#4015)
* fix: Only hold `tailnet.*Conn.Close()` for a short duration

The long duration could be cause to a test deadlock.

* Add closed chan to listener struct
2022-09-12 17:46:45 +00:00
dependabot[bot] d4f0a6fecf chore: bump github.com/hashicorp/hcl/v2 from 2.13.0 to 2.14.0 (#4026)
Bumps [github.com/hashicorp/hcl/v2](https://github.com/hashicorp/hcl) from 2.13.0 to 2.14.0.
- [Release notes](https://github.com/hashicorp/hcl/releases)
- [Changelog](https://github.com/hashicorp/hcl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/hcl/compare/v2.13.0...v2.14.0)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/hcl/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 17:44:14 +00:00
dependabot[bot] 4db98b2b9f chore: bump cloud.google.com/go/compute from 1.7.0 to 1.9.0 (#4012)
Bumps [cloud.google.com/go/compute](https://github.com/googleapis/google-cloud-go) from 1.7.0 to 1.9.0.
- [Release notes](https://github.com/googleapis/google-cloud-go/releases)
- [Changelog](https://github.com/googleapis/google-cloud-go/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-cloud-go/compare/video/v1.7.0...pubsub/v1.9.0)

---
updated-dependencies:
- dependency-name: cloud.google.com/go/compute
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 12:34:28 -05:00
dependabot[bot] cab6fe9482 chore: bump github.com/moby/moby (#4021)
Bumps [github.com/moby/moby](https://github.com/moby/moby) from 20.10.17+incompatible to 20.10.18+incompatible.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v20.10.17...v20.10.18)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 12:33:51 -05:00
dependabot[bot] edec39baef chore: bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc (#4016)
Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) from 1.7.0 to 1.9.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.7.0...v1.9.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 12:10:55 -05:00
dependabot[bot] a7a56f9a26 chore: bump github.com/unrolled/secure from 1.12.0 to 1.13.0 (#4017)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.12.0 to 1.13.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.12.0...v1.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 12:10:38 -05:00
Kyle Carberry 0551a6cba2 chore: Automatically approve dependabot PRs (#4014)
Dependabot is annoying but now it makes merging it's PRs a
little bit easier!
2022-09-12 16:56:38 +00:00
dependabot[bot] 42d1b5e4ba chore: bump go.uber.org/goleak from 1.1.12 to 1.2.0 (#4010)
Bumps [go.uber.org/goleak](https://github.com/uber-go/goleak) from 1.1.12 to 1.2.0.
- [Release notes](https://github.com/uber-go/goleak/releases)
- [Changelog](https://github.com/uber-go/goleak/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/goleak/compare/v1.1.12...v1.2.0)

---
updated-dependencies:
- dependency-name: go.uber.org/goleak
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 11:55:46 -05:00
dependabot[bot] 4f78368403 chore: bump github.com/coreos/go-oidc/v3 from 3.2.0 to 3.4.0 (#4013)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.2.0 to 3.4.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.2.0...v3.4.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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 11:55:34 -05:00
dependabot[bot] 31f25002a6 chore: bump github.com/charmbracelet/lipgloss from 0.5.0 to 0.6.0 (#4011)
Bumps [github.com/charmbracelet/lipgloss](https://github.com/charmbracelet/lipgloss) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v0.5.0...v0.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 11:55:04 -05:00
Kyle Carberry 2b8223bdd5 fix: Use command property when launching an application (#3998)
Fixes #3777.
2022-09-12 16:46:13 +00:00
dependabot[bot] 07e2565a4f chore: bump go.opentelemetry.io/otel/trace from 1.8.0 to 1.9.0 (#3794)
Bumps [go.opentelemetry.io/otel/trace](https://github.com/open-telemetry/opentelemetry-go) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.8.0...v1.9.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/trace
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 11:40:58 -05:00
dependabot[bot] 761f1e7c1a chore: bump google.golang.org/api from 0.94.0 to 0.95.0 (#3921)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.94.0 to 0.95.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.94.0...v0.95.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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 11:38:07 -05:00
Mathias Fredriksson 09da3858ce fix: Terminal emulation used by SSH sessions (#3473)
Fixes #3371
2022-09-12 19:27:51 +03:00
Kyle Carberry b4c29f34c3 fix: Always use UTC time when inserting stats (#4009)
Fixes a flake reported by @mafredri
2022-09-12 16:01:42 +00:00
Mathias Fredriksson d0b02e581d feat: Improve experience with local SSH keys (#3835)
* feat: Improve experience with local SSH keys

This change means that users can place SSH keys in the default locations
for OpenSSH, like `~/.ssh/id_rsa` and it will be automatically picked
up (as per a default OpenSSH experience).

Fixes #3126

* fix: Ensure gitssh cleans up temporary file on interrupt

Co-authored-by: Dean Sheather <dean@deansheather.com>
2022-09-12 17:26:04 +03:00
Kyle Carberry 66ad86a755 fix: Update workspace wasn't using the latest build (#4001)
This was an oversight in a prior contribution. It broke the update
button, but fixed the other cases.
2022-09-12 08:22:29 -03:00
Bruno Quaresma 43f368dfc4 docs: Add audit logs docs (#3975)
* docs: Add audit logs docs

* Apply suggestions from code review

Co-authored-by: Joe Previte <jjprevite@gmail.com>

* Add contact link

Co-authored-by: Joe Previte <jjprevite@gmail.com>
2022-09-12 01:04:56 +00:00
Ben Potter e5e1ed2f9c chore: minor clarifications to install docs (#3983) 2022-09-12 10:50:20 +10:00
Joe Previte 067069d2e2 docs: add jsjoeio/coder-templates to community (#3986) 2022-09-12 10:49:05 +10:00
Kyle Carberry 5b5bc1da56 feat: Add local configuration option for DERP mapping (#3996)
This allows entirely airgapped geodistributed deployments of Coder!
2022-09-11 16:45:49 -05:00
Kyle Carberry 6e20f9c729 fix: Recursively ignore hidden folders (#3997)
Fixes #3938.
2022-09-11 15:13:20 -05:00
Kyle Carberry 9e148a5cac fix: Update embedded DERP server default name (#3995)
* fix: Update embedded DERP server default name

This is still configurable, but exposing the name DERP
seemed awkward.

* Update relay name
2022-09-11 13:06:07 -05:00
Kyle Carberry f5bbbdf638 chore: Fix VSCode configuration to hide visual test overlay (#3994)
This made it impossible to code 😅
2022-09-11 10:50:50 -05:00
Denbeigh Stevens 522fde47dc docs: fix incorrect terraform providers docs link (#3991) 2022-09-10 16:20:28 -05:00
Colin Adler 29bac36816 feat: add workspace auditing (#3966) 2022-09-10 11:07:45 -05:00
J Bruni 442df9e132 Fix phrase at templates.md (#3987) 2022-09-10 16:07:51 +10:00
Kyle Carberry 849e389388 Update manifest.json 2022-09-09 15:53:00 -05:00
Presley Pizzo 20d950d1b3 feat: Update template page automatically (#3962)
* Update template page automatically

* Remove misleading test
2022-09-09 16:27:21 -04:00
Spike Curtis ba6a868a80 Licensed features docs (#3934)
* Licensed features docs

Signed-off-by: Spike Curtis <spike@coder.com>

* Licensed features -> Enterprise features

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-09-09 20:10:39 +00:00
Bruno Quaresma ce211fd8f5 fix: Do not update workspace on start (#3984) 2022-09-09 12:56:41 -07:00
Bruno Quaresma 8a94b72c7d feat: Allow hide resources (#3977) 2022-09-09 16:38:00 -03:00
Ammar Bandukwala f6aa025a01 feat: use active users instead of total users in Template views (#3900) 2022-09-09 19:30:31 +00:00
Bruno Quaresma 346583f13e fix: Audit log human parse message and nullable diffs (#3978)
* fix: Audit log human parse message and nullable diffs

* Fix diff values
2022-09-09 13:53:38 -03:00
Colin Adler abb804f2de feat: add template/template version auditing (#3965) 2022-09-09 11:34:23 -05:00
Ben Potter d380c9494d fix: broken docker-compose link (#3976) 2022-09-09 11:04:02 -05:00
Colin Adler 4e26e325a6 feat: add auditing to user routes (#3961) 2022-09-08 21:16:16 -05:00
sharkymark c026464375 chore: add uninstall steps to remove a Coder OSS deployment from docs (#3742)
Co-authored-by: Ben <ben@coder.com>
2022-09-09 00:31:29 +00:00
Ben Potter 3610f09c77 chore: separate install docs (#3859) 2022-09-08 14:41:00 -05:00
Geoffrey Huntley d38e645492 housekeeping(welcome): notify employees when it is someones first PR (#3884) 2022-09-08 14:35:51 -05:00
Eric Paulsen 9c5b879b16 add: ECS example template (#3915)
* add: ECS example template

* fix: empty main.tf

* cleanup

* rm: cluster & compute

* set CPU & memory vars

Co-authored-by: Ben Potter <ben@coder.com>

Co-authored-by: Ben Potter <ben@coder.com>
2022-09-08 15:27:27 +00:00
Kyle Carberry 2c41343ce5 fix: Show audit log in production if allowed (#3960) 2022-09-08 14:58:53 +00:00
Colin Adler 7dc73ed6c6 feat: add description to audit log responses (#3949) 2022-09-08 09:36:34 -05:00
Kyle Carberry 5e04a2f800 chore: Remove DataDog test reporting (#3958)
It was costing a lot of money, and it wasn't being used very much.
2022-09-08 14:29:30 +00:00
Kyle Carberry e1afec6db4 fix: Optionally consume email_verified if it's provided (#3957)
This reduces our OIDC requirement claims to only `email`. If `email_verified`
is provided and is `false`, we will block authentication.

Fixes #3954.
2022-09-08 14:06:00 +00:00
Dean Sheather bb4a681833 fix: don't check buildinfo or entitlements in agent (#3956) 2022-09-08 23:59:28 +10:00
Dean Sheather 6a3876d6df chore: hide template check 404 error from develop.sh (#3942) 2022-09-08 15:22:08 +10:00
Kyle Carberry 8596023e31 chore: Update PR template to mention checking for docs (#3913)
This arose from a conversation Presley and I had about developers
maintaining docs, and that this little reminder could be useful!
2022-09-07 22:20:02 -05:00
Kyle Carberry 7718fa53c9 fix: Use a channel for bufferring tailnet connection updates (#3940) 2022-09-07 22:18:35 -05:00
Kyle Carberry 519d724ca4 fix: Sort resources by name (#3941)
Fixes #3489.
2022-09-08 03:16:26 +00:00
Ben Potter 332056af29 dogfood: remove folder from code-server (#3944) 2022-09-07 17:37:30 -05:00
Kyle Carberry 2b0fcf3ece fix: Show the users workspaces by default on coder ls (#3947)
Fixes #3945.
2022-09-07 17:30:49 -05:00
Kyle Carberry c8d9c44aba fix: Sort workspaces by last used then name (#3943) 2022-09-07 21:16:53 +00:00
Kyle Carberry f510f01768 fix: Require an argument for speedtest (#3946) 2022-09-07 21:10:17 +00:00
Presley Pizzo 2a085d1936 chore: refactor dialogs (#3935)
* Move dialogs

* Repurpose WorkspaceDeleteDialog

* Rename to DeleteDialog

Pausing on the typing part for now, leaving this as a refactor

* Rename handlers
2022-09-07 17:04:42 -04:00
dependabot[bot] 47ee44e5ca chore: bump msw from 0.45.0 to 0.47.0 in /site (#3917)
Bumps [msw](https://github.com/mswjs/msw) from 0.45.0 to 0.47.0.
- [Release notes](https://github.com/mswjs/msw/releases)
- [Changelog](https://github.com/mswjs/msw/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mswjs/msw/compare/v0.45.0...v0.47.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 20:58:30 +00:00
dependabot[bot] 9a07d5de6e chore: bump eslint-plugin-jest from 26.7.0 to 27.0.1 in /site (#3828)
Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 26.7.0 to 27.0.1.
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v26.7.0...v27.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 13:51:12 -07:00
Kyle Carberry 11abb85df5 fix: Rename IncludeProvisionerD to IncludeProvisionerDaemon in test
This was an artifact from merging!
2022-09-07 20:29:26 +00:00
Bruno Quaresma a00fdd699f feat: Add Audit page in the UI (#3782) 2022-09-07 17:26:12 -03:00
Joe Previte 1359850715 feat(cli): validate name length on template create (#3823)
* feat(cli): add template create validation test

This adds a test to validate that `template create` prints an error
message if called with a template name exceeding the 32-char limit.

* fixup

* fixup test

* feat(cli): add name validation to templatecreate

This adds a validation step to ensure the template name is less than 32
characters.

* fixup!: use utf8.RuneCountInString

* fixup!: remove pty from test
2022-09-07 15:01:18 -05:00
Kyle Carberry 720c9dadcf fix: Remove name from workspace builds (#3937)
Fixes #1561.
2022-09-07 19:49:57 +00:00
Colin Adler 762063ed8f fix: add avatar_url to user object in audit log response (#3939) 2022-09-07 19:22:04 +00:00
dependabot[bot] 87379f413f chore: bump @typescript-eslint/parser from 5.31.0 to 5.36.2 in /site (#3912)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 5.31.0 to 5.36.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v5.36.2/packages/parser)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 15:59:18 -03:00
dependabot[bot] c880263926 chore: bump eslint from 8.21.0 to 8.23.0 in /site (#3920)
Bumps [eslint](https://github.com/eslint/eslint) from 8.21.0 to 8.23.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.21.0...v8.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 15:58:58 -03:00
Dean Sheather a79e34c0c7 chore: build releases on a single Linux runner (switch to rcodesign) (#3890)
* chore: build, sign and notarize darwin binaries on linux

* chore: download rcodesign during release

* chore: change nfpm install to be a download instead of compile

* chore: delete apple cert secrets after build

* fix: fix dependencies in archive.sh and build_go.sh

* chore: reduce output from rcodesign
2022-09-07 18:56:46 +00:00
Spike Curtis ac279b3483 Add periods to end of license warning text. (#3933)
* Add periods to end of license warning text.

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix tests

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-09-07 17:27:42 +00:00
Spike Curtis d46b04cb1e Add Enterprise License text (#3932)
Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-09-07 16:55:57 +00:00
Dean Sheather 819622182b chore: parallel makefile attempt 3 (#3926)
* Revert "chore: Revert parallel Makefile builds (#3918)"

This reverts commit b077f71015.

* fix: fix release workflow with parallel makefile

* fix: mark generated files as fresh during releases
2022-09-08 02:40:17 +10:00
Colin Adler 3d6d51fbd0 feat: audit log api (#3898) 2022-09-07 16:38:19 +00:00
Jon Ayers ad24404018 fix: fix creating users with wrong login type (#3929) 2022-09-07 10:37:15 -05:00
Presley Pizzo 69f430257c chore: remove unused sql-formatter (#3903) 2022-09-07 11:06:43 -04:00
dependabot[bot] cd85be52de chore: bump eslint-import-resolver-typescript in /site (#3925)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.3.0 to 3.5.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.3.0...v3.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 11:06:01 -04:00
Kyle Carberry 3db927bc09 fix: Add contents permission for release CI (#3927) 2022-09-07 14:53:31 +00:00
Kyle Carberry 00104096c2 fix: Resolve CI flakes for tailnet agent (#3924) 2022-09-07 09:24:58 -05:00
dependabot[bot] 73ec618aff chore: bump @testing-library/react from 13.3.0 to 13.4.0 in /site (#3905)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 13.3.0 to 13.4.0.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v13.3.0...v13.4.0)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 10:12:42 -04:00
Kyle Carberry f9ef4b148b fix: Add package write permission to releases (#3923) 2022-09-07 13:58:22 +00:00
Kyle Carberry 80352656e9 fix: Improve speedtest by adding direct connection toggle (#3919)
It's weird to test connection speeds over DERP, because most
connections will eventually migrate to direct.
2022-09-07 03:21:08 +00:00
dependabot[bot] 0f0e3d1068 chore: bump uuid from 8.3.2 to 9.0.0 in /site (#3914)
Bumps [uuid](https://github.com/uuidjs/uuid) from 8.3.2 to 9.0.0.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v8.3.2...v9.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-06 21:57:32 -05:00
Kyle Carberry b077f71015 chore: Revert parallel Makefile builds (#3918)
This was breaking the release process. Namely it was running
the `gen` targets due to the dependency tree, which was failing
on macOS and Linux runners. This revert can be reverted once
we fix that up.
2022-09-07 01:56:51 +00:00
Kyle Carberry d2e6f305b1 Lower protoc version requirement for easy CI install 2022-09-06 20:35:52 -05:00
Kyle Carberry 502a7370c8 Pin to a static version of protoc 2022-09-06 20:32:24 -05:00
Kyle Carberry d970d2d3da Install protoc in release build 2022-09-06 20:13:08 -05:00
Kyle Carberry bb17fe5398 Use go run when executing goimports in gen 2022-09-06 19:59:14 -05:00
Kyle Carberry 65d63f9167 Use go run for executing goimports 2022-09-06 19:53:39 -05:00
Bruno Quaresma b1bdf10e38 feat: Add table support and syntax highlights for markdowns (#3910) 2022-09-06 22:20:23 +00:00
Kyle Carberry dca24bd15d fix: Don't clear out peers that haven't connected yet (#3916)
This was causing parallel connections to fail, because they wouldn't
be established yet.
2022-09-06 21:27:59 +00:00
Joe Previte 18af9426c0 chore: add no implicit coercion eslint rule (#3909)
* chore: add no-implicit-coercion ESLint rule

This adds a new ESLint rule to prevent us from using implicit coercion
in the codebase. See https://eslint.org/docs/latest/rules/no-implicit-coercion

* chore: fix implicit coercion errors

* fixup: formatting
2022-09-06 21:27:10 +00:00
dependabot[bot] bb0e79eb88 chore: bump prettier-plugin-organize-imports in /site (#3891)
Bumps [prettier-plugin-organize-imports](https://github.com/simonhaenisch/prettier-plugin-organize-imports) from 3.0.0 to 3.1.1.
- [Release notes](https://github.com/simonhaenisch/prettier-plugin-organize-imports/releases)
- [Commits](https://github.com/simonhaenisch/prettier-plugin-organize-imports/compare/v3.0.0...v3.1.1)

---
updated-dependencies:
- dependency-name: prettier-plugin-organize-imports
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-06 17:21:16 -04:00
dependabot[bot] 5301d36027 chore: bump canvas from 2.9.3 to 2.10.0 in /site (#3904)
Bumps [canvas](https://github.com/Automattic/node-canvas) from 2.9.3 to 2.10.0.
- [Release notes](https://github.com/Automattic/node-canvas/releases)
- [Changelog](https://github.com/Automattic/node-canvas/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Automattic/node-canvas/compare/v2.9.3...v2.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-06 13:47:12 -07:00
Andrei Kondratiev f5ba90b963 Home folder can be empty, so copying default bash settings (#3897) 2022-09-06 15:11:53 -05:00
dependabot[bot] 30ce62b5b4 chore: bump @playwright/test from 1.24.1 to 1.25.1 in /site (#3843)
Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.24.1 to 1.25.1.
- [Release notes](https://github.com/Microsoft/playwright/releases)
- [Commits](https://github.com/Microsoft/playwright/compare/v1.24.1...v1.25.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-06 15:07:48 -04:00
Spike Curtis a7cdec5d39 Feature server implementation (#3899)
* Feature server implementation

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix imports

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-09-06 18:59:10 +00:00
Dean Sheather 1b6f9e54a3 fix: fix ERRPIPE in scripts/lib.sh (#3908) 2022-09-07 04:42:45 +10:00
Kyle Carberry 3264960fb3 Change the primary UI font, darken the background, and show template icons for workspaces (#3863)
* Use darker colors in the dashboard

I think this looks a bit nicer. It's pretty subjective, but right now
we sit in-between a light and a dark mode, but more on the dark side.

This essentially transforms us into a dark mode.

* Add icons to workspaces rows and apge

* Add narrowed navbar to tighten up design

* Swap gray[3] for gray[4]
2022-09-06 18:26:36 +00:00
Bruno Quaresma 3c94ca9cbe fix: Skip empty values so Terraform can use the default value (#3902) 2022-09-06 15:15:19 -03:00
dependabot[bot] 94eb503aac chore: bump chromatic from 6.7.1 to 6.9.0 in /site (#3837)
Bumps [chromatic](https://github.com/chromaui/chromatic-cli) from 6.7.1 to 6.9.0.
- [Release notes](https://github.com/chromaui/chromatic-cli/releases)
- [Changelog](https://github.com/chromaui/chromatic-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chromaui/chromatic-cli/compare/v6.7.1...v6.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-06 14:05:13 -04:00
Dean Sheather 419d701927 chore: parallel builds with Makefile (#3854)
* Revert "revert: Makefile buff-ification (#3700) (#3848)"

This reverts commit e490bdd531.

* fix: fix slim targets in makefile

* fix: don't clobber slim binaries, make sure they're in the correct location
2022-09-06 17:27:06 +00:00
Ammar Bandukwala 4f0105ef7e feat: add orphan support (#3849)
* feat: add resource orphanage

* feat: deny custom state in build for regular users

* Minor protoc improvements
2022-09-06 17:07:00 +00:00
Bruno Quaresma 209e011404 fix: Escape # character on appName (#3895) 2022-09-06 15:16:03 +00:00
Presley Pizzo 1f55135765 Make color usage more consistent (#3842)
* Tweak overrides - should not cause visual change

* Use closest color for avatar

* Change hover color of contained buttons

* Change nav item color (matches avatar now)

* Format

* Use lighter border for contained button hover

This looks more clickable than lightening the background

* Delete unused component

* Make dropdown arrow consistent

Same up as down. Contrast text everywhere except nav, where it matches links and avatar.

* No need to fade right arrows

* Add hover color

* Consistent box shadows

* Format

* Delete unused DialogSearch

* Deleting unused button types to avoid confusion

* Use disabled arrow on disabled action buttons
2022-09-06 10:58:12 -04:00
dependabot[bot] 8e1dfc2763 chore: bump typescript from 4.7.4 to 4.8.2 in /site (#3836)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.7.4 to 4.8.2.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.7.4...v4.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bruno Quaresma <bruno@coder.com>
2022-09-06 14:15:29 +00:00
Geoffrey Huntley 1b56a8cccb docs(readme): use /chat link in the README.md (#3868) 2022-09-06 08:58:27 +00:00
dependabot[bot] e3bbc77c35 chore: bump google.golang.org/api from 0.90.0 to 0.94.0 (#3882)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.90.0 to 0.94.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.90.0...v0.94.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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-05 18:02:17 -05:00
Kyle Carberry 1254e7a902 feat: Add speedtest command for tailnet (#3874) 2022-09-05 17:15:49 -05:00
Ammar Bandukwala 38825b9ab4 dogfood: keep image locally (#3878)
Avoid delete conflicts
2022-09-05 19:23:52 +00:00
Geoffrey Huntley d6812e0be8 housekeeping(codeowners): migrate to teams (#3867) 2022-09-05 13:38:29 -05:00
Kyle Carberry 2fa77a9bbd fix: Run status callbacks async to solve tailnet race (#3866) 2022-09-05 10:43:24 -05:00
Mathias Fredriksson 3ca6f1fcd4 fix: Prevent nil pointer deref in reconnectingPTY (#3871)
Related #3870
2022-09-05 16:45:10 +03:00
Ammar Bandukwala 1a5d3eace4 dogfood: dynamically pull image (#3864)
Previously, the template would never pull new image updates.
2022-09-04 21:06:36 +00:00
Kyle Carberry 00f05e798b Fix avatar_url dump.sql 2022-09-04 16:56:09 +00:00
Kyle Carberry d8f9537880 Fix avatar_url database type 2022-09-04 16:55:25 +00:00
Kyle Carberry 05e2806ff3 feat: Add profile pictures to OAuth users (#3855)
This supports GitHub and OIDC login for profile pictures!
2022-09-04 11:44:27 -05:00
Kyle Carberry 67c4605370 chore: Reduce test times (#3856)
* chore: Reduce test times

* Rename IncludeProvisionerD to IncludeProvisionerDaemon

* Make  TestTemplateDAUs use Tailnet
2022-09-04 11:28:09 -05:00
J Bruni 271d075667 Update Coder contact at ADOPTERS.md (#3861) 2022-09-04 09:15:25 -05:00
Ammar Bandukwala 0a7fad674a dogfood: remove github apt source (#3860) 2022-09-03 20:44:40 -05:00
Ammar Bandukwala 1b3e75c3ab add watchexec to dogfood image (#3858)
* add watchexec to dogfood image

This comes in handy quite frequently.

* Fix dogfood image
2022-09-03 18:38:13 -05:00
Geoffrey Huntley aae57476f1 docs(adopters): add ADOPTERS.md (#3825) 2022-09-03 06:18:04 +00:00
Geoffrey Huntley 0372586382 housekeeping(discord): use /chat instead of the discord.gg link (#3826) 2022-09-03 06:16:57 +00:00
Kyle Carberry a24f26c137 fix: Allow disabling built-in DERP server (#3852) 2022-09-02 23:47:25 +00:00
Kyle Carberry 4f4d470c7c feat: Add wireguard to port-forward (#3851)
This allows replacement of the WebRTC networking!
2022-09-02 18:26:01 -05:00
Ammar Bandukwala a09ffd6c0d feat: show better error on invalid template upload (#3847)
* feat: show better error on invalid template upload

* Fix tests
2022-09-02 22:48:40 +00:00
Kyle Carberry ac50070713 fix: Add omitempty for proper latency type (#3850)
This was causing an error on the frontend, because this value can be nil!
2022-09-02 22:05:27 +00:00
Kyle Carberry 2e1db6cc63 feat: Add latency indicator to the UI (#3846)
With Tailscale, we now get latency of all regions.
2022-09-02 20:09:05 +00:00
Kyle Carberry e490bdd531 revert: Makefile buff-ification (#3700) (#3848)
This caused the following issues:
- Slim binaries weren't being updated.
- The coder.tar.ztd was misplaced.
- There is no coder.sha1 file with proper filenames.

This should be reintroduced in a future change with those fixes.
2022-09-02 14:46:58 -05:00
Bruno Quaresma d350d9033c refactor: Remove extra line from table bottom (#3831) 2022-09-02 19:32:28 +00:00
Colin Adler ff0aa8d742 feat: add unique ids to all HTTP requests (#3845) 2022-09-02 13:04:29 -05:00
Kyle Carberry de219d966d fix: Run Tailnet SSH connections in a goroutine (#3838)
This was causing SSH connections in parallel to fail 🤦!
2022-09-02 11:58:15 -05:00
dependabot[bot] 3be7bb58b4 chore: bump @storybook/addon-essentials from 6.4.22 to 6.5.10 in /site (#3827)
Bumps [@storybook/addon-essentials](https://github.com/storybookjs/storybook/tree/HEAD/addons/essentials) from 6.4.22 to 6.5.10.
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/v6.5.10/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v6.5.10/addons/essentials)

---
updated-dependencies:
- dependency-name: "@storybook/addon-essentials"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-02 16:58:04 +00:00
Bruno Quaresma 6fe63ed358 refactor: Keep focused style when input is hovered (#3832) 2022-09-02 09:53:46 -07:00
Bruno Quaresma 5618640227 refactor: Remove duplicated title (#3829) 2022-09-02 16:49:41 +00:00
Colin Adler 55c13c8ff9 chore: fully implement enterprise audit pkg (#3821) 2022-09-02 16:42:28 +00:00
Dean Sheather fefdff4946 fix: install goimports in deploy build (#3841) 2022-09-03 02:38:33 +10:00
Dean Sheather e6699d25ca fix: fix CI calling script/version.sh instead of scripts (#3839) 2022-09-03 02:16:19 +10:00
Bruno Quaresma 8c70b6c360 refactor: Update table cell colors to match the ones in the Workspace (#3830)
page
2022-09-02 13:04:08 -03:00
Bruno Quaresma 21ae411237 refactor: Fix README spacing (#3833) 2022-09-02 13:03:59 -03:00
Bruno Quaresma b9e5cc97a1 refactor: Make user columns consistent (#3834) 2022-09-02 13:03:36 -03:00
dependabot[bot] f1976a086f chore: bump webpack-bundle-analyzer from 4.5.0 to 4.6.1 in /site (#3818)
Bumps [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) from 4.5.0 to 4.6.1.
- [Release notes](https://github.com/webpack-contrib/webpack-bundle-analyzer/releases)
- [Changelog](https://github.com/webpack-contrib/webpack-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/webpack-bundle-analyzer/compare/v4.5.0...v4.6.1)

---
updated-dependencies:
- dependency-name: webpack-bundle-analyzer
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-02 10:25:26 -05:00
dependabot[bot] e20ff62c9f chore: bump xstate from 4.32.1 to 4.33.5 in /site (#3817)
Bumps [xstate](https://github.com/statelyai/xstate) from 4.32.1 to 4.33.5.
- [Release notes](https://github.com/statelyai/xstate/releases)
- [Commits](https://github.com/statelyai/xstate/compare/xstate@4.32.1...xstate@4.33.5)

---
updated-dependencies:
- dependency-name: xstate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-02 13:11:20 +00:00
dependabot[bot] afd6834ff7 chore: bump @typescript-eslint/eslint-plugin in /site (#3804)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 5.31.0 to 5.36.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v5.36.1/packages/eslint-plugin)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-02 10:08:26 -03:00
Dean Sheather e1a4f3a16b Makefile buff-ification (#3700)
Remove old go_build_matrix and go_build_slim scripts in favor of full makefile-ification.
2022-09-02 12:58:23 +00:00
Dean Sheather 46bf265e9b fix: prevent running helm chart without valid tag (#3770)
Co-authored-by: Eric Paulsen <ericpaulsen@coder.com>
2022-09-02 21:01:30 +10:00
Mathias Fredriksson 4c18034260 fix: Prevent autobuild executor from slowing down API requests (#3726)
With just a few workspaces, the autobuild executor can slow down API
requests every time it runs. This is because we started a long running
transaction and checked all eligible (for autostart) workspaces inside
that transaction. PostgreSQL doesn't know if we're modifying rows and as
such is locking the tables for read operations.

This commit changes the behavior so each workspace is checked in its own
transaction reducing the time the table/rows needs to stay locked.

For now concurrency has been arbitrarily limited to 10 workspaces at a
time, this could be made configurable or adjusted as the need arises.
2022-09-02 13:24:47 +03:00
Ammar Bandukwala 3f73243b37 feat: improve formatting of last used (#3824) 2022-09-01 23:03:02 -05:00
Ammar Bandukwala 2d347657dc site: correct documentation on gitsshkey (#3690)
* site: correct documentation on gitsshkey

Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com>
2022-09-02 02:29:57 +00:00
Joe Previte 3c91b92930 docs: add comment to ResourceAvatar (#3822) 2022-09-01 18:16:20 -07:00
Ammar Bandukwala 04b03792cb feat: add last used to Workspaces page (#3816) 2022-09-02 00:08:51 +00:00
Garrett Delfosse 80e9f24ac7 feat: add loaders to ssh and terminal buttons (#3820) 2022-09-01 19:58:43 -04:00
Kyle Carberry be273a20a7 fix: Update Tailscale to add HTTP(s) latency reporting (#3819)
This was broken in Tailscale, and I'll be sending an upstream PR
to resolve it. See: https://github.com/coder/tailscale/commit/2c5af585574d4e1432f0d5dc9d02c63db3f497b0
2022-09-01 22:02:05 +00:00
dependabot[bot] 081259314b chore: bump cron-parser from 4.5.0 to 4.6.0 in /site (#3809)
Bumps [cron-parser](https://github.com/harrisiirak/cron-parser) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/harrisiirak/cron-parser/releases)
- [Commits](https://github.com/harrisiirak/cron-parser/compare/4.5.0...4.6.0)

---
updated-dependencies:
- dependency-name: cron-parser
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 21:25:53 +00:00
dependabot[bot] ff026d4890 chore: bump eslint-plugin-react from 7.30.1 to 7.31.1 in /site (#3806)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.30.1 to 7.31.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.30.1...v7.31.1)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 14:20:08 -07:00
Kyle Carberry cde036c1ab fix: Update to Go 1.19 for releases (#3814) 2022-09-01 20:10:53 +00:00
Ammar Bandukwala 30f8fd9b95 Daily Active User Metrics (#3735)
* agent: add StatsReporter

* Stabilize protoc
2022-09-01 14:58:23 -05:00
Kyle Carberry e0cb52ceea fix: Use an unnamed region instead of erroring for DERP (#3810) 2022-09-01 18:43:52 +00:00
Presley Pizzo 5f0b13795a feat: make scrollbars match color scheme (#3807) 2022-09-01 14:28:18 -04:00
dependabot[bot] 1efcd33d63 chore: bump jest-runner-eslint from 1.0.0 to 1.1.0 in /site (#3799)
Bumps [jest-runner-eslint](https://github.com/jest-community/jest-runner-eslint) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/jest-community/jest-runner-eslint/releases)
- [Changelog](https://github.com/jest-community/jest-runner-eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/jest-runner-eslint/compare/v1.0.0...v1.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 14:26:45 -04:00
Presley Pizzo 6d95145d3b Feat: delete template button (#3781)
* Add api call

* Extract DropDownButton

* Start adding DropdownButton to Template page

* Move stories to dropdown button

* Format

* Update xservice to delete

* Deletion flow

* Format

* Move ErrorSummary for consistency

* RBAC (unfinished) and style tweak

* Format

* Test rbac

* Format

* Move ErrorSummary under PageHeader in workspace and template

* Format

* Replace hook with onBlur

* Make style arg optional

* Format
2022-09-01 14:24:14 -04:00
Kyle Carberry 6826b976d7 fix: Add latency-check for DERP over HTTP(s) (#3788)
* fix: Add latency-check for DERP over HTTP(s)

This fixes scenarios where latency wasn't being reported if
a connection had UDP entirely blocked.

* Add inactivity ping

* Improve coordinator error reporting consistency
2022-09-01 16:41:47 +00:00
dependabot[bot] f4c8bfdc18 chore: bump webpack-dev-server from 4.9.3 to 4.10.1 in /site (#3801)
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 4.9.3 to 4.10.1.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v4.9.3...v4.10.1)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 16:26:50 +00:00
dependabot[bot] 5b9573d7c1 chore: bump just-debounce-it from 3.0.1 to 3.1.1 in /site (#3800)
Bumps [just-debounce-it](https://github.com/angus-c/just) from 3.0.1 to 3.1.1.
- [Release notes](https://github.com/angus-c/just/releases)
- [Commits](https://github.com/angus-c/just/compare/just-debounce-it@3.0.1...just-pick@3.1.1)

---
updated-dependencies:
- dependency-name: just-debounce-it
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 09:15:42 -07:00
dependabot[bot] b57b8b887d chore: bump jest-websocket-mock from 2.3.0 to 2.4.0 in /site (#3797)
Bumps [jest-websocket-mock](https://github.com/romgain/jest-websocket-mock) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/romgain/jest-websocket-mock/releases)
- [Commits](https://github.com/romgain/jest-websocket-mock/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: jest-websocket-mock
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 09:14:57 -07:00
Mathias Fredriksson f4a78c976f docs: Update direnv docs for Nix and remove .envrc (#3790) 2022-09-01 20:24:08 +10:00
Kyle Carberry 567e750659 fix: Prepend STUN nodes for DERP (#3787)
This makes Tailscale prefer STUN over DERP when possible.
2022-09-01 02:21:21 +00:00
Kyle Carberry 9bd83e5ec7 feat: Add Tailscale networking (#3505)
* fix: Add coder user to docker group on installation

This makes for a simpler setup, and reduces the likelihood
a user runs into a strange issue.

* Add wgnet

* Add ping

* Add listening

* Finish refactor to make this work

* Add interface for swapping

* Fix conncache with interface

* chore: update gvisor

* fix tailscale types

* linting

* more linting

* Add coordinator

* Add coordinator tests

* Fix coordination

* It compiles!

* Move all connection negotiation in-memory

* Migrate coordinator to use net.conn

* Add closed func

* Fix close listener func

* Make reconnecting PTY work

* Fix reconnecting PTY

* Update CI to Go 1.19

* Add CLI flags for DERP mapping

* Fix Tailnet test

* Rename ConnCoordinator to TailnetCoordinator

* Remove print statement from workspace agent test

* Refactor wsconncache to use tailnet

* Remove STUN from unit tests

* Add migrate back to dump

* chore: Upgrade to Go 1.19

This is required as part of #3505.

* Fix reconnecting PTY tests

* fix: update wireguard-go to fix devtunnel

* fix migration numbers

* linting

* Return early for status if endpoints are empty

* Update cli/server.go

Co-authored-by: Colin Adler <colin1adler@gmail.com>

* Update cli/server.go

Co-authored-by: Colin Adler <colin1adler@gmail.com>

* Fix frontend entites

* Fix agent bicopy

* Fix race condition for the last node

* Fix down migration

* Fix connection RBAC

* Fix migration numbers

* Fix forwarding TCP to a local port

* Implement ping for tailnet

* Rename to ForceHTTP

* Add external derpmapping

* Expose DERP region names to the API

* Add global option to enable Tailscale networking for web

* Mark DERP flags hidden while testing

* Update DERP map on reconnect

* Add close func to workspace agents

* Fix race condition in upstream dependency

* Fix feature columns race condition

Co-authored-by: Colin Adler <colin1adler@gmail.com>
2022-08-31 20:09:44 -05:00
Colin Adler 00da01fdf7 chore: rearrange audit logging code into enterprise folder (#3741) 2022-08-31 21:12:54 +00:00
Mickael 9583e16a05 Update community-templates.md (#3785)
added kubernetes dind template
2022-08-31 15:40:41 -05:00
Cian Johnston 5362f4636e feat: show agent version in UI and CLI (#3709)
This commit adds the ability for agents to set their version upon start.
This is then reported in the UI and CLI.
2022-08-31 16:33:50 +01:00
Steven Masley aa9a1c3f56 fix: Prevent suspending owners (#3757) 2022-08-31 15:26:36 +00:00
Joe Previte e6802f0a56 refactor: use WidgetsIcon for null resources (#3754)
* refactor: replace HelpIcon w/WidgetsIcon

Based on user feedback, we believe the `WidgetsIcon` will cause less
confusion.

* fixup

* refactor: clean up types in ResourceAvatar.tsx

Before, we were using `string` for `type` in `ResourceAvatar`. This
meant it wasn't tied to the types generated from the backend.

Now it imports `WorkspaceResource` so that there is a single source of
truth and they always stay in sync.
2022-08-31 07:44:20 -07:00
Muhammad Atif Ali 774d7588dd docs: Update community-templates.md (#3778)
Added docker based deep learning and matlab coder-templates
2022-08-31 12:04:16 +00:00
Michael Eanes 126d71f41d Remove alpha warning from about (#3774)
The doc was outdated; I don't think the software is alpha anymore.
2022-08-31 03:23:56 +00:00
Kyle Carberry 6644e951d8 fix: Scope error to test functions to fix TestFeaturesService race (#3765)
Fixes #3747.
2022-08-30 19:17:57 -05:00
Bruno Quaresma 02c0100d4d fix: Use a select when parameter input has many options (#3762) 2022-08-30 15:56:36 -07:00
Garrett Delfosse 01a06e1213 feat: Add dedicated labels to agent status and OS (#3759) 2022-08-30 19:18:10 +00:00
Kyle Carberry a410ac42f5 fix: Use first user for telemetry email (#3761)
This was causing other users email to be sent, which isn't desired.
2022-08-30 19:00:23 +00:00
Bruno Quaresma f037aad456 fix: Accepts empty string for the icon prop to remove it (#3760) 2022-08-30 18:48:03 +00:00
Mathias Fredriksson 1dc0485027 fix: Use smarter quoting for ProxyCommand in config-ssh (#3755)
* fix: Use smarter quoting for ProxyCommand in config-ssh

This change takes better into account how OpenSSH executes
`ProxyCommand`s and applies quoting accordingly.

This supercedes #3664, which was reverted.

Fixes #2853

* fix: Ensure `~/.ssh` directory exists
2022-08-30 21:08:20 +03:00
Bruno Quaresma 0708e37a38 feat: Sort templates by workspaces count (#3734) 2022-08-30 17:27:33 +00:00
Muhammad Atif Ali 190310464d Update username in connecting to a workspace documenation (using JetBrains Gateway) (#3746)
if someone is not using coder-provided templates, they might not have coder as a user name.
2022-08-30 16:18:04 +00:00
Eric Paulsen 8a60ee0391 add: code-server to template examples (#3739)
* add: code-server to template examples

* add: code-server to gcp templates

* add: code-server to gcp-linux template

* update: READMEs

* update: boot disk version

* update: google provider version
2022-08-30 10:55:40 -05:00
Geoffrey Huntley 20086c1e77 feat(devenv): use direnv to invoke nix-shell (#3745) 2022-08-30 02:33:11 +00:00
Eric Paulsen c4a9be9c41 update: google provider to latest (#3743)
* update: google provider to latest

* rm: code-server
2022-08-29 19:12:26 -05:00
Spike Curtis cc346afce6 Use licenses to populate the Entitlements API (#3715)
* Use licenses for entitlements API

Signed-off-by: Spike Curtis <spike@coder.com>

* Tests for entitlements API

Signed-off-by: Spike Curtis <spike@coder.com>

* Add commentary about FeatureService

Signed-off-by: Spike Curtis <spike@coder.com>

* Lint

Signed-off-by: Spike Curtis <spike@coder.com>

* Quiet down the logs

Signed-off-by: Spike Curtis <spike@coder.com>

* Tell revive it's ok

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-29 16:45:40 -07:00
Joe Previte 05f932b37e refactor(scripts): remove -P from ln calls (#3740) 2022-08-29 15:05:08 -07:00
Jon Ayers 053fe6ff61 feat: add panic recovery middleware (#3687) 2022-08-29 17:00:52 -05:00
Bruno Quaresma 3cf17d34e7 refactor: Redesign auth cli page and add workspaces link (#3737) 2022-08-29 16:57:54 -03:00
Spike Curtis 779c446a6e cli prints license warnings (#3716)
* cli prints license warnings

Signed-off-by: Spike Curtis <spike@coder.com>

* Satisfy the linter

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-29 11:30:06 -07:00
Bruno Quaresma 62f686c003 fix: Templates table columns width (#3731) 2022-08-29 14:49:04 -03:00
Colin Adler 6285d65b6a fix: remove (http.Server).ReadHeaderTimeout (#3730)
* fix: remove `(http.Server).ReadHeaderTimeout`

Fixes https://github.com/coder/coder/issues/3710. It caused some race
condition for websockets where the server sent the first message.

* comment why disabled
2022-08-29 12:07:49 -05:00
Kyle Carberry 611ca55458 fix: Use "data" scheme when creating parameters from the site (#3732)
Fixes #3691.
2022-08-29 16:32:57 +00:00
Steven Masley 34d902ebf1 fix: Fix properly selecting workspace apps by agent (#3684) 2022-08-29 08:56:52 -04:00
Mathias Fredriksson dc9b4155e0 feat: Generate DB unique constraints as enums (#3701)
* feat: Generate DB unique constraints as enums

This fixes a TODO from #3409.
2022-08-29 14:56:51 +03:00
Mathias Fredriksson f4c5020f63 fix: Print postgres-builtin-url to stdout without formatting (#3727)
This allows use-cases like `eval $(coder server postgres-builtin-url)`.
2022-08-29 11:37:18 +00:00
Dean Sheather b9b9c2fb9f fix: mount TLS secret in helm chart (#3717) 2022-08-27 15:03:10 +00:00
Garrett Delfosse ccabec6dd1 fi stop tracing 4xx http status codes as errors (#3707) 2022-08-26 15:18:42 +00:00
Spike Curtis 23f61fce2a CLI: coder licensese delete (#3699)
Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-26 08:15:46 -07:00
Mathias Fredriksson 98a6958f10 Revert "fix: Avoid double escaping of ProxyCommand on Windows (#3664)" (#3704)
This reverts commit 123fe0131e.
2022-08-26 17:52:25 +03:00
Mathias Fredriksson 6a00baf235 fix: Transform branch name to valid Docker tag for dogfood (#3703) 2022-08-26 17:38:40 +03:00
Mathias Fredriksson c8f8c95f6a feat: Add support for renaming workspaces (#3409)
* feat: Implement workspace renaming

* feat: Add hidden rename command (and data loss warning)

* feat: Implement database.IsUniqueViolation
2022-08-26 12:28:38 +03:00
Presley Pizzo 623fc5baac feat: condition Audit log on licensing (#3685)
* Update XService

* Add simple wrapper

* Add selector

* Condition page

* Condition link

* Format and lint

* Integration test

* Add username to api call

* Format

* Format

* Fix link name

* Upgrade xstate/react to fix crashing tests

* Fix tests

* Format

* Abstract strings

* Debug test

* Increase timeout

* Add comments and try shorter timeout

* Use PropsWithChildren

* Undo PropsWithChildren, try lower timeout

* Format, lower timeout
2022-08-25 19:20:31 -04:00
Spike Curtis ca3811499e DELETE license API endpoint (#3697)
* DELETE license API endpoint

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix new lint stuff

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-25 14:04:31 -07:00
Dean Sheather 14a9576b77 Auto import kubernetes template in Helm charts (#3550) 2022-08-26 05:32:35 +10:00
Joe Previte 94e96fa40b chore: enable react/no-array-index-key eslint (#3696)
* chore: enable react/no-array-index-key eslint

* fix: add missing key to ResourcesTable
2022-08-25 11:20:24 -07:00
Dean Sheather 8a446837d4 chore: remove exa -> ls and bat -> cat replacements from dogfood img (#3695) 2022-08-26 04:03:27 +10:00
Garrett Delfosse 7a77e55bd4 fix: match term color (#3694) 2022-08-25 16:34:37 +00:00
Garrett Delfosse b412cc1a4b fix: use correct response writer for tracing middle (#3693) 2022-08-25 11:24:43 -05:00
Mathias Fredriksson 78a24941fe feat: Add codersdk.NullTime, change workspace build deadline (#3552)
Fixes #2015

Co-authored-by: Joe Previte <jjprevite@gmail.com>
2022-08-25 19:10:42 +03:00
Roman Zubov a21a6d2f4a docs: replaced manual up next blocks with doc tag in workspaces.md (#3023)
* docs: replaced manual up next blocks with doc tag in workspaces.md

* replaced up next blocks with <doc page=""> tags

* revert back to markdown

now that we updated how these links work, we can have them as markdown on github and as cards on the docs website.

Co-authored-by: Anton Korzhuk <antonkorzhuk@gmail.com>
2022-08-25 08:26:04 -07:00
Spike Curtis 4de1fc8339 CLI: coder licenses list (#3686)
* Check GET license calls authz

Signed-off-by: Spike Curtis <spike@coder.com>

* CLI: coder licenses list

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-25 08:24:39 -07:00
Garrett Delfosse a05fad4efd fix: stop tracing static file server (#3683) 2022-08-25 09:37:59 -04:00
Steven Masley 6e496077ae feat: Support search query and --me in workspace list (#3667) 2022-08-24 17:43:41 -04:00
Kira Pilot cf0d2c9bbc added react-i18next to FE (#3682)
* added react-i18next

* fixing typo

* snake case to camel case

* typo

* clearer error in catch block
2022-08-24 17:28:02 -04:00
Joe Previte e6b6b7f610 chore: upload playwright videos on failure (#3677) 2022-08-24 13:45:03 -07:00
Steven Masley 0b53b06fc6 chore: Make member role struct match site roles (#3671) 2022-08-24 15:58:57 -04:00
Spike Curtis 076c4a0aa8 Fix authz test for GET licenses (#3681)
Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-24 12:25:37 -07:00
Spike Curtis 9e35793b43 Enterprise rbac testing (#3653)
* WIP refactor Auth tests to allow enterprise

Signed-off-by: Spike Curtis <spike@coder.com>

* enterprise RBAC testing

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix import ordering

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-24 12:05:46 -07:00
Joe Previte 254e91a08f Update stale.yaml (#3674)
- remove close-issue-reason (only valid in 5.1.0)
- add days-before-issue-stale 30
2022-08-24 12:02:12 -07:00
Garrett Delfosse 5d7c4092ac fix: end long lived connection traces (#3679) 2022-08-24 14:57:31 -04:00
Spike Curtis c9bce19d88 GET license endpoint (#3651)
* GET license endpoint

Signed-off-by: Spike Curtis <spike@coder.com>

* SDK GetLicenses -> Licenses

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-24 18:44:22 +00:00
Kira Pilot da54874958 fixed users test (#3676) 2022-08-24 14:10:41 -04:00
Kira Pilot 57c202d112 Template settings fixes/kira pilot (#3668)
* using hours instead of seconds

* checking out

* added ttl tests

* added description validation  and tests

* added some helper text

* fix typing

* Update site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx

Co-authored-by: Cian Johnston <cian@coder.com>

* ran prettier

* added ttl of 0 test

* typo

* PR feedback

Co-authored-by: Cian Johnston <cian@coder.com>
2022-08-24 14:07:56 -04:00
Garrett Delfosse 4e3b212707 make agent 'connecting' visually different from 'connected' (#3675) 2022-08-24 17:54:45 +00:00
Kyle Carberry 4f8270d95b fix: Exclude time column when selecting build log (#3673)
Closes #2962.
2022-08-24 12:04:33 -05:00
Garrett Delfosse 1400d7cd84 fix: correctly link agent name in app urls (#3672) 2022-08-24 16:49:03 +00:00
Eric Paulsen ca3c0490e0 chore: k8s example persistence & coder images (#3619)
* add: persistence & coder images

* add: code-server

* chore: README updates

* chore: README example
2022-08-24 11:23:02 -05:00
Mathias Fredriksson 123fe0131e fix: Avoid double escaping of ProxyCommand on Windows (#3664)
Fixes #2853
2022-08-24 19:12:40 +03:00
Kyle Carberry 09142255e6 fix: Add consistent use of coder templates init (#3665)
Closes #2303.
2022-08-24 11:40:36 -04:00
Kyle Carberry 706bceb7e7 fix: Remove reference to coder rebuild command (#3670)
Closes #2464.
2022-08-24 15:35:46 +00:00
Cian Johnston eba753ba87 fix: template: enforce bounds of template max_ttl (#3662)
This PR makes the following changes:

- enforces lower and upper limits on template `max_ttl_ms`
- adds a migration to enforce 7-day cap on `max_ttl`
- allows setting template `max_ttl` to 0
- updates template edit CLI help to be clearer
2022-08-24 15:45:14 +01:00
Mathias Fredriksson 343d1184b2 fix: Clean up coder config-ssh dry-run behavior (#3660)
This commit also drops old deprecated code.

Fixes #2982
2022-08-24 16:58:46 +03:00
Mathias Fredriksson 7a71180ae6 chore: Enable comments for database dump / models (#3661) 2022-08-24 12:44:30 +00:00
Ammar Bandukwala 253e6cbffa web: fix template permission check (#3652)
Resolves #3582
2022-08-23 23:44:32 +00:00
Spike Curtis 184f0625e1 coder licenses add CLI command (#3632)
* coder licenses add CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix up lint

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix t.parallel call

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review improvements

Signed-off-by: Spike Curtis <spike@coder.com>

* Lint

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-23 13:55:39 -07:00
Cian Johnston 6dacf70898 fix: disable AccountForm when user is not allowed edit users (#3649)
* RED: add unit tests for AccountForm username field
* GREEN: disable username field and button on account form when user edits are not allowed

Co-authored-by: Joe Previte <jjprevite@gmail.com>
2022-08-23 20:19:26 +00:00
Garrett Delfosse b9dd566804 fix scrollbar on ssh key view (#3647) 2022-08-23 15:22:42 -04:00
Mathias Fredriksson e44f7adb7e feat: Set SSH env vars: SSH_CLIENT, SSH_CONNECTION and SSH_TTY (#3622)
Fixes #2339
2022-08-23 21:19:57 +03:00
Garrett Delfosse 9c0cd5287c fix: clarify we download templates on template select (#3296)
Co-authored-by: Joe Previte <jjprevite@gmail.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2022-08-23 17:30:46 +00:00
Mathias Fredriksson 5025fe2fa0 fix: Protect circular buffer during close in reconnectingPTY (#3646) 2022-08-23 16:07:31 +00:00
Presley Pizzo 49de44c76d feat: Add LicenseBanner (#3568)
* Extract reusable Pill component

* Make icon optional

* Get pills in place

* Rough styling

* Extract Expander component

* Fix alignment

* Put it in action - type error

* Hide banner by default

* Use generated type

* Move PaletteIndex type

* Tweak colors

* Format, another color tweak

* Add stories

* Add tests

* Update site/src/components/Pill/Pill.tsx

Co-authored-by: Kira Pilot <kira@coder.com>

* Update site/src/components/Pill/Pill.tsx

Co-authored-by: Kira Pilot <kira@coder.com>

* Comments

* Remove empty story, improve empty test

* Lint

Co-authored-by: Kira Pilot <kira@coder.com>
2022-08-23 11:26:22 -04:00
Mathias Fredriksson f7ccfa2ab9 feat: Set CODER=true in workspaces (#3637)
Fixes #2340
2022-08-23 14:29:01 +03:00
Colin Adler 8343a4f199 chore: cleanup go.mod (#3636) 2022-08-22 22:40:11 -05:00
Jon Ayers a7b49788f5 chore: deduplicate OAuth login code (#3575) 2022-08-22 18:13:46 -05:00
Ammar Bandukwala a07ca946c3 Increase default auto-stop to 12h (#3631)
Resolves #3462.

And, clarify language to resolve #3509.
2022-08-22 17:24:15 -05:00
Ben Potter 8ca3fa9712 fix: use hardcoded "coder" user for AWS and Azure (#3625) 2022-08-22 22:19:30 +00:00
Spike Curtis b101a6f3f4 POST license API endpoint (#3570)
* POST license API

Signed-off-by: Spike Curtis <spike@coder.com>

* Support interface{} types in generated Typescript

Signed-off-by: Spike Curtis <spike@coder.com>

* Disable linting on empty interface any

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review updates

Signed-off-by: Spike Curtis <spike@coder.com>

* Enforce unique licenses

Signed-off-by: Spike Curtis <spike@coder.com>

* Renames from code review

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review renames and comments

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-22 15:02:50 -07:00
dependabot[bot] 85acfdf0dc chore: bump msw from 0.44.2 to 0.45.0 in /site (#3629)
Bumps [msw](https://github.com/mswjs/msw) from 0.44.2 to 0.45.0.
- [Release notes](https://github.com/mswjs/msw/releases)
- [Changelog](https://github.com/mswjs/msw/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mswjs/msw/compare/v0.44.2...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-22 16:56:39 -04:00
Ammar Bandukwala 2ee6acb2ad Upgrade frontend to React 18 (#3353)
Co-authored-by: Kira Pilot <kira.pilot23@gmail.com>
2022-08-22 15:42:06 -05:00
Ammar Bandukwala 6fde537f9c web: use seconds in max TTL input (#3576)
Milliseconds are more difficult to deal with due to
all of the zeros.

Also, describe this feature as "auto-stop" to be
consistent with our Workspace page UI and CLI. "ttl"
is our backend lingo which should eventually be updated.
2022-08-22 20:35:17 +00:00
Ammar Bandukwala 5e36be8cbb docs: remove architecture diagram (#3615)
The diagram was more confusion than helpful.
2022-08-22 10:56:10 -05:00
Kyle Carberry 58d29264aa feat: Add template icon to the workspaces page (#3612)
This removes the last built by column from the page. It seemed
cluttered to have both on the page, and is simple enough to
click on the workspace to see additional info.
2022-08-22 09:42:11 -05:00
Dean Sheather 369a9fb535 fix: add writeable home dir to docker image (#3603) 2022-08-22 19:43:13 +10:00
Eric Paulsen 68e17921f0 fix: tooltip 404 (#3618) 2022-08-21 18:50:36 -05:00
Kyle Carberry b0fe9bcdd1 chore: Upgrade to Go 1.19 (#3617)
This is required as part of #3505.
2022-08-21 22:32:53 +00:00
Ammar Bandukwala d37fb054c8 docs: outdent remote desktop docs (#3614)
Resolves #3590
2022-08-21 01:59:40 +00:00
Bruno Quaresma 54b8e794ce feat: Add emoji picker for template icons (#3601) 2022-08-19 16:42:05 -04:00
Bruno Quaresma a4c90c591d feat: Add icon to the template page (#3604) 2022-08-19 15:37:16 -03:00
Spike Curtis 690e6c6585 Check AGPL code doesn't import enterprise (#3602)
* Check AGPL code doesn't import enterprise

Signed-off-by: Spike Curtis <spike@coder.com>

* use error/log instead of echo/exit

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-19 17:49:08 +00:00
Joe Previte 91bfcca287 fix(ui): decrease WorkspaceActions popover padding (#3555)
There was too much padding on the WorkspaceActions dropdown. This fixes
that.
2022-08-19 09:58:31 -07:00
Bruno Quaresma c14a4b92ed feat: Display and edit template icons in the UI (#3598) 2022-08-19 13:09:07 -03:00
Joe Previte e938e8577f fix: add missing && \ in Dockerfile (#3594)
* fix: add missing && \ in Dockerfile

* fixup: add goboring after PATH goboring
2022-08-19 15:41:17 +00:00
Kyle Carberry 985eea6099 fix: Update icon when metadata is changed (#3587)
This was causing names to become empty! Fixes #3586.
2022-08-19 10:11:54 -05:00
Joe Previte c417115eb1 feat: add cmake, nfpm to dogfood dockerfile (#3558)
* feat: add cmake, nfpm to dogfood dockerfile

* fixup: formatting

* Update dogfood/Dockerfile

Co-authored-by: Cian Johnston <cian@coder.com>

Co-authored-by: Cian Johnston <cian@coder.com>
2022-08-19 15:10:56 +00:00
Mathias Fredriksson 544bf01fbb chore: Update coder/coder provider in example templates (#3581)
Additionally, a convenience script was added to
`examples/update_template_versions.sh` to keep the templates up-to-date.

Fixes #2966
2022-08-19 17:18:11 +03:00
Bruno Quaresma 80f042f01b feat: Add icon to templates (#3561) 2022-08-19 13:17:35 +00:00
Cian Johnston 57f3410009 cli: remove confirm prompt when starting a workspace (#3580) 2022-08-19 11:08:56 +01:00
Mathias Fredriksson 3fdae47b87 fix: Shadow err in TestProvision_Cancel to fix test race (#3579)
Fixes #3574
2022-08-19 11:56:28 +03:00
Eric Paulsen 4ba3573632 fix: quickstart 404 (#3564) 2022-08-18 18:47:12 -05:00
Jon Ayers f6b0835982 fix: avoid processing updates to usernames (#3571)
- With the support of OIDC we began processing updates to a user's
  email and username to stay in sync with the upstream provider. This
  can cause issues in templates that use the user's username as a stable
  identifier, potentially causing the deletion of user's home volumes.
- Fix some faulty error wrapping.
2022-08-18 17:56:17 -05:00
Cian Johnston 04c5f924d7 fix: ui: workspace bumpers now honour template max_ttl (#3532)
- chore: WorkspacePage: invert workspace schedule bumper logic for readibility
- fix: make workspace bumpers honour template max_ttl
- chore: refactor workspace schedule bumper logic to util/schedule.ts and unit test separately
2022-08-18 23:32:23 +01:00
Bruno Quaresma 7599ad4bf6 feat: Add template settings page (#3557) 2022-08-18 16:58:01 -03:00
Joe Previte aabb72783c docs: update CONTRIBUTING requirements (#3541)
* docs: update CONTRIBUTING requirements

* Update docs/CONTRIBUTING.md

* refactor: remove dev from Makefile

* fixup: add linux section
2022-08-18 17:11:58 +00:00
Dean Sheather 55890df6f1 feat: add helm README, install guide, linters (#3268) 2022-08-19 02:41:23 +10:00
Dean Sheather 3610402cd8 Use new table formatter everywhere (#3544) 2022-08-19 02:41:00 +10:00
Kyle Carberry c43297937b feat: Add Kubernetes and resource metadata telemetry (#3548)
Fixes #3524.
2022-08-18 15:57:46 +00:00
Mathias Fredriksson f1423450bd fix: Allow terraform provisions to be gracefully cancelled (#3526)
* fix: Allow terraform provisions to be gracefully cancelled

This change allows terraform commands to be gracefully cancelled on
Unix-like platforms by signaling interrupt on provision cancellation.

One implementation detail to note is that we do not necessarily kill a
running terraform command immediately even if the stream is closed. The
reason for this is to allow for graceful cancellation even in such an
event. Currently the timeout is set to 5 minutes by default.

Related: #2683

The above issue may be partially or fully fixed by this change.

* fix: Remove incorrect minimumTerraformVersion variable

* Allow init to return provision complete response
2022-08-18 17:03:55 +03:00
Mathias Fredriksson 6a0f8ae9cc fix: Add SIGHUP and SIGTERM handling to coder server (#3543)
* fix: Add `SIGHUP` and `SIGTERM` handling to `coder server`

To prevent additional signals from aborting program execution, signal
handling was moved to the beginning of the main function, this ensures
that signals stays registered for the entire shutdown procedure.

Fixes #1529
2022-08-18 16:25:32 +03:00
Jon Ayers 380022fe63 fix: update oauth token on each login (#3542) 2022-08-17 23:06:03 -05:00
Jon Ayers c3eea98db0 fix: use unique ID for linked accounts (#3441)
- move OAuth-related fields off of api_keys into a new user_links table
- restrict users to single form of login
- process updates to user email/usernames for OIDC
- added a login_type column to users
2022-08-17 18:00:53 -05:00
Cian Johnston 53d1fb36db update-alternatives to ensure gofmt is goboring gofmt (#3540) 2022-08-17 20:03:44 +00:00
whitney-coder d6351a6b9f Update README.md (#3539)
Minor grammatical change on line 14
2022-08-17 14:48:41 -05:00
Bruno Quaresma 546157b63e feat: Make template name editable (#3538) 2022-08-17 19:04:00 +00:00
Kira Pilot 4b646cc4fa fix: hiding agent status on stopped workspaces (#3512)
* hiding agent status on a stopped workspaace

resolves #3484

* run prettier and lint

* Update site/src/components/Resources/Resources.tsx

Co-authored-by: Joe Previte <jjprevite@gmail.com>

* running prettier

Co-authored-by: Joe Previte <jjprevite@gmail.com>
2022-08-17 14:37:54 -04:00
Spike Curtis acd0cd66f6 coder features list CLI command (#3533)
* AGPL Entitlements API

Signed-off-by: Spike Curtis <spike@coder.com>

* Generate typesGenerated.ts

Signed-off-by: Spike Curtis <spike@coder.com>

* AllFeatures -> FeatureNames

Signed-off-by: Spike Curtis <spike@coder.com>

* Features CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Validate columns

Signed-off-by: Spike Curtis <spike@coder.com>

* Tests for features list CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Drop empty EntitlementsRequest

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix dump.sql generation

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-17 11:26:16 -07:00
Spike Curtis 5c898d0c83 Fix archive.sh for LICENSE files (#3535)
Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-17 10:27:52 -07:00
Kyle Carberry c3f946737c fix: Strip session_token cookie from app proxy requests (#3528)
Fixes coder/security#1.
2022-08-17 17:09:45 +00:00
Noah Huppert 000e1a5ef2 Fixed env block in Emacs IDE docs (#3534) 2022-08-17 16:30:45 +00:00
Dean Sheather a872330a8d feat: add generic table formatter (#3415) 2022-08-18 02:28:22 +10:00
Spike Curtis b1b2d1b2b2 AGPL Entitlements API (#3523)
* AGPL Entitlements API

Signed-off-by: Spike Curtis <spike@coder.com>

* Generate typesGenerated.ts

Signed-off-by: Spike Curtis <spike@coder.com>

* AllFeatures -> FeatureNames

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-17 09:02:36 -07:00
Spike Curtis 5817c6ac7f Build enterprise coder binary by default (#3517)
* Build enterprise coder binary by default

Signed-off-by: Spike Curtis <spike@coder.com>

* Add --agpl to develop.sh

Signed-off-by: Spike Curtis <spike@coder.com>

* Add --agpl flag to archive.sh

Signed-off-by: Spike Curtis <spike@coder.com>

* shell format

Signed-off-by: Spike Curtis <spike@coder.com>

* Move AGPL back to LICENSE, explain enterprise license is forthcoming

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
2022-08-17 09:02:25 -07:00
Steven Masley 4be61d9250 fix: Role assign ui fixes (#3521)
Co-authored-by: Kira Pilot <kira@coder.com>
2022-08-16 10:39:42 -05:00
Ben Potter 4b6a82f92a chore: rename to "template push" in docs (#3525) 2022-08-16 14:52:31 +00:00
Steven Masley 01dd35f1ba chore: Rename 'admin' to 'owner' (#3498)
Co-authored-by: Colin Adler <colin1adler@gmail.com>
2022-08-15 14:40:19 -05:00
Steven Masley 2306d2c709 chore: Fix misspelled "referrer" in site.go (#3507) 2022-08-15 14:12:34 +00:00
Mathias Fredriksson e749070193 chore: Update readme with note about embedded database (#3488) 2022-08-15 12:32:22 +03:00
Jon Ayers 301727d1fc chore: improve dump error output (#3499)
* chore: improve dump error output

- Properly report the error that occurs during the DB connection retry
  loop.
- Fail fatally if migration is unsuccessful.
2022-08-12 22:15:13 -05:00
Ammar Bandukwala 8cf82112ad docs: document additional roles (#3496)
Co-authored-by: Steven Masley <stevenmasley@coder.com>
2022-08-12 22:42:16 +00:00
Steven Masley 40e68cb80b feat: Add template-admin + user-admin role for managing templates + users (#3490)
Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2022-08-12 17:27:48 -05:00
Bruno Quaresma c41261cf6e fix: Remove unexpected break lines when copy logs (#3492) 2022-08-12 19:18:41 +00:00
Bruno Quaresma 351d55e1f4 chore: Minor table design changes (#3494) 2022-08-12 16:18:03 -03:00
Kyle Carberry 3b951f77fb fix: Unskip SuspendAnotherUser test (#3430)
It wasn't clear why this was skipped, it seems accidental.
2022-08-12 19:12:44 +00:00
Oxylibrium 0a46b1e59d chore: remove swr and dead code (#3495) 2022-08-12 15:06:40 -04:00
Mathias Fredriksson 010f64e8e9 fix: Enable goleak for cli tests (#3370) 2022-08-12 21:02:10 +03:00
Bruno Quaresma 0e8c68ebc5 chore: Increase border radius (#3493) 2022-08-12 14:58:14 -03:00
Muhammad Atif Ali c3fcf7c953 chore: renamed coder template edit flags in coder CLI (#3471)
Use `-` over `_` for cli flags
2022-08-12 10:21:42 -05:00
Kyle Carberry b3d3b8ba0f fix: Stop multiple buttons from compounding in the workspace action dropdown (#3482)
The variadic function on an object doesn't clone the inner array.

This was causing the `secondary` property to accumulate more and
more button types as time went on!

Fixes #3154.
2022-08-12 13:19:52 +00:00
Kyle Carberry 16c12e976e chore: Improve agent logging (#3483) 2022-08-12 07:01:00 -05:00
Kyle Carberry ca342067b3 fix: Remove typo in policy.rego 2022-08-11 23:33:50 -05:00
Ammar Bandukwala d7b96f7d58 Correct spelling of macOS (#3478)
* Correct spelling of macOS

* fixup! Correct spelling of macOS

* fixup! Correct spelling of macOS
2022-08-11 21:22:06 -04:00
Jon Ayers 923c212960 chore: add zstd to dogfood image (#3479) 2022-08-11 17:48:49 -05:00
Steven Masley 3ae42f4de9 chore: Update rego to be partial execution friendly (#3449)
- Improves performance of batch authorization calls
- Enables possibility to convert rego auth calls into SQL WHERE clauses
2022-08-11 22:07:48 +00:00
Bruno Quaresma 4a17e0d91f feat: Add setup page (#3476) 2022-08-11 17:22:46 +00:00
Sagar Vora 604f211674 fix: replace broken link with Github contributors graph (#3472) 2022-08-11 14:35:51 +00:00
Kira Pilot 6122df6f1f feature: gate audit log by permissions (#3464)
* pairing

* restricting audit route

resolvees #3460

* updated tests

* fixing lint

* useSelector instead of useActor
2022-08-11 09:34:45 -04:00
Ammar Bandukwala 4e6645af50 docs: outdent generic quickstart (#3467) 2022-08-10 21:53:35 -05:00
Jon Ayers 426b30ed16 fix: add missing dependencies to dogfood image (#3470) 2022-08-11 01:24:56 +00:00
Eric Paulsen 272962cfae docs: add upgrade page & update getting started (#3439) 2022-08-10 17:56:21 -05:00
Presley Pizzo 5d40b1f0f4 feat: Add switches for auto-start and auto-stop (#3358)
* Add elements

* Add Loading story

* Make form show empty values when manual

* Make form depend on switches

* Fix style

* Format

* Update unit tests

* Tweaks

* Update storybook

* Move util files

* Pull out more util functions

* Pull out strings

* Add border to section

* Make min ttl 1

* Format

* Fix import

* Fix validation for falsey values

* Format and fix tests

* Put switches in form, persist form state

* Fix bug

* Remove helper text when disabled

* Fix storybook

* Revert "Remove helper text when disabled"

This reverts commit a6271ca6c4.

* Format

* Use nicer function to set values

* Format
2022-08-10 22:03:15 +00:00
Ben Potter cee0d1f848 chore: add metadata to example templates (#3451) 2022-08-10 16:34:17 -05:00
4547 changed files with 42383 additions and 20981 deletions
+83
View File
@@ -0,0 +1,83 @@
FROM ubuntu
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV EDITOR=vim
RUN apt-get update && apt-get upgrade
RUN apt-get install --yes \
ca-certificates \
bash-completion \
build-essential \
curl \
cmake \
direnv \
emacs-nox \
gnupg \
htop \
jq \
less \
lsb-release \
lsof \
man-db \
nano \
neovim \
ssl-cert \
sudo \
unzip \
xz-utils \
zip
# configure locales to UTF8
RUN apt-get install locales && locale-gen en_US.UTF-8
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
# configure direnv
RUN direnv hook bash >> $HOME/.bashrc
# install nix
RUN sh <(curl -L https://nixos.org/nix/install) --daemon
RUN mkdir -p $HOME/.config/nix $HOME/.config/nixpkgs \
&& echo 'sandbox = false' >> $HOME/.config/nix/nix.conf \
&& echo '{ allowUnfree = true; }' >> $HOME/.config/nixpkgs/config.nix \
&& echo '. $HOME/.nix-profile/etc/profile.d/nix.sh' >> $HOME/.bashrc
# install docker and configure daemon to use vfs as GitHub codespaces requires vfs
# https://github.com/moby/moby/issues/13742#issuecomment-725197223
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install --yes docker-ce docker-ce-cli containerd.io docker-compose-plugin \
&& mkdir -p /etc/docker \
&& echo '{"cgroup-parent":"/actions_job","storage-driver":"vfs"}' >> /etc/docker/daemon.json
# install golang and language tooling
ENV GO_VERSION=1.19
ENV GOPATH=$HOME/go-packages
ENV GOROOT=$HOME/go
ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH
RUN curl -fsSL https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz | tar xzs
RUN echo 'export PATH=$GOPATH/bin:$PATH' >> $HOME/.bashrc
RUN bash -c ". $HOME/.bashrc \
go install -v golang.org/x/tools/gopls@latest \
&& go install -v mvdan.cc/sh/v3/cmd/shfmt@latest \
"
# install nodejs
RUN bash -c "$(curl -fsSL https://deb.nodesource.com/setup_14.x)" \
&& apt-get install -y nodejs
# install zstd
RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/horta/zstd.install/main/install)"
# install nfpm
RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list \
&& apt update \
&& apt install nfpm
+18
View File
@@ -0,0 +1,18 @@
// For format details, see https://aka.ms/devcontainer.json
{
"name": "Development environments on your infrastructure",
// Sets the run context to one level up instead of the .devcontainer folder.
"context": ".",
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
"dockerFile": "Dockerfile",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
"postStartCommand": "dockerd",
// privileged is required by GitHub codespaces - https://github.com/microsoft/vscode-dev-containers/issues/727
"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--privileged", "--init" ]
}
+3 -1
View File
@@ -1,2 +1,4 @@
site/ @coder/frontend
docs/ @ammario
docs/ @coder/docs
README.md @coder/docs
ADOPTERS.md @coder/docs
+2 -12
View File
@@ -1,13 +1,3 @@
<!-- Help reviewers by listing the subtasks in this PR
Here's an example:
This PR adds a new feature to the CLI.
## Subtasks
- [x] added a test for feature
Fixes #345
<!--
Check if your change requires documentation edits before merging: https://coder.com/docs/coder. Make edits in `docs/`.
-->
+86 -81
View File
@@ -36,7 +36,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: typos-action
uses: crate-ci/typos@v1.0.4
uses: crate-ci/typos@master
with:
config: .github/workflows/typos.toml
- name: Fix Helper
@@ -52,6 +52,7 @@ jobs:
docs-only: ${{ steps.filter.outputs.docs_count == steps.filter.outputs.all_count }}
sh: ${{ steps.filter.outputs.sh }}
ts: ${{ steps.filter.outputs.ts }}
k8s: ${{ steps.filter.outputs.k8s }}
steps:
- uses: actions/checkout@v3
# For pull requests it's not necessary to checkout the code
@@ -69,6 +70,10 @@ jobs:
- "**.sh"
ts:
- 'site/**'
k8s:
- 'helm/**'
- Dockerfile
- scripts/helm.sh
- id: debug
run: |
echo "${{ toJSON(steps.filter )}}"
@@ -91,11 +96,20 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: golangci-lint
uses: golangci/golangci-lint-action@v3.2.0
with:
version: v1.46.0
version: v1.48.0
check-enterprise-imports:
name: check/enterprise-imports
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check imports of enterprise code
run: ./scripts/check_enterprise_imports.sh
style-lint-shellcheck:
name: style/lint/shellcheck
@@ -136,6 +150,26 @@ jobs:
run: yarn lint
working-directory: site
style-lint-k8s:
name: "style/lint/k8s"
timeout-minutes: 5
needs: changes
if: needs.changes.outputs.k8s == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install helm
uses: azure/setup-helm@v3
with:
version: v3.9.2
- name: cd helm && make lint
run: |
cd helm
make lint
gen:
name: "style/gen"
timeout-minutes: 8
@@ -159,13 +193,9 @@ jobs:
- name: Install node_modules
run: ./scripts/yarn_install.sh
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: "3.20.0"
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -185,14 +215,33 @@ jobs:
path: ${{ steps.go-cache-paths.outputs.go-mod }}
key: ${{ github.job }}-go-mod-${{ hashFiles('**/go.sum') }}
- run: |
- name: Install sqlc
run: |
curl -sSL https://github.com/kyleconroy/sqlc/releases/download/v1.13.0/sqlc_1.13.0_linux_amd64.tar.gz | sudo tar -C /usr/bin -xz sqlc
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
- name: Install protoc-gen-go-drpc
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@latest
- run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
- run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
- run: go install golang.org/x/tools/cmd/goimports@latest
- run: "make --output-sync -j -B gen"
- run: ./scripts/check_unstaged.sh
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile
# or the version in the comments will differ.
set -x
cd dogfood
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
protoc_path=/usr/local/bin/protoc
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
chmod +x $protoc_path
protoc --version
- name: make gen
run: "make --output-sync -j -B gen"
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
style-fmt:
name: "style/fmt"
@@ -222,7 +271,8 @@ jobs:
- name: Install shfmt
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0
- run: |
- name: make fmt
run: |
export PATH=${PATH}:$(go env GOPATH)/bin
make --output-sync -j -B fmt
@@ -241,7 +291,7 @@ jobs:
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -294,15 +344,6 @@ jobs:
fi
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=$test_timeout -short -failfast $COVERAGE_FLAGS
- name: Upload DataDog Trace
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
DD_DATABASE: fake
DD_CATEGORY: unit
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: go run scripts/datadog-cireport/main.go gotests.xml
- uses: codecov/codecov-action@v3
# This action has a tendency to error out unexpectedly, it has
# the `fail_ci_if_error` option that defaults to `false`, but
@@ -328,7 +369,7 @@ jobs:
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -364,14 +405,6 @@ jobs:
- name: Test with PostgreSQL Database
run: make test-postgres
- name: Upload DataDog Trace
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
DD_DATABASE: postgresql
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: go run scripts/datadog-cireport/main.go gotests.xml
- uses: codecov/codecov-action@v3
# This action has a tendency to error out unexpectedly, it has
# the `fail_ci_if_error` option that defaults to `false`, but
@@ -411,7 +444,7 @@ jobs:
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: Echo Go Cache Paths
id: go-cache-paths
@@ -442,41 +475,29 @@ jobs:
restore-keys: |
js-${{ runner.os }}-
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@latest
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Build site
run: make -B site/out/index.html
- name: Build Release
run: |
set -euo pipefail
go mod download
mkdir -p ./dist
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
# build linux amd64 packages
./scripts/build_go_matrix.sh \
--output ./dist/ \
--package-linux \
linux:amd64 \
windows:amd64
version="$(./scripts/version.sh)"
make -j \
build/coder_"$version"_windows_amd64.zip \
build/coder_"$version"_linux_amd64.{tar.gz,deb}
- name: Install Release
run: |
gcloud config set project coder-dogfood
gcloud config set compute/zone us-central1-a
gcloud compute scp ./dist/coder_*_linux_amd64.deb coder:/tmp/coder.deb
gcloud compute scp ./build/coder_*_linux_amd64.deb coder:/tmp/coder.deb
gcloud compute ssh coder -- sudo dpkg -i --force-confdef /tmp/coder.deb
gcloud compute ssh coder -- sudo systemctl daemon-reload
@@ -487,12 +508,9 @@ jobs:
with:
name: coder
path: |
./dist/*.zip
./dist/*.exe
./dist/*.tar.gz
./dist/*.apk
./dist/*.deb
./dist/*.rpm
./build/*.zip
./build/*.tar.gz
./build/*.deb
retention-days: 7
test-js:
@@ -513,11 +531,6 @@ jobs:
restore-keys: |
js-${{ runner.os }}-
# Go is required for uploading the test results to datadog
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
- uses: actions/setup-node@v3
with:
node-version: "14"
@@ -540,14 +553,6 @@ jobs:
files: ./site/coverage/lcov.info
flags: unittest-js
- name: Upload DataDog Trace
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
DD_CATEGORY: unit
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
test-e2e:
name: "test/e2e/${{ matrix.os }}"
needs:
@@ -571,10 +576,9 @@ jobs:
.eslintcache
key: js-${{ runner.os }}-e2e-${{ hashFiles('**/yarn.lock') }}
# Go is required for uploading the test results to datadog
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- uses: hashicorp/setup-terraform@v2
with:
@@ -619,13 +623,14 @@ jobs:
DEBUG: pw:api
working-directory: site
- name: Upload DataDog Trace
- name: Upload Playwright Failed Tests
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
DD_CATEGORY: e2e
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
uses: actions/upload-artifact@v3
with:
name: failed-test-videos
path: ./site/test-results/**/*.webm
retention:days: 7
chromatic:
# REMARK: this is only used to build storybook and deploy it to Chromatic.
runs-on: ubuntu-latest
+13
View File
@@ -0,0 +1,13 @@
# Dependabot is annoying, but this makes it a bit less so.
name: Auto Approve Dependabot
on: pull_request_target
jobs:
auto-approve:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: hmarr/auto-approve-action@v2
if: github.actor == 'dependabot[bot]'
+9 -1
View File
@@ -21,6 +21,14 @@ jobs:
id: branch-name
uses: tj-actions/branch-names@v5.4
- name: "Branch name to Docker tag name"
id: docker-tag-name
run: |
tag=${{ steps.branch-name.outputs.current_branch }}
# Replace / with --, e.g. user/feature => user--feature.
tag=${tag//\//--}
echo "::set-output name=tag::${tag}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -38,6 +46,6 @@ jobs:
with:
context: "{{defaultContext}}:dogfood"
push: true
tags: "codercom/oss-dogfood:${{ steps.branch-name.outputs.current_branch }},codercom/oss-dogfood:latest"
tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest"
cache-from: type=registry,ref=codercom/oss-dogfood:latest
cache-to: type=inline
+82 -198
View File
@@ -1,10 +1,4 @@
# GitHub release workflow.
#
# This workflow is a bit complicated because we have to build darwin binaries on
# a mac runner, but the mac runners are extremely slow. So instead of running
# the entire release on a mac (which will take an hour to run), we run only the
# mac build on a mac, and the rest on a linux runner. The final release is then
# published using a final linux runner.
name: release
on:
push:
@@ -21,11 +15,17 @@ on:
type: boolean
required: true
permissions:
# Required to publish a release
contents: write
# Necessary to push docker images to ghcr.io.
packages: write
env:
CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }}
jobs:
linux-windows:
release:
runs-on: ubuntu-latest
env:
# Necessary for Docker manifest
@@ -47,12 +47,12 @@ jobs:
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
go-version: "~1.19"
- name: Cache Node
id: cache-node
@@ -66,51 +66,66 @@ jobs:
js-${{ runner.os }}-
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
run: |
set -euo pipefail
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.18.1/nfpm_amd64.deb
sudo dpkg -i /tmp/nfpm.deb
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Build Site
run: make site/out/index.html
- name: Install rcodesign
run: |
set -euo pipefail
- name: Build Linux and Windows Binaries
# Install a prebuilt binary of rcodesign for linux amd64. Once the
# following PR is merged and released upstream, we can download
# directly from GitHub releases instead:
# https://github.com/indygreg/PyOxidizer/pull/635
wget -O /tmp/rcodesign https://cdn.discordapp.com/attachments/283356472258199552/1016767245717872700/rcodesign
sudo install --mode 755 /tmp/rcodesign /usr/local/bin/rcodesign
- name: Setup Apple Developer certificate and API key
run: |
set -euo pipefail
touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12
echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt
echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8
env:
AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Build binaries
run: |
set -euo pipefail
go mod download
mkdir -p ./dist
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
version="$(./scripts/version.sh)"
make gen/mark-fresh
make -j \
build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \
build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \
build/coder_helm_"$version".tgz
env:
CODER_SIGN_DARWIN: "1"
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }}
AC_APIKEY_ID: ${{ secrets.AC_APIKEY_ID }}
AC_APIKEY_FILE: /tmp/apple_apikey.p8
# build linux and windows binaries
./scripts/build_go_matrix.sh \
--output ./dist/ \
--archive \
--package-linux \
linux:amd64,armv7,arm64 \
windows:amd64,arm64
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
- name: Build Linux Docker images
run: |
set -euxo pipefail
# build and (maybe) push Docker images for each architecture
images=()
for arch in amd64 armv7 arm64; do
img="$(
./scripts/build_docker.sh \
${{ (!github.event.inputs.dry_run && !github.event.inputs.snapshot) && '--push' || '' }} \
--arch "$arch" \
./dist/coder_*_linux_"$arch"
)"
images+=("$img")
done
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# we can't build multi-arch if the images aren't pushed, so quit now
# if dry-running
@@ -119,10 +134,9 @@ jobs:
exit 0
fi
# build and push multi-arch manifest
./scripts/build_docker_multiarch.sh \
--push \
"${images[@]}"
# build and push multi-arch manifest, this depends on the other images
# being pushed so will automatically push them.
make -j push/build/coder_"$version"_linux.tag
# if the current version is equal to the highest (according to semver)
# version in the repo, also create a multi-arch image as ":latest" and
@@ -131,165 +145,35 @@ jobs:
./scripts/build_docker_multiarch.sh \
--push \
--target "$(./scripts/image_tag.sh --version latest)" \
"${images[@]}"
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
fi
- name: Upload binary artifacts
uses: actions/upload-artifact@v3
with:
name: linux
path: |
dist/*.zip
dist/*.tar.gz
dist/*.apk
dist/*.deb
dist/*.rpm
- name: ls build
run: ls -lh build
# The mac binaries get built on mac runners because they need to be signed,
# and the signing tool only runs on mac. This darwin job only builds the Mac
# binaries and uploads them as job artifacts used by the publish step.
darwin:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
# If the event that triggered the build was an annotated tag (which our
# tags are supposed to be), actions/checkout has a bug where the tag in
# question is only a lightweight tag and not a full annotated tag. This
# command seems to fix it.
# https://github.com/actions/checkout/issues/290
- name: Fetch git tags
run: git fetch --tags --force
- uses: actions/setup-go@v3
with:
go-version: "~1.18"
- name: Import Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
p12-password: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
- name: Cache Node
id: cache-node
uses: actions/cache@v3
with:
path: |
**/node_modules
.eslintcache
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
js-${{ runner.os }}-
- name: Install dependencies
run: |
set -euo pipefail
# The version of bash that MacOS ships with is too old
brew install bash
# The version of make that MacOS ships with is too old
brew install make
echo "$(brew --prefix)/opt/make/libexec/gnubin" >> $GITHUB_PATH
# BSD getopt is incompatible with the build scripts
brew install gnu-getopt
echo "$(brew --prefix)/opt/gnu-getopt/bin" >> $GITHUB_PATH
# Used for notarizing the binaries
brew tap mitchellh/gon
brew install mitchellh/gon/gon
# Used for compressing embedded slim binaries
brew install zstd
- name: Build Site
run: make site/out/index.html
- name: Build darwin Binaries (with signatures)
run: |
set -euo pipefail
go mod download
mkdir -p ./dist
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
# build darwin binaries
./scripts/build_go_matrix.sh \
--output ./dist/ \
--archive \
--sign-darwin \
darwin:amd64,arm64
env:
AC_USERNAME: ${{ secrets.AC_USERNAME }}
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
AC_APPLICATION_IDENTITY: BDB050EB749EDD6A80C6F119BF1382ECA119CCCC
- name: Upload Binary Artifacts
uses: actions/upload-artifact@v3
with:
name: darwin
path: ./dist/coder_*.zip
publish:
runs-on: ubuntu-latest
needs:
- linux-windows
- darwin
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
# If the event that triggered the build was an annotated tag (which our
# tags are supposed to be), actions/checkout has a bug where the tag in
# question is only a lightweight tag and not a full annotated tag. This
# command seems to fix it.
# https://github.com/actions/checkout/issues/290
- name: Fetch git tags
run: git fetch --tags --force
- name: mkdir artifacts
run: mkdir artifacts
- name: Download darwin Artifacts
uses: actions/download-artifact@v3
with:
name: darwin
path: artifacts
- name: Download Linux and Windows Artifacts
uses: actions/download-artifact@v3
with:
name: linux
path: artifacts
- name: ls artifacts
run: ls artifacts
- name: Publish Helm
run: |
set -euxo pipefail
./scripts/helm.sh --push
mv ./dist/*.tgz ./artifacts/
- name: Publish Release
- name: Publish release
run: |
./scripts/publish_release.sh \
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
./artifacts/*.zip \
./artifacts/*.tar.gz \
./artifacts/*.tgz \
./artifacts/*.apk \
./artifacts/*.deb \
./artifacts/*.rpm
./build/*.zip \
./build/*.tar.gz \
./build/*.tgz \
./build/*.apk \
./build/*.deb \
./build/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts to actions (if dry-run or snapshot)
if: ${{ github.event.inputs.dry_run || github.event.inputs.snapshot }}
uses: actions/upload-artifact@v2
with:
name: release-artifacts
path: |
./build/*.zip
./build/*.tar.gz
./build/*.tgz
./build/*.apk
./build/*.deb
./build/*.rpm
retention-days: 7
-1
View File
@@ -31,6 +31,5 @@ jobs:
isn't more activity.
# Upped from 30 since we have a big tracker and was hitting the limit.
operations-per-run: 60
close-issue-reason: not_planned
# Start with the oldest issues, always.
ascending: true
+2
View File
@@ -2,12 +2,14 @@
alog = "alog"
Jetbrains = "JetBrains"
IST = "IST"
MacOS = "macOS"
[default.extend-words]
[files]
extend-exclude = [
"**.svg",
"**.png",
"**.lock",
"go.sum",
"go.mod",
+18
View File
@@ -0,0 +1,18 @@
name: Welcome
on:
pull_request:
types: [opened]
jobs:
test:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: wow-actions/welcome@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FIRST_PR_REACTIONS: '+1, hooray, rocket, heart'
FIRST_PR_COMMENT: |
👋 Welcome @{{ author }} to Coder! Yo @coder/docs this is @{{ author }}'s first pull-request here!
FIRST_PR_MERGED: |
🎉 Thanks for the contribution @{{ author }}! Yo @coder/docs @{{ author }}'s first contribution has been merged! 👀👀👀
+5
View File
@@ -15,6 +15,7 @@ vendor
yarn-error.log
gotests.coverage
.idea
.gitpod.yml
.DS_Store
# Front-end ignore
@@ -30,14 +31,18 @@ site/**/*.typegen.ts
site/build-storybook.log
# Build
build/
dist/
site/out/
*.tfstate
*.tfstate.backup
*.tfplan
*.lock.hcl
.terraform/
.vscode/*.log
.vscode/launch.json
**/*.swp
.coderv2/*
**/__debug_bin
+61 -10
View File
@@ -2,16 +2,22 @@
"cSpell.words": [
"apps",
"awsidentity",
"bodyclose",
"buildinfo",
"buildname",
"circbuf",
"cliflag",
"cliui",
"codecov",
"Codespaces",
"coderd",
"coderdtest",
"codersdk",
"cronstrue",
"databasefake",
"DERP",
"derphttp",
"derpmap",
"devel",
"drpc",
"drpcconn",
@@ -24,8 +30,10 @@
"goarch",
"gographviz",
"goleak",
"gonet",
"gossh",
"gsyslog",
"GTTY",
"hashicorp",
"hclsyntax",
"httpapi",
@@ -33,24 +41,37 @@
"idtoken",
"Iflag",
"incpatch",
"ipnstate",
"isatty",
"Jobf",
"Keygen",
"kirsle",
"Kubernetes",
"ldflags",
"magicsock",
"manifoldco",
"mapstructure",
"mattn",
"mitchellh",
"moby",
"namesgenerator",
"namespacing",
"netaddr",
"netip",
"netmap",
"netns",
"netstack",
"nettype",
"nfpms",
"nhooyr",
"nmcfg",
"nolint",
"nosec",
"ntqry",
"OIDC",
"oneof",
"opty",
"paralleltest",
"parameterscopeid",
"pqtype",
"prometheusmetrics",
@@ -59,15 +80,27 @@
"provisionerd",
"provisionersdk",
"ptty",
"ptys",
"ptytest",
"quickstart",
"reconfig",
"retrier",
"rpty",
"sdkproto",
"sdktrace",
"Signup",
"slogtest",
"sourcemapped",
"Srcs",
"stretchr",
"STTY",
"stuntest",
"tailbroker",
"tailcfg",
"tailexchange",
"tailnet",
"tailnettest",
"Tailscale",
"TCGETS",
"tcpip",
"TCSETS",
@@ -79,24 +112,43 @@
"tfjson",
"tfplan",
"tfstate",
"tios",
"tparallel",
"trimprefix",
"tsdial",
"tslogger",
"tstun",
"turnconn",
"typegen",
"typesafe",
"unconvert",
"Untar",
"Userspace",
"VMID",
"walkthrough",
"weblinks",
"webrtc",
"wgcfg",
"wgconfig",
"wgengine",
"wgmonitor",
"wgnet",
"workspaceagent",
"workspaceagents",
"workspaceapp",
"workspaceapps",
"workspacebuilds",
"workspacename",
"wsconncache",
"wsjson",
"xerrors",
"xstate",
"yamux"
],
"cSpell.ignorePaths": [
"site/package.json",
".vscode/settings.json"
],
"emeraldwalk.runonsave": {
"commands": [
{
@@ -119,20 +171,19 @@
"go.lintFlags": ["--fast"],
"go.lintOnSave": "package",
"go.coverOnSave": true,
"go.coverageDecorator": {
"type": "gutter",
"coveredGutterStyle": "blockgreen",
"uncoveredGutterStyle": "blockred"
},
// The codersdk is used by coderd another other packages extensively.
// To reduce redundancy in tests, it's covered by other packages.
// Since package coverage pairing can't be defined, all packages cover
// all other packages.
"go.testFlags": ["-short", "-coverpkg=./..."],
"go.coverageDecorator": {
"type": "gutter",
"coveredHighlightColor": "rgba(64,128,128,0.5)",
"uncoveredHighlightColor": "rgba(128,64,64,0.25)",
"coveredBorderColor": "rgba(64,128,128,0.5)",
"uncoveredBorderColor": "rgba(128,64,64,0.25)",
"coveredGutterStyle": "blockgreen",
"uncoveredGutterStyle": "blockred"
},
"go.testFlags": [
"-short",
"-coverpkg=./..."
],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib"
+12
View File
@@ -0,0 +1,12 @@
# Adopters
[!["Join us on
Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=green)](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=adopters.md) [![Twitter
Follow](https://img.shields.io/twitter/follow/coderhq?label=%40coderhq&style=social)](https://twitter.com/coderhq)
🦩 _If you're using Coder in your organization, please try to add your company name to this list. It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. You can do this by by editing this file and contributing your changes via a pull-request on GitHub._
> 👋 _If you are considering using Coder in your organization please introduce yourself via https://coder.com/demo_ 🙇🏻‍♂️
| Organization | Contact | Description of Use |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Coder](https://www.coder.com) | [@coderhq](https://twitter.com/coderhq) | Coder builds coder with Coder. |
+9 -4
View File
@@ -15,12 +15,17 @@ LABEL \
org.opencontainers.image.version="$CODER_VERSION" \
org.opencontainers.image.licenses="AGPL-3.0"
# Create coder group and user. We cannot use `addgroup` and `adduser` because
# they won't work if we're building the image for a different architecture.
COPY --chown=root:root --chmod=644 group passwd /etc/
# The coder binary is injected by scripts/build_docker.sh.
COPY --chown=coder:coder --chmod=755 coder /opt/coder
# Create coder group and user. We cannot use `addgroup` and `adduser` because
# they won't work if we're building the image for a different architecture.
COPY --chown=root:root --chmod=644 group passwd /etc/
COPY --chown=coder:coder --chmod=700 empty-dir /home/coder
USER coder:coder
ENV HOME=/home/coder
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt
WORKDIR /home/coder
ENTRYPOINT [ "/opt/coder", "server" ]
+31
View File
@@ -0,0 +1,31 @@
## Acceptance
By using any software and associated documentation files under Coder
Technologies Inc.s ("Coder") directory named "enterprise" ("Enterprise
Software"), you agree to all of the terms and conditions below.
## Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide,
non-sublicensable, non-transferable license to use, copy, distribute, make
available, modify and prepare derivative works of the Enterprise Software, in
each case subject to the limitations and conditions below.
## Limitations
You may not move, change, disable, or circumvent the license key functionality
in the software, and you may not remove or obscure any functionality in the
software that is protected by the license key.
You may not alter, remove, or obscure any licensing, copyright, or other notices
of the licensor in the software.
You agree that Coder and/or its licensors (as applicable) retain all right,
title and interest in and to all such modifications and/or patches.
## Additional Terms
This Enterprise Software may only be used in production, if you (and any entity
that you represent) have agreed to, and are in compliance with, the Coders
Terms of Service, available at https://coder.com/legal/terms-of-service, or
other agreement governing the use of the Software, as agreed by you and Coder.
+367 -97
View File
@@ -1,75 +1,350 @@
.DEFAULT_GOAL := build
# This is the Coder Makefile. The build directory for most tasks is `build/`.
#
# These are the targets you're probably looking for:
# - clean
# - build-fat: builds all "fat" binaries for all architectures
# - build-slim: builds all "slim" binaries (no frontend or slim binaries
# embedded) for all architectures
# - release: simulate a release (mostly, does not push images)
# - build/coder(-slim)?_${os}_${arch}(.exe)?: build a single fat binary
# - build/coder_${os}_${arch}.(zip|tar.gz): build a release archive
# - build/coder_linux_${arch}.(apk|deb|rpm): build a release Linux package
# - build/coder_${version}_linux_${arch}.tag: build a release Linux Docker image
# - build/coder_helm.tgz: build a release Helm chart
.DEFAULT_GOAL := build-fat
# Use a single bash shell for each job, and immediately exit on failure
SHELL := bash
.SHELLFLAGS = -ceu
.SHELLFLAGS := -ceu
.ONESHELL:
# This doesn't work on directories.
# See https://stackoverflow.com/questions/25752543/make-delete-on-error-for-directory-targets
.DELETE_ON_ERROR:
INSTALL_DIR=$(shell go env GOPATH)/bin
GOOS=$(shell go env GOOS)
GOARCH=$(shell go env GOARCH)
VERSION=$(shell ./scripts/version.sh)
# Don't print the commands in the file unless you specify VERBOSE. This is
# essentially the same as putting "@" at the start of each line.
ifndef VERBOSE
.SILENT:
endif
bin: $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
@echo "== This builds slim binaries for command-line usage."
@echo "== Use \"make build\" to embed the site."
# Create the output directories if they do not exist.
$(shell mkdir -p build site/out/bin)
mkdir -p ./dist
rm -rf ./dist/coder-slim_*
rm -f ./site/out/bin/coder*
./scripts/build_go_slim.sh \
--compress 6 \
GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
VERSION := $(shell ./scripts/version.sh)
# Use the highest ZSTD compression level in CI.
ifdef CI
ZSTDFLAGS := -22 --ultra
else
ZSTDFLAGS := -6
endif
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
OS_ARCHES := \
linux_amd64 linux_arm64 linux_armv7 \
darwin_amd64 darwin_arm64 \
windows_amd64.exe windows_arm64.exe
# Archive formats and their corresponding ${OS}_${ARCH} combos.
ARCHIVE_TAR_GZ := linux_amd64 linux_arm64 linux_armv7
ARCHIVE_ZIP := \
darwin_amd64 darwin_arm64 \
windows_amd64 windows_arm64
# All package formats we build and the ${OS}_${ARCH} combos we build them for.
PACKAGE_FORMATS := apk deb rpm
PACKAGE_OS_ARCHES := linux_amd64 linux_armv7 linux_arm64
# All architectures we build Docker images for (Linux only).
DOCKER_ARCHES := amd64 arm64 armv7
# Computed variables based on the above.
CODER_SLIM_BINARIES := $(addprefix build/coder-slim_$(VERSION)_,$(OS_ARCHES))
CODER_FAT_BINARIES := $(addprefix build/coder_$(VERSION)_,$(OS_ARCHES))
CODER_ALL_BINARIES := $(CODER_SLIM_BINARIES) $(CODER_FAT_BINARIES)
CODER_TAR_GZ_ARCHIVES := $(foreach os_arch, $(ARCHIVE_TAR_GZ), build/coder_$(VERSION)_$(os_arch).tar.gz)
CODER_ZIP_ARCHIVES := $(foreach os_arch, $(ARCHIVE_ZIP), build/coder_$(VERSION)_$(os_arch).zip)
CODER_ALL_ARCHIVES := $(CODER_TAR_GZ_ARCHIVES) $(CODER_ZIP_ARCHIVES)
CODER_ALL_PACKAGES := $(foreach os_arch, $(PACKAGE_OS_ARCHES), $(addprefix build/coder_$(VERSION)_$(os_arch).,$(PACKAGE_FORMATS)))
CODER_ARCH_IMAGES := $(foreach arch, $(DOCKER_ARCHES), build/coder_$(VERSION)_linux_$(arch).tag)
CODER_ARCH_IMAGES_PUSHED := $(addprefix push/, $(CODER_ARCH_IMAGES))
CODER_MAIN_IMAGE := build/coder_$(VERSION)_linux.tag
CODER_SLIM_NOVERSION_BINARIES := $(addprefix build/coder-slim_,$(OS_ARCHES))
CODER_FAT_NOVERSION_BINARIES := $(addprefix build/coder_,$(OS_ARCHES))
CODER_ALL_NOVERSION_IMAGES := $(foreach arch, $(DOCKER_ARCHES), build/coder_linux_$(arch).tag) build/coder_linux.tag
CODER_ALL_NOVERSION_IMAGES_PUSHED := $(addprefix push/, $(CODER_ALL_NOVERSION_IMAGES))
clean:
rm -rf build site/out
mkdir -p build site/out/bin
git restore site/out
.PHONY: clean
build-slim: $(CODER_SLIM_BINARIES)
.PHONY: build-slim
build-fat build-full build: $(CODER_FAT_BINARIES)
.PHONY: build-fat build-full build
release: $(CODER_FAT_BINARIES) $(CODER_ALL_ARCHIVES) $(CODER_ALL_PACKAGES) $(CODER_ARCH_IMAGES) build/coder_helm_$(VERSION).tgz
.PHONY: release
build/coder-slim_$(VERSION)_checksums.sha1 site/out/bin/coder.sha1: $(CODER_SLIM_BINARIES)
pushd ./site/out/bin
openssl dgst -r -sha1 coder-* | tee coder.sha1
popd
cp "site/out/bin/coder.sha1" "build/coder-slim_$(VERSION)_checksums.sha1"
build/coder-slim_$(VERSION).tar: build/coder-slim_$(VERSION)_checksums.sha1 $(CODER_SLIM_BINARIES)
pushd ./site/out/bin
tar cf "../../../build/$(@F)" coder-*
popd
build/coder-slim_$(VERSION).tar.zst site/out/bin/coder.tar.zst: build/coder-slim_$(VERSION).tar
zstd $(ZSTDFLAGS) \
--force \
--long \
--no-progress \
-o "build/coder-slim_$(VERSION).tar.zst" \
"build/coder-slim_$(VERSION).tar"
cp "build/coder-slim_$(VERSION).tar.zst" "site/out/bin/coder.tar.zst"
# delete the uncompressed binaries from the embedded dir
rm site/out/bin/coder-*
# Redirect from version-less targets to the versioned ones. There is a similar
# target for slim binaries below.
#
# Called like this:
# make build/coder_linux_amd64
# make build/coder_windows_amd64.exe
$(CODER_FAT_NOVERSION_BINARIES): build/coder_%: build/coder_$(VERSION)_%
rm -f "$@"
ln "$<" "$@"
# Same as above, but for slim binaries.
#
# Called like this:
# make build/coder-slim_linux_amd64
# make build/coder-slim_windows_amd64.exe
$(CODER_SLIM_NOVERSION_BINARIES): build/coder-slim_%: build/coder-slim_$(VERSION)_%
rm -f "$@"
ln "$<" "$@"
# "fat" binaries always depend on the site and the compressed slim binaries.
$(CODER_FAT_BINARIES): \
site/out/index.html \
site/out/bin/coder.sha1 \
site/out/bin/coder.tar.zst
# This is a handy block that parses the target to determine whether it's "slim"
# or "fat", which OS was specified and which architecture was specified.
#
# It populates the following variables: mode, os, arch_ext, arch, ext (without
# dot).
define get-mode-os-arch-ext =
mode="$$([[ "$@" = build/coder-slim* ]] && echo "slim" || echo "fat")"
os="$$(echo $@ | cut -d_ -f3)"
arch_ext="$$(echo $@ | cut -d_ -f4)"
if [[ "$$arch_ext" == *.* ]]; then
arch="$$(echo $$arch_ext | cut -d. -f1)"
ext="$${arch_ext#*.}"
else
arch="$$arch_ext"
ext=""
fi
endef
# This task handles all builds, for both "fat" and "slim" binaries. It parses
# the target name to get the metadata for the build, so it must be specified in
# this format:
# build/coder(-slim)?_${version}_${os}_${arch}(.exe)?
#
# You should probably use the non-version targets above instead if you're
# calling this manually.
$(CODER_ALL_BINARIES): go.mod go.sum \
$(shell find . -not -path './vendor/*' -type f -name '*.go') \
$(shell find ./examples/templates)
$(get-mode-os-arch-ext)
if [[ "$$os" != "windows" ]] && [[ "$$ext" != "" ]]; then
echo "ERROR: Invalid build binary extension" 1>&2
exit 1
fi
if [[ "$$os" == "windows" ]] && [[ "$$ext" != exe ]]; then
echo "ERROR: Windows binaries must have an .exe extension." 1>&2
exit 1
fi
build_args=( \
--os "$$os" \
--arch "$$arch" \
--version "$(VERSION)" \
--output ./dist/ \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
.PHONY: bin
--output "$@" \
)
if [ "$$mode" == "slim" ]; then
build_args+=(--slim)
fi
build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
rm -rf ./dist
mkdir -p ./dist
rm -f ./site/out/bin/coder*
./scripts/build_go.sh "$${build_args[@]}"
# build slim artifacts and copy them to the site output directory
./scripts/build_go_slim.sh \
if [[ "$$mode" == "slim" ]]; then
dot_ext=""
if [[ "$$ext" != "" ]]; then
dot_ext=".$$ext"
fi
cp "$@" "./site/out/bin/coder-$$os-$$arch$$dot_ext"
fi
# This task builds all archives. It parses the target name to get the metadata
# for the build, so it must be specified in this format:
# build/coder_${version}_${os}_${arch}.${format}
#
# The following OS/arch/format combinations are supported:
# .tar.gz: linux_amd64, linux_arm64, linux_armv7
# .zip: darwin_amd64, darwin_arm64, windows_amd64, windows_arm64
#
# This depends on all fat binaries because it's difficult to do dynamic
# dependencies due to the .exe requirement on Windows. These targets are
# typically only used during release anyways.
$(CODER_ALL_ARCHIVES): $(CODER_FAT_BINARIES)
$(get-mode-os-arch-ext)
bin_ext=""
if [[ "$$os" == "windows" ]]; then
bin_ext=".exe"
fi
./scripts/archive.sh \
--format "$$ext" \
--os "$$os" \
--output "$@" \
"build/coder_$(VERSION)_$${os}_$${arch}$${bin_ext}"
# This task builds all packages. It parses the target name to get the metadata
# for the build, so it must be specified in this format:
# build/coder_${version}_linux_${arch}.${format}
#
# Supports apk, deb, rpm for all linux targets.
#
# This depends on all Linux fat binaries and archives because it's difficult to
# do dynamic dependencies due to the extensions in the filenames. These targets
# are typically only used during release anyways.
#
# Packages need to run after the archives are built, otherwise they cause tar
# errors like "file changed as we read it".
CODER_PACKAGE_DEPS := $(foreach os_arch, $(PACKAGE_OS_ARCHES), build/coder_$(VERSION)_$(os_arch) build/coder_$(VERSION)_$(os_arch).tar.gz)
$(CODER_ALL_PACKAGES): $(CODER_PACKAGE_DEPS)
$(get-mode-os-arch-ext)
./scripts/package.sh \
--arch "$$arch" \
--format "$$ext" \
--version "$(VERSION)" \
--compress 6 \
--output ./dist/ \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
--output "$@" \
"build/coder_$(VERSION)_$${os}_$${arch}"
# build not-so-slim artifacts with the default name format
./scripts/build_go_matrix.sh \
# Redirect from version-less Docker image targets to the versioned ones.
#
# Called like this:
# make build/coder_linux_amd64.tag
$(CODER_ALL_NOVERSION_IMAGES): build/coder_%: build/coder_$(VERSION)_%
.PHONY: $(CODER_ALL_NOVERSION_IMAGES)
# Redirect from version-less push Docker image targets to the versioned ones.
#
# Called like this:
# make push/build/coder_linux_amd64.tag
$(CODER_ALL_NOVERSION_IMAGES_PUSHED): push/build/coder_%: push/build/coder_$(VERSION)_%
.PHONY: $(CODER_ALL_NOVERSION_IMAGES_PUSHED)
# This task builds all Docker images. It parses the target name to get the
# metadata for the build, so it must be specified in this format:
# build/coder_${version}_${os}_${arch}.tag
#
# Supports linux_amd64, linux_arm64, linux_armv7.
#
# Images need to run after the archives and packages are built, otherwise they
# cause errors like "file changed as we read it".
$(CODER_ARCH_IMAGES): build/coder_$(VERSION)_%.tag: \
build/coder_$(VERSION)_% \
build/coder_$(VERSION)_%.apk \
build/coder_$(VERSION)_%.deb \
build/coder_$(VERSION)_%.rpm \
build/coder_$(VERSION)_%.tar.gz
$(get-mode-os-arch-ext)
image_tag="$$(./scripts/image_tag.sh --arch "$$arch" --version "$(VERSION)")"
./scripts/build_docker.sh \
--arch "$$arch" \
--target "$$image_tag" \
--version "$(VERSION)" \
--output ./dist/ \
--archive \
--package-linux \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
.PHONY: build
"build/coder_$(VERSION)_$${os}_$${arch}"
# Runs migrations to output a dump of the database.
coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql)
go run coderd/database/dump/main.go
echo "$$image_tag" > "$@"
# Generates Go code for querying the database.
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
coderd/database/generate.sh
# Multi-arch Docker image. This requires all architecture-specific images to be
# built AND pushed.
$(CODER_MAIN_IMAGE): $(CODER_ARCH_IMAGES_PUSHED)
image_tag="$$(./scripts/image_tag.sh --version "$(VERSION)")"
./scripts/build_docker_multiarch.sh \
--target "$$image_tag" \
--version "$(VERSION)" \
$(foreach img, $^, "$$(cat "$(img:push/%=%)")")
# This target is deprecated, as GNU make has issues passing signals to subprocesses.
dev:
@echo Please run ./scripts/develop.sh manually.
.PHONY: dev
echo "$$image_tag" > "$@"
# Push a Docker image.
$(CODER_ARCH_IMAGES_PUSHED): push/%: %
image_tag="$$(cat "$<")"
docker push "$$image_tag"
.PHONY: $(CODER_ARCH_IMAGES_PUSHED)
# Push the multi-arch Docker manifest.
push/$(CODER_MAIN_IMAGE): $(CODER_MAIN_IMAGE)
image_tag="$$(cat "$<")"
docker manifest push "$$image_tag"
.PHONY: push/$(CODER_MAIN_IMAGE)
# Shortcut for Helm chart package.
build/coder_helm.tgz: build/coder_helm_$(VERSION).tgz
rm -f "$@"
ln "$<" "$@"
# Helm chart package.
build/coder_helm_$(VERSION).tgz:
./scripts/helm.sh \
--version "$(VERSION)" \
--output "$@"
site/out/index.html: $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.tsx') $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.ts') site/package.json
./scripts/yarn_install.sh
cd site
yarn typegen
yarn build
install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
install_dir="$$(go env GOPATH)/bin"
output_file="$${install_dir}/coder$(GOOS_BIN_EXT)"
mkdir -p "$$install_dir"
cp "$<" "$$output_file"
.PHONY: install
fmt: fmt/prettier fmt/terraform fmt/shfmt
.PHONY: fmt
fmt/prettier:
@echo "--- prettier"
echo "--- prettier"
cd site
# Avoid writing files in CI to reduce file write activity
ifdef CI
@@ -84,7 +359,7 @@ fmt/terraform: $(wildcard *.tf)
.PHONY: fmt/terraform
fmt/shfmt: $(shell shfmt -f .)
@echo "--- shfmt"
echo "--- shfmt"
# Only do diff check in CI, errors on diff.
ifdef CI
shfmt -d $(shell shfmt -f .)
@@ -93,58 +368,53 @@ else
endif
.PHONY: fmt/shfmt
fmt: fmt/prettier fmt/terraform fmt/shfmt
.PHONY: fmt
gen: coderd/database/querier.go peerbroker/proto/peerbroker.pb.go provisionersdk/proto/provisioner.pb.go provisionerd/proto/provisionerd.pb.go site/src/api/typesGenerated.ts
.PHONY: gen
install: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
@output_file="$(INSTALL_DIR)/coder"
@if [[ "$(GOOS)" == "windows" ]]; then
@output_file="$${output_file}.exe"
@fi
@echo "-- Building CLI for $(GOOS) $(GOARCH) at $$output_file"
./scripts/build_go.sh \
--version "$(VERSION)" \
--output "$$output_file" \
--os "$(GOOS)" \
--arch "$(GOARCH)"
@echo
.PHONY: install
lint: lint/shellcheck lint/go
.PHONY: lint
lint/go:
./scripts/check_enterprise_imports.sh
golangci-lint run
.PHONY: lint/go
# Use shfmt to determine the shell files, takes editorconfig into consideration.
lint/shellcheck: $(shell shfmt -f .)
@echo "--- shellcheck"
echo "--- shellcheck"
shellcheck --external-sources $(shell shfmt -f .)
.PHONY: lint/shellcheck
peerbroker/proto/peerbroker.pb.go: peerbroker/proto/peerbroker.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./peerbroker/proto/peerbroker.proto
# all gen targets should be added here and to gen/mark-fresh
gen: \
coderd/database/dump.sql \
coderd/database/querier.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
site/src/api/typesGenerated.ts
.PHONY: gen
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./provisionerd/proto/provisionerd.proto
# Mark all generated files as fresh so make thinks they're up-to-date. This is
# used during releases so we don't run generation scripts.
gen/mark-fresh:
files="coderd/database/dump.sql coderd/database/querier.go provisionersdk/proto/provisioner.pb.go provisionerd/proto/provisionerd.pb.go site/src/api/typesGenerated.ts"
for file in $$files; do
echo "$$file"
if [ ! -f "$$file" ]; then
echo "File '$$file' does not exist"
exit 1
fi
# touch sets the mtime of the file to the current time
touch $$file
done
.PHONY: gen/mark-fresh
# Runs migrations to output a dump of the database schema after migrations are
# applied.
coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/database/migrations/*.sql)
go run ./coderd/database/gen/dump/main.go
# Generates Go code for querying the database.
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/gen/enum/main.go
./coderd/database/generate.sh
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
protoc \
@@ -154,13 +424,13 @@ provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
--go-drpc_opt=paths=source_relative \
./provisionersdk/proto/provisioner.proto
site/out/index.html: $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.tsx') $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.ts') site/package.json
./scripts/yarn_install.sh
cd site
yarn typegen
yarn build
# Restores GITKEEP files!
git checkout HEAD out
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./provisionerd/proto/provisionerd.proto
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find codersdk -type f -name '*.go')
go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts
+9 -5
View File
@@ -1,7 +1,7 @@
# Coder
[!["Join us on
Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=green)](https://discord.gg/coder)
Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=green)](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
[![codecov](https://codecov.io/gh/coder/coder/branch/main/graph/badge.svg?token=TNLW3OAP6G)](https://codecov.io/gh/coder/coder)
[![Go Reference](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![Twitter
@@ -52,9 +52,9 @@ You can modify the installation process by including flags. Run the help command
curl -L https://coder.com/install.sh | sh -s -- --help
```
> See [install](docs/install.md) for additional methods.
> See [install](docs/install) for additional methods.
Once installed, you can start a production deployment with a single command:
Once installed, you can start a production deployment<sup>1</sup> with a single command:
```sh
# Automatically sets up an external access URL on *.try.coder.app
@@ -64,6 +64,8 @@ coder server --tunnel
coder server --postgres-url <url> --access-url <url>
```
> <sup>1</sup> The embedded database is great for trying out Coder with small deployments, but do consider using an external database for increased assurance and control.
Use `coder --help` to get a complete list of flags and environment variables. Use our [quickstart guide](https://coder.com/docs/coder-oss/latest/quickstart) for a full walkthrough.
## Documentation
@@ -87,12 +89,14 @@ _Last updated: 5/27/22_
## Community and Support
Join our community on [Discord](https://discord.gg/coder) and [Twitter](https://twitter.com/coderhq)!
Join our community on [Discord](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) and [Twitter](https://twitter.com/coderhq)!
[Suggest improvements and report problems](https://github.com/coder/coder/issues/new/choose)
## Contributing
If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact.
Read the [contributing docs](https://coder.com/docs/coder-oss/latest/CONTRIBUTING).
Find our list of contributors [here](./docs/CONTRIBUTORS.md).
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
+287 -240
View File
@@ -4,12 +4,13 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"net/netip"
"os"
"os/exec"
"os/user"
@@ -27,15 +28,14 @@ import (
"go.uber.org/atomic"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"inet.af/netaddr"
"tailscale.com/types/key"
"tailscale.com/net/speedtest"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"github.com/coder/coder/agent/usershell"
"github.com/coder/coder/peer"
"github.com/coder/coder/peer/peerwg"
"github.com/coder/coder/peerbroker"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty"
"github.com/coder/coder/tailnet"
"github.com/coder/retry"
)
@@ -51,55 +51,46 @@ const (
)
type Options struct {
EnableWireguard bool
UploadWireguardKeys UploadWireguardKeys
ListenWireguardPeers ListenWireguardPeers
ReconnectingPTYTimeout time.Duration
EnvironmentVariables map[string]string
Logger slog.Logger
CoordinatorDialer CoordinatorDialer
FetchMetadata FetchMetadata
StatsReporter StatsReporter
WorkspaceAgentApps WorkspaceAgentApps
PostWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth
ReconnectingPTYTimeout time.Duration
EnvironmentVariables map[string]string
Logger slog.Logger
}
type Metadata struct {
WireguardAddresses []netaddr.IPPrefix `json:"addresses"`
EnvironmentVariables map[string]string `json:"environment_variables"`
StartupScript string `json:"startup_script"`
Directory string `json:"directory"`
}
// CoordinatorDialer is a function that constructs a new broker.
// A dialer must be passed in to allow for reconnects.
type CoordinatorDialer func(context.Context) (net.Conn, error)
type WireguardPublicKeys struct {
Public key.NodePublic `json:"public"`
Disco key.DiscoPublic `json:"disco"`
}
// FetchMetadata is a function to obtain metadata for the agent.
type FetchMetadata func(context.Context) (codersdk.WorkspaceAgentMetadata, error)
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
type UploadWireguardKeys func(ctx context.Context, keys WireguardPublicKeys) error
type ListenWireguardPeers func(ctx context.Context, logger slog.Logger) (<-chan peerwg.Handshake, func(), error)
func New(dialer Dialer, options *Options) io.Closer {
if options == nil {
options = &Options{}
}
func New(options Options) io.Closer {
if options.ReconnectingPTYTimeout == 0 {
options.ReconnectingPTYTimeout = 5 * time.Minute
}
ctx, cancelFunc := context.WithCancel(context.Background())
server := &agent{
dialer: dialer,
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
logger: options.Logger,
closeCancel: cancelFunc,
closed: make(chan struct{}),
envVars: options.EnvironmentVariables,
enableWireguard: options.EnableWireguard,
postKeys: options.UploadWireguardKeys,
listenWireguardPeers: options.ListenWireguardPeers,
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
logger: options.Logger,
closeCancel: cancelFunc,
closed: make(chan struct{}),
envVars: options.EnvironmentVariables,
coordinatorDialer: options.CoordinatorDialer,
fetchMetadata: options.FetchMetadata,
stats: &Stats{},
statsReporter: options.StatsReporter,
workspaceAgentApps: options.WorkspaceAgentApps,
postWorkspaceAgentAppHealth: options.PostWorkspaceAgentAppHealth,
}
server.init(ctx)
return server
}
type agent struct {
dialer Dialer
logger slog.Logger
reconnectingPTYs sync.Map
@@ -113,23 +104,25 @@ type agent struct {
envVars map[string]string
// metadata is atomic because values can change after reconnection.
metadata atomic.Value
startupScript atomic.Bool
fetchMetadata FetchMetadata
sshServer *ssh.Server
enableWireguard bool
network *peerwg.Network
postKeys UploadWireguardKeys
listenWireguardPeers ListenWireguardPeers
network *tailnet.Conn
coordinatorDialer CoordinatorDialer
stats *Stats
statsReporter StatsReporter
workspaceAgentApps WorkspaceAgentApps
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth
}
func (a *agent) run(ctx context.Context) {
var metadata Metadata
var peerListener *peerbroker.Listener
var metadata codersdk.WorkspaceAgentMetadata
var err error
// An exponential back-off occurs when the connection is failing to dial.
// This is to prevent server spam in case of a coderd outage.
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
metadata, peerListener, err = a.dialer(ctx, a.logger)
a.logger.Info(ctx, "connecting")
metadata, err = a.fetchMetadata(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return
@@ -140,7 +133,7 @@ func (a *agent) run(ctx context.Context) {
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
continue
}
a.logger.Info(context.Background(), "connected")
a.logger.Info(context.Background(), "fetched metadata")
break
}
select {
@@ -150,40 +143,177 @@ func (a *agent) run(ctx context.Context) {
}
a.metadata.Store(metadata)
if a.startupScript.CAS(false, true) {
// The startup script has not ran yet!
go func() {
err := a.runStartupScript(ctx, metadata.StartupScript)
if errors.Is(err, context.Canceled) {
return
}
if err != nil {
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
}
}()
}
if a.enableWireguard {
err = a.startWireguard(ctx, metadata.WireguardAddresses)
if err != nil {
a.logger.Error(ctx, "start wireguard", slog.Error(err))
}
}
for {
conn, err := peerListener.Accept()
if err != nil {
if a.isClosed() {
return
}
a.logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
a.run(ctx)
// The startup script has not ran yet!
go func() {
err := a.runStartupScript(ctx, metadata.StartupScript)
if errors.Is(err, context.Canceled) {
return
}
a.closeMutex.Lock()
a.connCloseWait.Add(1)
a.closeMutex.Unlock()
go a.handlePeerConn(ctx, conn)
if err != nil {
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
}
}()
if metadata.DERPMap != nil {
go a.runTailnet(ctx, metadata.DERPMap)
}
if a.workspaceAgentApps != nil && a.postWorkspaceAgentAppHealth != nil {
go NewWorkspaceAppHealthReporter(a.logger, a.workspaceAgentApps, a.postWorkspaceAgentAppHealth)(ctx)
}
}
func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) {
a.closeMutex.Lock()
defer a.closeMutex.Unlock()
if a.isClosed() {
return
}
if a.network != nil {
a.network.SetDERPMap(derpMap)
return
}
var err error
a.network, err = tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.TailnetIP, 128)},
DERPMap: derpMap,
Logger: a.logger.Named("tailnet"),
})
if err != nil {
a.logger.Critical(ctx, "create tailnet", slog.Error(err))
return
}
a.network.SetForwardTCPCallback(func(conn net.Conn, listenerExists bool) net.Conn {
if listenerExists {
// If a listener already exists, we would double-wrap the conn.
return conn
}
return a.stats.wrapConn(conn)
})
go a.runCoordinator(ctx)
sshListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSSHPort))
if err != nil {
a.logger.Critical(ctx, "listen for ssh", slog.Error(err))
return
}
go func() {
for {
conn, err := sshListener.Accept()
if err != nil {
return
}
go a.sshServer.HandleConn(a.stats.wrapConn(conn))
}
}()
reconnectingPTYListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetReconnectingPTYPort))
if err != nil {
a.logger.Critical(ctx, "listen for reconnecting pty", slog.Error(err))
return
}
go func() {
for {
conn, err := reconnectingPTYListener.Accept()
if err != nil {
a.logger.Debug(ctx, "accept pty failed", slog.Error(err))
return
}
conn = a.stats.wrapConn(conn)
// This cannot use a JSON decoder, since that can
// buffer additional data that is required for the PTY.
rawLen := make([]byte, 2)
_, err = conn.Read(rawLen)
if err != nil {
continue
}
length := binary.LittleEndian.Uint16(rawLen)
data := make([]byte, length)
_, err = conn.Read(data)
if err != nil {
continue
}
var msg codersdk.ReconnectingPTYInit
err = json.Unmarshal(data, &msg)
if err != nil {
continue
}
go a.handleReconnectingPTY(ctx, msg, conn)
}
}()
speedtestListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSpeedtestPort))
if err != nil {
a.logger.Critical(ctx, "listen for speedtest", slog.Error(err))
return
}
go func() {
for {
conn, err := speedtestListener.Accept()
if err != nil {
a.logger.Debug(ctx, "speedtest listener failed", slog.Error(err))
return
}
a.closeMutex.Lock()
a.connCloseWait.Add(1)
a.closeMutex.Unlock()
go func() {
defer a.connCloseWait.Done()
_ = speedtest.ServeConn(conn)
}()
}
}()
}
// runCoordinator listens for nodes and updates the self-node as it changes.
func (a *agent) runCoordinator(ctx context.Context) {
for {
reconnect := a.runCoordinatorWithRetry(ctx)
if !reconnect {
return
}
}
}
func (a *agent) runCoordinatorWithRetry(ctx context.Context) (reconnect bool) {
var coordinator net.Conn
var err error
// An exponential back-off occurs when the connection is failing to dial.
// This is to prevent server spam in case of a coderd outage.
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
coordinator, err = a.coordinatorDialer(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return false
}
if a.isClosed() {
return false
}
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
continue
}
//nolint:revive // Defer is ok because we're exiting this loop.
defer coordinator.Close()
a.logger.Info(context.Background(), "connected to coordination server")
break
}
select {
case <-ctx.Done():
return false
default:
}
sendNodes, errChan := tailnet.ServeCoordinator(coordinator, a.network.UpdateNodes)
a.network.SetNodeCallback(sendNodes)
select {
case <-ctx.Done():
return false
case err := <-errChan:
if a.isClosed() {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
a.logger.Debug(ctx, "node broker accept exited; restarting connection", slog.Error(err))
return true
}
}
@@ -192,7 +322,7 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
return nil
}
writer, err := os.OpenFile(filepath.Join(os.TempDir(), "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0600)
writer, err := os.OpenFile(filepath.Join(os.TempDir(), "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return xerrors.Errorf("open startup script log file: %w", err)
}
@@ -219,42 +349,8 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
return nil
}
func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
go func() {
select {
case <-a.closed:
case <-conn.Closed():
}
_ = conn.Close()
a.connCloseWait.Done()
}()
for {
channel, err := conn.Accept(ctx)
if err != nil {
if errors.Is(err, peer.ErrClosed) || a.isClosed() {
return
}
a.logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
return
}
switch channel.Protocol() {
case ProtocolSSH:
go a.sshServer.HandleConn(channel.NetConn())
case ProtocolReconnectingPTY:
go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn())
case ProtocolDial:
go a.handleDial(ctx, channel.Label(), channel.NetConn())
default:
a.logger.Warn(ctx, "unhandled protocol from channel",
slog.F("protocol", channel.Protocol()),
slog.F("label", channel.Label()),
)
}
}
}
func (a *agent) init(ctx context.Context) {
a.logger.Info(ctx, "generating host key")
// Clients' should ignore the host key when connecting.
// The agent needs to authenticate with coderd to SSH,
// so SSH authentication doesn't improve security.
@@ -321,6 +417,8 @@ func (a *agent) init(ctx context.Context) {
},
SubsystemHandlers: map[string]ssh.SubsystemHandler{
"sftp": func(session ssh.Session) {
session.DisablePTYEmulation()
server, err := sftp.NewServer(session)
if err != nil {
a.logger.Debug(session.Context(), "initialize sftp server", slog.Error(err))
@@ -337,6 +435,21 @@ func (a *agent) init(ctx context.Context) {
}
go a.run(ctx)
if a.statsReporter != nil {
cl, err := a.statsReporter(ctx, a.logger, func() *codersdk.AgentStats {
return a.stats.Copy()
})
if err != nil {
a.logger.Error(ctx, "report stats", slog.Error(err))
return
}
a.connCloseWait.Add(1)
go func() {
defer a.connCloseWait.Done()
<-a.closed
cl.Close()
}()
}
}
// createCommand processes raw command input with OpenSSH-like behavior.
@@ -358,7 +471,7 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
if rawMetadata == nil {
return nil, xerrors.Errorf("no metadata was provided: %w", err)
}
metadata, valid := rawMetadata.(Metadata)
metadata, valid := rawMetadata.(codersdk.WorkspaceAgentMetadata)
if !valid {
return nil, xerrors.Errorf("metadata is the wrong type: %T", metadata)
}
@@ -392,12 +505,25 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
if err != nil {
return nil, xerrors.Errorf("getting os executable: %w", err)
}
// Set environment variables reliable detection of being inside a
// Coder workspace.
cmd.Env = append(cmd.Env, "CODER=true")
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
// Git on Windows resolves with UNIX-style paths.
// If using backslashes, it's unable to find the executable.
unixExecutablePath := strings.ReplaceAll(executablePath, "\\", "/")
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND=%s gitssh --`, unixExecutablePath))
// Set SSH connection environment variables (these are also set by OpenSSH
// and thus expected to be present by SSH clients). Since the agent does
// networking in-memory, trying to provide accurate values here would be
// nonsensical. For now, we hard code these values so that they're present.
srcAddr, srcPort := "0.0.0.0", "0"
dstAddr, dstPort := "0.0.0.0", "0"
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %s %s", srcAddr, srcPort, dstPort))
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", srcAddr, srcPort, dstAddr, dstPort))
// Load environment variables passed via the agent.
// These should override all variables we manually specify.
for envKey, value := range metadata.EnvironmentVariables {
@@ -417,7 +543,8 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
}
func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
ctx := session.Context()
cmd, err := a.createCommand(ctx, session.RawCommand(), session.Environ())
if err != nil {
return err
}
@@ -434,30 +561,34 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
sshPty, windowSize, isPty := session.Pty()
if isPty {
// Disable minimal PTY emulation set by gliderlabs/ssh (NL-to-CRNL).
// See https://github.com/coder/coder/issues/3371.
session.DisablePTYEmulation()
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", sshPty.Term))
ptty, process, err := pty.Start(cmd)
// The pty package sets `SSH_TTY` on supported platforms.
ptty, process, err := pty.Start(cmd, pty.WithPTYOption(
pty.WithSSHRequest(sshPty),
pty.WithLogger(slog.Stdlib(ctx, a.logger, slog.LevelInfo)),
))
if err != nil {
return xerrors.Errorf("start command: %w", err)
}
defer func() {
closeErr := ptty.Close()
if closeErr != nil {
a.logger.Warn(context.Background(), "failed to close tty",
slog.Error(closeErr))
a.logger.Warn(ctx, "failed to close tty", slog.Error(closeErr))
if retErr == nil {
retErr = closeErr
}
}
}()
err = ptty.Resize(uint16(sshPty.Window.Height), uint16(sshPty.Window.Width))
if err != nil {
return xerrors.Errorf("resize ptty: %w", err)
}
go func() {
for win := range windowSize {
resizeErr := ptty.Resize(uint16(win.Height), uint16(win.Width))
if resizeErr != nil {
a.logger.Warn(context.Background(), "failed to resize tty", slog.Error(resizeErr))
a.logger.Warn(ctx, "failed to resize tty", slog.Error(resizeErr))
}
}
}()
@@ -472,8 +603,7 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
// ExitErrors just mean the command we run returned a non-zero exit code, which is normal
// and not something to be concerned about. But, if it's something else, we should log it.
if err != nil && !xerrors.As(err, &exitErr) {
a.logger.Warn(context.Background(), "wait error",
slog.Error(err))
a.logger.Warn(ctx, "wait error", slog.Error(err))
}
return err
}
@@ -497,60 +627,36 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
return cmd.Wait()
}
func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) {
func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.ReconnectingPTYInit, conn net.Conn) {
defer conn.Close()
// The ID format is referenced in conn.go.
// <uuid>:<height>:<width>
idParts := strings.SplitN(rawID, ":", 4)
if len(idParts) != 4 {
a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID))
return
}
id := idParts[0]
// Enforce a consistent format for IDs.
_, err := uuid.Parse(id)
if err != nil {
a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err))
return
}
// Parse the initial terminal dimensions.
height, err := strconv.Atoi(idParts[1])
if err != nil {
a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1]))
return
}
width, err := strconv.Atoi(idParts[2])
if err != nil {
a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2]))
return
}
var rpty *reconnectingPTY
rawRPTY, ok := a.reconnectingPTYs.Load(id)
rawRPTY, ok := a.reconnectingPTYs.Load(msg.ID)
if ok {
rpty, ok = rawRPTY.(*reconnectingPTY)
if !ok {
a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id))
a.logger.Error(ctx, "found invalid type in reconnecting pty map", slog.F("id", msg.ID))
return
}
} else {
// Empty command will default to the users shell!
cmd, err := a.createCommand(ctx, idParts[3], nil)
cmd, err := a.createCommand(ctx, msg.Command, nil)
if err != nil {
a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err))
a.logger.Error(ctx, "create reconnecting pty command", slog.Error(err))
return
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptty, process, err := pty.Start(cmd)
if err != nil {
a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id))
}
// Default to buffer 64KiB.
circularBuffer, err := circbuf.NewBuffer(64 << 10)
if err != nil {
a.logger.Warn(ctx, "create circular buffer", slog.Error(err))
a.logger.Error(ctx, "create circular buffer", slog.Error(err))
return
}
ptty, process, err := pty.Start(cmd)
if err != nil {
a.logger.Error(ctx, "start reconnecting pty command", slog.F("id", msg.ID))
return
}
@@ -565,7 +671,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc),
circularBuffer: circularBuffer,
}
a.reconnectingPTYs.Store(id, rpty)
a.reconnectingPTYs.Store(msg.ID, rpty)
go func() {
// CommandContext isn't respected for Windows PTYs right now,
// so we need to manually track the lifecycle.
@@ -594,7 +700,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
_, err = rpty.circularBuffer.Write(part)
rpty.circularBufferMutex.Unlock()
if err != nil {
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id))
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", msg.ID))
break
}
rpty.activeConnsMutex.Lock()
@@ -608,22 +714,22 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
// ID from memory.
_ = process.Kill()
rpty.Close()
a.reconnectingPTYs.Delete(id)
a.reconnectingPTYs.Delete(msg.ID)
a.connCloseWait.Done()
}()
}
// Resize the PTY to initial height + width.
err = rpty.ptty.Resize(uint16(height), uint16(width))
err := rpty.ptty.Resize(msg.Height, msg.Width)
if err != nil {
// We can continue after this, it's not fatal!
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
}
// Write any previously stored data for the TTY.
rpty.circularBufferMutex.RLock()
_, err = conn.Write(rpty.circularBuffer.Bytes())
rpty.circularBufferMutex.RUnlock()
if err != nil {
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err))
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", msg.ID), slog.Error(err))
return
}
connectionID := uuid.NewString()
@@ -662,19 +768,19 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
rpty.activeConnsMutex.Unlock()
}()
decoder := json.NewDecoder(conn)
var req ReconnectingPTYRequest
var req codersdk.ReconnectingPTYRequest
for {
err = decoder.Decode(&req)
if xerrors.Is(err, io.EOF) {
return
}
if err != nil {
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err))
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", msg.ID), slog.Error(err))
return
}
_, err = rpty.ptty.Input().Write([]byte(req.Data))
if err != nil {
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err))
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
return
}
// Check if a resize needs to happen!
@@ -684,75 +790,11 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
err = rpty.ptty.Resize(req.Height, req.Width)
if err != nil {
// We can continue after this, it's not fatal!
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
}
}
}
// dialResponse is written to datachannels with protocol "dial" by the agent as
// the first packet to signify whether the dial succeeded or failed.
type dialResponse struct {
Error string `json:"error,omitempty"`
}
func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) {
defer conn.Close()
writeError := func(responseError error) error {
msg := ""
if responseError != nil {
msg = responseError.Error()
if !xerrors.Is(responseError, io.EOF) {
a.logger.Warn(ctx, "handle dial", slog.F("label", label), slog.Error(responseError))
}
}
b, err := json.Marshal(dialResponse{
Error: msg,
})
if err != nil {
a.logger.Warn(ctx, "write dial response", slog.F("label", label), slog.Error(err))
return xerrors.Errorf("marshal agent webrtc dial response: %w", err)
}
_, err = conn.Write(b)
return err
}
u, err := url.Parse(label)
if err != nil {
_ = writeError(xerrors.Errorf("parse URL %q: %w", label, err))
return
}
network := u.Scheme
addr := u.Host + u.Path
if strings.HasPrefix(network, "unix") {
if runtime.GOOS == "windows" {
_ = writeError(xerrors.New("Unix forwarding is not supported from Windows workspaces"))
return
}
addr, err = ExpandRelativeHomePath(addr)
if err != nil {
_ = writeError(xerrors.Errorf("expand path %q: %w", addr, err))
return
}
}
d := net.Dialer{Timeout: 3 * time.Second}
nconn, err := d.DialContext(ctx, network, addr)
if err != nil {
_ = writeError(xerrors.Errorf("dial '%v://%v': %w", network, addr, err))
return
}
err = writeError(nil)
if err != nil {
return
}
Bicopy(ctx, conn, nconn)
}
// isClosed returns whether the API is closed or not.
func (a *agent) isClosed() bool {
select {
@@ -771,6 +813,9 @@ func (a *agent) Close() error {
}
close(a.closed)
a.closeCancel()
if a.network != nil {
_ = a.network.Close()
}
_ = a.sshServer.Close()
a.connCloseWait.Wait()
return nil
@@ -795,7 +840,9 @@ func (r *reconnectingPTY) Close() {
_ = conn.Close()
}
_ = r.ptty.Close()
r.circularBufferMutex.Lock()
r.circularBuffer.Reset()
r.circularBufferMutex.Unlock()
r.timeout.Stop()
}
+224 -76
View File
@@ -7,21 +7,23 @@ import (
"fmt"
"io"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"golang.org/x/xerrors"
"tailscale.com/net/speedtest"
scp "github.com/bramvdbogaerde/go-scp"
"github.com/google/uuid"
"github.com/pion/udp"
"github.com/pion/webrtc/v3"
"github.com/pkg/sftp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -33,11 +35,10 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker"
"github.com/coder/coder/peerbroker/proto"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/tailnet"
"github.com/coder/coder/tailnet/tailnettest"
"github.com/coder/coder/testutil"
)
@@ -47,9 +48,55 @@ func TestMain(m *testing.M) {
func TestAgent(t *testing.T) {
t.Parallel()
t.Run("Stats", func(t *testing.T) {
t.Parallel()
t.Run("SSH", func(t *testing.T) {
t.Parallel()
conn, stats := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
sshClient, err := conn.SSHClient()
require.NoError(t, err)
defer sshClient.Close()
session, err := sshClient.NewSession()
require.NoError(t, err)
defer session.Close()
assert.EqualValues(t, 1, (<-stats).NumConns)
assert.Greater(t, (<-stats).RxBytes, int64(0))
assert.Greater(t, (<-stats).TxBytes, int64(0))
})
t.Run("ReconnectingPTY", func(t *testing.T) {
t.Parallel()
conn, stats := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
ptyConn, err := conn.ReconnectingPTY(uuid.NewString(), 128, 128, "/bin/bash")
require.NoError(t, err)
defer ptyConn.Close()
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = ptyConn.Write(data)
require.NoError(t, err)
var s *codersdk.AgentStats
require.Eventuallyf(t, func() bool {
var ok bool
s, ok = (<-stats)
return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0
}, testutil.WaitLong, testutil.IntervalFast,
"never saw stats: %+v", s,
)
})
})
t.Run("SessionExec", func(t *testing.T) {
t.Parallel()
session := setupSSHSession(t, agent.Metadata{})
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "echo test"
if runtime.GOOS == "windows" {
@@ -62,7 +109,7 @@ func TestAgent(t *testing.T) {
t.Run("GitSSH", func(t *testing.T) {
t.Parallel()
session := setupSSHSession(t, agent.Metadata{})
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "sh -c 'echo $GIT_SSH_COMMAND'"
if runtime.GOOS == "windows" {
command = "cmd.exe /c echo %GIT_SSH_COMMAND%"
@@ -80,7 +127,7 @@ func TestAgent(t *testing.T) {
// it seems like it could be either.
t.Skip("ConPTY appears to be inconsistent on Windows.")
}
session := setupSSHSession(t, agent.Metadata{})
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "bash"
if runtime.GOOS == "windows" {
command = "cmd.exe"
@@ -108,7 +155,7 @@ func TestAgent(t *testing.T) {
t.Run("SessionTTYExitCode", func(t *testing.T) {
t.Parallel()
session := setupSSHSession(t, agent.Metadata{})
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "areallynotrealcommand"
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
require.NoError(t, err)
@@ -165,8 +212,10 @@ func TestAgent(t *testing.T) {
t.Run("SFTP", func(t *testing.T) {
t.Parallel()
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
sshClient, err := conn.SSHClient()
require.NoError(t, err)
defer sshClient.Close()
client, err := sftp.NewClient(sshClient)
require.NoError(t, err)
tempFile := filepath.Join(t.TempDir(), "sftp")
@@ -180,8 +229,11 @@ func TestAgent(t *testing.T) {
t.Run("SCP", func(t *testing.T) {
t.Parallel()
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
sshClient, err := conn.SSHClient()
require.NoError(t, err)
defer sshClient.Close()
scpClient, err := scp.NewClientBySSH(sshClient)
require.NoError(t, err)
tempFile := filepath.Join(t.TempDir(), "scp")
@@ -196,7 +248,7 @@ func TestAgent(t *testing.T) {
t.Parallel()
key := "EXAMPLE"
value := "value"
session := setupSSHSession(t, agent.Metadata{
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
EnvironmentVariables: map[string]string{
key: value,
},
@@ -213,7 +265,7 @@ func TestAgent(t *testing.T) {
t.Run("EnvironmentVariableExpansion", func(t *testing.T) {
t.Parallel()
key := "EXAMPLE"
session := setupSSHSession(t, agent.Metadata{
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
EnvironmentVariables: map[string]string{
key: "$SOMETHINGNOTSET",
},
@@ -232,11 +284,54 @@ func TestAgent(t *testing.T) {
require.Equal(t, expect, strings.TrimSpace(string(output)))
})
t.Run("Coder env vars", func(t *testing.T) {
t.Parallel()
for _, key := range []string{"CODER"} {
key := key
t.Run(key, func(t *testing.T) {
t.Parallel()
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "sh -c 'echo $" + key + "'"
if runtime.GOOS == "windows" {
command = "cmd.exe /c echo %" + key + "%"
}
output, err := session.Output(command)
require.NoError(t, err)
require.NotEmpty(t, strings.TrimSpace(string(output)))
})
}
})
t.Run("SSH connection env vars", func(t *testing.T) {
t.Parallel()
// Note: the SSH_TTY environment variable should only be set for TTYs.
// For some reason this test produces a TTY locally and a non-TTY in CI
// so we don't test for the absence of SSH_TTY.
for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT"} {
key := key
t.Run(key, func(t *testing.T) {
t.Parallel()
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
command := "sh -c 'echo $" + key + "'"
if runtime.GOOS == "windows" {
command = "cmd.exe /c echo %" + key + "%"
}
output, err := session.Output(command)
require.NoError(t, err)
require.NotEmpty(t, strings.TrimSpace(string(output)))
})
}
})
t.Run("StartupScript", func(t *testing.T) {
t.Parallel()
tempPath := filepath.Join(t.TempDir(), "content.txt")
content := "somethingnice"
setupAgent(t, agent.Metadata{
setupAgent(t, codersdk.WorkspaceAgentMetadata{
StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath),
}, 0)
@@ -271,7 +366,7 @@ func TestAgent(t *testing.T) {
t.Skip("ConPTY appears to be inconsistent on Windows.")
}
conn := setupAgent(t, agent.Metadata{}, 0)
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
id := uuid.NewString()
netConn, err := conn.ReconnectingPTY(id, 100, 100, "/bin/bash")
require.NoError(t, err)
@@ -281,7 +376,7 @@ func TestAgent(t *testing.T) {
// the shell is simultaneously sending a prompt.
time.Sleep(100 * time.Millisecond)
data, err := json.Marshal(agent.ReconnectingPTYRequest{
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
@@ -347,19 +442,6 @@ func TestAgent(t *testing.T) {
return l
},
},
{
name: "Unix",
setup: func(t *testing.T) net.Listener {
if runtime.GOOS == "windows" {
t.Skip("Unix socket forwarding isn't supported on Windows")
}
tmpDir := t.TempDir()
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
require.NoError(t, err, "create UDP listener")
return l
},
},
}
for _, c := range cases {
@@ -381,8 +463,11 @@ func TestAgent(t *testing.T) {
}
}()
// Dial the listener over WebRTC twice and test out of order
conn := setupAgent(t, agent.Metadata{}, 0)
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
require.Eventually(t, func() bool {
_, err := conn.Ping()
return err == nil
}, testutil.WaitMedium, testutil.IntervalFast)
conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String())
require.NoError(t, err)
defer conn1.Close()
@@ -391,41 +476,32 @@ func TestAgent(t *testing.T) {
defer conn2.Close()
testDial(t, conn2)
testDial(t, conn1)
time.Sleep(150 * time.Millisecond)
})
}
})
t.Run("DialError", func(t *testing.T) {
t.Run("Speedtest", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
// This test uses Unix listeners so we can very easily ensure that
// no other tests decide to listen on the same random port we
// picked.
t.Skip("this test is unsupported on Windows")
return
if testing.Short() {
t.Skip("The minimum duration for a speedtest is hardcoded in Tailscale to 5s!")
}
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
require.NoError(t, err, "create temp dir")
t.Cleanup(func() {
_ = os.RemoveAll(tmpDir)
})
// Try to dial the non-existent Unix socket over WebRTC
conn := setupAgent(t, agent.Metadata{}, 0)
netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock"))
require.Error(t, err)
require.ErrorContains(t, err, "remote dial error")
require.ErrorContains(t, err, "no such file")
require.Nil(t, netConn)
derpMap := tailnettest.RunDERPAndSTUN(t)
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{
DERPMap: derpMap,
}, 0)
defer conn.Close()
res, err := conn.Speedtest(speedtest.Upload, 250*time.Millisecond)
require.NoError(t, err)
t.Logf("%.2f MBits/s", res[len(res)-1].MBitsPerSecond())
})
}
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
agentConn := setupAgent(t, agent.Metadata{}, 0)
agentConn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
waitGroup := sync.WaitGroup{}
go func() {
defer listener.Close()
for {
@@ -434,15 +510,20 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
return
}
ssh, err := agentConn.SSH()
if !assert.NoError(t, err) {
if err != nil {
_ = conn.Close()
return
}
go agent.Bicopy(context.Background(), conn, ssh)
waitGroup.Add(1)
go func() {
agent.Bicopy(context.Background(), conn, ssh)
waitGroup.Done()
}()
}
}()
t.Cleanup(func() {
_ = listener.Close()
waitGroup.Wait()
})
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
require.True(t, valid)
@@ -454,43 +535,110 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
return exec.Command("ssh", args...)
}
func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session {
sshClient, err := setupAgent(t, options, 0).SSHClient()
func setupSSHSession(t *testing.T, options codersdk.WorkspaceAgentMetadata) *ssh.Session {
conn, _ := setupAgent(t, options, 0)
sshClient, err := conn.SSHClient()
require.NoError(t, err)
t.Cleanup(func() {
_ = sshClient.Close()
})
session, err := sshClient.NewSession()
require.NoError(t, err)
return session
}
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
client, server := provisionersdk.TransportPipe()
closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) {
listener, err := peerbroker.Listen(server, nil)
return metadata, listener, err
}, &agent.Options{
type closeFunc func() error
func (c closeFunc) Close() error {
return c()
}
func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeout time.Duration) (
*codersdk.AgentConn,
<-chan *codersdk.AgentStats,
) {
if metadata.DERPMap == nil {
metadata.DERPMap = tailnettest.RunDERPAndSTUN(t)
}
coordinator := tailnet.NewCoordinator()
agentID := uuid.New()
statsCh := make(chan *codersdk.AgentStats)
closer := agent.New(agent.Options{
FetchMetadata: func(ctx context.Context) (codersdk.WorkspaceAgentMetadata, error) {
return metadata, nil
},
CoordinatorDialer: func(ctx context.Context) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
closed := make(chan struct{})
t.Cleanup(func() {
_ = serverConn.Close()
_ = clientConn.Close()
<-closed
})
go func() {
_ = coordinator.ServeAgent(serverConn, agentID)
close(closed)
}()
return clientConn, nil
},
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
ReconnectingPTYTimeout: ptyTimeout,
StatsReporter: func(ctx context.Context, log slog.Logger, statsFn func() *codersdk.AgentStats) (io.Closer, error) {
doneCh := make(chan struct{})
ctx, cancel := context.WithCancel(ctx)
go func() {
defer close(doneCh)
t := time.NewTicker(time.Millisecond * 100)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
select {
case statsCh <- statsFn():
case <-ctx.Done():
return
default:
// We don't want to send old stats.
continue
}
}
}()
return closeFunc(func() error {
cancel()
<-doneCh
close(statsCh)
return nil
}), nil
},
})
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()
_ = closer.Close()
})
api := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
stream, err := api.NegotiateConnection(context.Background())
assert.NoError(t, err)
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{
Logger: slogtest.Make(t, nil),
conn, err := tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
DERPMap: metadata.DERPMap,
Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug),
})
require.NoError(t, err)
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
_ = clientConn.Close()
_ = serverConn.Close()
_ = conn.Close()
})
return &agent.Conn{
Negotiator: api,
Conn: conn,
}
go coordinator.ServeClient(serverConn, uuid.New(), agentID)
sendNode, _ := tailnet.ServeCoordinator(clientConn, func(node []*tailnet.Node) error {
return conn.UpdateNodes(node)
})
conn.SetNodeCallback(sendNode)
return &codersdk.AgentConn{
Conn: conn,
}, statsCh
}
var dialTestPayload = []byte("dean-was-here123")
+184
View File
@@ -0,0 +1,184 @@
package agent
import (
"context"
"net/http"
"sync"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/codersdk"
"github.com/coder/retry"
)
// WorkspaceAgentApps fetches the workspace apps.
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
// PostWorkspaceAgentAppHealth updates the workspace app health.
type PostWorkspaceAgentAppHealth func(context.Context, codersdk.PostWorkspaceAppHealthsRequest) error
// WorkspaceAppHealthReporter is a function that checks and reports the health of the workspace apps until the passed context is canceled.
type WorkspaceAppHealthReporter func(ctx context.Context)
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps WorkspaceAgentApps, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
runHealthcheckLoop := func(ctx context.Context) error {
apps, err := workspaceAgentApps(ctx)
if err != nil {
if xerrors.Is(err, context.Canceled) {
return nil
}
return xerrors.Errorf("getting workspace apps: %w", err)
}
// no need to run this loop if no apps for this workspace.
if len(apps) == 0 {
return nil
}
hasHealthchecksEnabled := false
health := make(map[string]codersdk.WorkspaceAppHealth, 0)
for _, app := range apps {
health[app.Name] = app.Health
if !hasHealthchecksEnabled && app.Health != codersdk.WorkspaceAppHealthDisabled {
hasHealthchecksEnabled = true
}
}
// no need to run this loop if no health checks are configured.
if !hasHealthchecksEnabled {
return nil
}
// run a ticker for each app health check.
var mu sync.RWMutex
failures := make(map[string]int, 0)
for _, nextApp := range apps {
if !shouldStartTicker(nextApp) {
continue
}
app := nextApp
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
go func() {
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
// we set the http timeout to the healthcheck interval to prevent getting too backed up.
client := &http.Client{
Timeout: time.Duration(app.Healthcheck.Interval) * time.Second,
}
err := func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
if err != nil {
return err
}
res, err := client.Do(req)
if err != nil {
return err
}
// successful healthcheck is a non-5XX status code
res.Body.Close()
if res.StatusCode >= http.StatusInternalServerError {
return xerrors.Errorf("error status code: %d", res.StatusCode)
}
return nil
}()
if err != nil {
mu.Lock()
if failures[app.Name] < int(app.Healthcheck.Threshold) {
// increment the failure count and keep status the same.
// we will change it when we hit the threshold.
failures[app.Name]++
} else {
// set to unhealthy if we hit the failure threshold.
// we stop incrementing at the threshold to prevent the failure value from increasing forever.
health[app.Name] = codersdk.WorkspaceAppHealthUnhealthy
}
mu.Unlock()
} else {
mu.Lock()
// we only need one successful health check to be considered healthy.
health[app.Name] = codersdk.WorkspaceAppHealthHealthy
failures[app.Name] = 0
mu.Unlock()
}
t.Reset(time.Duration(app.Healthcheck.Interval))
}
}()
}
mu.Lock()
lastHealth := copyHealth(health)
mu.Unlock()
reportTicker := time.NewTicker(time.Second)
// every second we check if the health values of the apps have changed
// and if there is a change we will report the new values.
for {
select {
case <-ctx.Done():
return nil
case <-reportTicker.C:
mu.RLock()
changed := healthChanged(lastHealth, health)
mu.RUnlock()
if !changed {
continue
}
mu.Lock()
lastHealth = copyHealth(health)
mu.Unlock()
err := postWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
logger.Error(ctx, "failed to report workspace app stat", slog.Error(err))
}
}
}
}
return func(ctx context.Context) {
for r := retry.New(time.Second, 30*time.Second); r.Wait(ctx); {
err := runHealthcheckLoop(ctx)
if err == nil || xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
return
}
logger.Error(ctx, "failed running workspace app reporter", slog.Error(err))
}
}
}
func shouldStartTicker(app codersdk.WorkspaceApp) bool {
return app.Healthcheck.URL != "" && app.Healthcheck.Interval > 0 && app.Healthcheck.Threshold > 0
}
func healthChanged(old map[string]codersdk.WorkspaceAppHealth, new map[string]codersdk.WorkspaceAppHealth) bool {
for name, newValue := range new {
oldValue, found := old[name]
if !found {
return true
}
if newValue != oldValue {
return true
}
}
return false
}
func copyHealth(h1 map[string]codersdk.WorkspaceAppHealth) map[string]codersdk.WorkspaceAppHealth {
h2 := make(map[string]codersdk.WorkspaceAppHealth, 0)
for k, v := range h1 {
h2[k] = v
}
return h2
}
+177
View File
@@ -0,0 +1,177 @@
package agent_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
func TestAppHealth(t *testing.T) {
t.Parallel()
t.Run("Healthy", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
Name: "app1",
Healthcheck: codersdk.Healthcheck{},
Health: codersdk.WorkspaceAppHealthDisabled,
},
{
Name: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
// create a httptest server for us and set it for us.
Interval: 1,
Threshold: 1,
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
handlers := []http.Handler{
nil,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
}
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
apps, err := getApps(ctx)
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health)
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
}, testutil.WaitLong, testutil.IntervalSlow)
})
t.Run("500", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
Name: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
// create a httptest server for us and set it for us.
Interval: 1,
Threshold: 1,
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
handlers := []http.Handler{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
}),
}
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
}, testutil.WaitLong, testutil.IntervalSlow)
})
t.Run("Timeout", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
Name: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
// create a httptest server for us and set it for us.
Interval: 1,
Threshold: 1,
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
handlers := []http.Handler{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// sleep longer than the interval to cause the health check to time out
time.Sleep(2 * time.Second)
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
}
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
}, testutil.WaitLong, testutil.IntervalSlow)
})
}
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
closers := []func(){}
for i, handler := range handlers {
if handler == nil {
continue
}
ts := httptest.NewServer(handler)
app := apps[i]
app.Healthcheck.URL = ts.URL
apps[i] = app
closers = append(closers, ts.Close)
}
var mu sync.Mutex
workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) {
mu.Lock()
defer mu.Unlock()
var newApps []codersdk.WorkspaceApp
return append(newApps, apps...), nil
}
postWorkspaceAgentAppHealth := func(_ context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error {
mu.Lock()
for name, health := range req.Healths {
for i, app := range apps {
if app.Name != name {
continue
}
app.Health = health
apps[i] = app
}
}
mu.Unlock()
return nil
}
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), workspaceAgentApps, postWorkspaceAgentAppHealth)(ctx)
return workspaceAgentApps, func() {
for _, closeFn := range closers {
closeFn()
}
}
}
-118
View File
@@ -1,118 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"net"
"net/url"
"strings"
"golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker/proto"
)
// ReconnectingPTYRequest is sent from the client to the server
// to pipe data to a PTY.
type ReconnectingPTYRequest struct {
Data string `json:"data"`
Height uint16 `json:"height"`
Width uint16 `json:"width"`
}
// Conn wraps a peer connection with helper functions to
// communicate with the agent.
type Conn struct {
// Negotiator is responsible for exchanging messages.
Negotiator proto.DRPCPeerBrokerClient
*peer.Conn
}
// ReconnectingPTY returns a connection serving a TTY that can
// be reconnected to via ID.
//
// The command is optional and defaults to start a shell.
func (c *Conn) ReconnectingPTY(id string, height, width uint16, command string) (net.Conn, error) {
channel, err := c.CreateChannel(context.Background(), fmt.Sprintf("%s:%d:%d:%s", id, height, width, command), &peer.ChannelOptions{
Protocol: ProtocolReconnectingPTY,
})
if err != nil {
return nil, xerrors.Errorf("pty: %w", err)
}
return channel.NetConn(), nil
}
// SSH dials the built-in SSH server.
func (c *Conn) SSH() (net.Conn, error) {
channel, err := c.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{
Protocol: ProtocolSSH,
})
if err != nil {
return nil, xerrors.Errorf("dial: %w", err)
}
return channel.NetConn(), nil
}
// SSHClient calls SSH to create a client that uses a weak cipher
// for high throughput.
func (c *Conn) SSHClient() (*ssh.Client, error) {
netConn, err := c.SSH()
if err != nil {
return nil, xerrors.Errorf("ssh: %w", err)
}
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
// SSH host validation isn't helpful, because obtaining a peer
// connection already signifies user-intent to dial a workspace.
// #nosec
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
return nil, xerrors.Errorf("ssh conn: %w", err)
}
return ssh.NewClient(sshConn, channels, requests), nil
}
// DialContext dials an arbitrary protocol+address from inside the workspace and
// proxies it through the provided net.Conn.
func (c *Conn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
u := &url.URL{
Scheme: network,
}
if strings.HasPrefix(network, "unix") {
u.Path = addr
} else {
u.Host = addr
}
channel, err := c.CreateChannel(ctx, u.String(), &peer.ChannelOptions{
Protocol: ProtocolDial,
Unordered: strings.HasPrefix(network, "udp"),
})
if err != nil {
return nil, xerrors.Errorf("create datachannel: %w", err)
}
// The first message written from the other side is a JSON payload
// containing the dial error.
dec := json.NewDecoder(channel)
var res dialResponse
err = dec.Decode(&res)
if err != nil {
return nil, xerrors.Errorf("decode agent dial response: %w", err)
}
if res.Error != "" {
_ = channel.Close()
return nil, xerrors.Errorf("remote dial error: %v", res.Error)
}
return channel.NetConn(), nil
}
func (c *Conn) Close() error {
_ = c.Negotiator.DRPCConn().Close()
return c.Conn.Close()
}
+1
View File
@@ -29,6 +29,7 @@ func TestReap(t *testing.T) {
// exited processes and passing the PIDs through the shared
// channel.
t.Run("OK", func(t *testing.T) {
t.Parallel()
pids := make(reap.PidCh, 1)
err := reaper.ForkReap(
reaper.WithPIDCallback(pids),
+68
View File
@@ -0,0 +1,68 @@
package agent
import (
"context"
"io"
"net"
"sync/atomic"
"cdr.dev/slog"
"github.com/coder/coder/codersdk"
)
// statsConn wraps a net.Conn with statistics.
type statsConn struct {
*Stats
net.Conn `json:"-"`
}
var _ net.Conn = new(statsConn)
func (c *statsConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
atomic.AddInt64(&c.RxBytes, int64(n))
return n, err
}
func (c *statsConn) Write(b []byte) (n int, err error) {
n, err = c.Conn.Write(b)
atomic.AddInt64(&c.TxBytes, int64(n))
return n, err
}
var _ net.Conn = new(statsConn)
// Stats records the Agent's network connection statistics for use in
// user-facing metrics and debugging.
// Each member value must be written and read with atomic.
type Stats struct {
NumConns int64 `json:"num_comms"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func (s *Stats) Copy() *codersdk.AgentStats {
return &codersdk.AgentStats{
NumConns: atomic.LoadInt64(&s.NumConns),
RxBytes: atomic.LoadInt64(&s.RxBytes),
TxBytes: atomic.LoadInt64(&s.TxBytes),
}
}
// wrapConn returns a new connection that records statistics.
func (s *Stats) wrapConn(conn net.Conn) net.Conn {
atomic.AddInt64(&s.NumConns, 1)
cs := &statsConn{
Stats: s,
Conn: conn,
}
return cs
}
// StatsReporter periodically accept and records agent stats.
type StatsReporter func(
ctx context.Context,
log slog.Logger,
stats func() *codersdk.AgentStats,
) (io.Closer, error)
-97
View File
@@ -1,97 +0,0 @@
package agent
import (
"context"
"net"
"strconv"
"golang.org/x/xerrors"
"inet.af/netaddr"
"cdr.dev/slog"
"github.com/coder/coder/peer/peerwg"
)
func (a *agent) startWireguard(ctx context.Context, addrs []netaddr.IPPrefix) error {
if a.network != nil {
_ = a.network.Close()
a.network = nil
}
// We can't create a wireguard network without these.
if len(addrs) == 0 || a.listenWireguardPeers == nil || a.postKeys == nil {
return xerrors.New("wireguard is enabled, but no addresses were provided or necessary functions were not provided")
}
wg, err := peerwg.New(a.logger.Named("wireguard"), addrs)
if err != nil {
return xerrors.Errorf("create wireguard network: %w", err)
}
// A new keypair is generated on each agent start.
// This keypair must be sent to Coder to allow for incoming connections.
err = a.postKeys(ctx, WireguardPublicKeys{
Public: wg.NodePrivateKey.Public(),
Disco: wg.DiscoPublicKey,
})
if err != nil {
a.logger.Warn(ctx, "post keys", slog.Error(err))
}
go func() {
for {
ch, listenClose, err := a.listenWireguardPeers(ctx, a.logger)
if err != nil {
a.logger.Warn(ctx, "listen wireguard peers", slog.Error(err))
return
}
for {
peer, ok := <-ch
if !ok {
break
}
err := wg.AddPeer(peer)
a.logger.Info(ctx, "added wireguard peer", slog.F("peer", peer.NodePublicKey.ShortString()), slog.Error(err))
}
listenClose()
}
}()
a.startWireguardListeners(ctx, wg, []handlerPort{
{port: 12212, handler: a.sshServer.HandleConn},
})
a.network = wg
return nil
}
type handlerPort struct {
handler func(conn net.Conn)
port uint16
}
func (a *agent) startWireguardListeners(ctx context.Context, network *peerwg.Network, handlers []handlerPort) {
for _, h := range handlers {
go func(h handlerPort) {
a.logger.Debug(ctx, "starting wireguard listener", slog.F("port", h.port))
listener, err := network.Listen("tcp", net.JoinHostPort("", strconv.Itoa(int(h.port))))
if err != nil {
a.logger.Warn(ctx, "listen wireguard", slog.F("port", h.port), slog.Error(err))
return
}
for {
conn, err := listener.Accept()
if err != nil {
return
}
go h.handler(conn)
}
}(h)
}
}
+27 -7
View File
@@ -20,6 +20,7 @@ import (
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/agent"
"github.com/coder/coder/agent/reaper"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/codersdk"
"github.com/coder/retry"
@@ -31,7 +32,6 @@ func workspaceAgent() *cobra.Command {
pprofEnabled bool
pprofAddress string
noReap bool
wireguard bool
)
cmd := &cobra.Command{
Use: "agent",
@@ -73,6 +73,12 @@ func workspaceAgent() *cobra.Command {
return nil
}
version := buildinfo.Version()
logger.Info(cmd.Context(), "starting agent",
slog.F("url", coderURL),
slog.F("auth", auth),
slog.F("version", version),
)
client := codersdk.New(coderURL)
if pprofEnabled {
@@ -138,6 +144,7 @@ func workspaceAgent() *cobra.Command {
}
if exchangeToken != nil {
logger.Info(cmd.Context(), "exchanging identity token")
// Agent's can start before resources are returned from the provisioner
// daemon. If there are many resources being provisioned, this time
// could be significant. This is arbitrarily set at an hour to prevent
@@ -161,6 +168,18 @@ func workspaceAgent() *cobra.Command {
}
}
ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour)
defer cancelFunc()
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
err := client.PostWorkspaceAgentVersion(cmd.Context(), version)
if err != nil {
logger.Warn(cmd.Context(), "post agent version: %w", slog.Error(err), slog.F("version", version))
continue
}
logger.Info(ctx, "updated agent version", slog.F("version", version))
break
}
executablePath, err := os.Executable()
if err != nil {
return xerrors.Errorf("getting os executable: %w", err)
@@ -170,16 +189,18 @@ func workspaceAgent() *cobra.Command {
return xerrors.Errorf("add executable to $PATH: %w", err)
}
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
Logger: logger,
closer := agent.New(agent.Options{
FetchMetadata: client.WorkspaceAgentMetadata,
Logger: logger,
EnvironmentVariables: map[string]string{
// Override the "CODER_AGENT_TOKEN" variable in all
// shells so "gitssh" works!
"CODER_AGENT_TOKEN": client.SessionToken,
},
EnableWireguard: wireguard,
UploadWireguardKeys: client.UploadWorkspaceAgentKeys,
ListenWireguardPeers: client.WireguardPeerListener,
CoordinatorDialer: client.ListenWorkspaceAgentTailnet,
StatsReporter: client.AgentReportStats,
WorkspaceAgentApps: client.WorkspaceAgentApps,
PostWorkspaceAgentAppHealth: client.PostWorkspaceAgentAppHealth,
})
<-cmd.Context().Done()
return closer.Close()
@@ -190,6 +211,5 @@ func workspaceAgent() *cobra.Command {
cliflag.BoolVarP(cmd.Flags(), &pprofEnabled, "pprof-enable", "", "CODER_AGENT_PPROF_ENABLE", false, "Enable serving pprof metrics on the address defined by --pprof-address.")
cliflag.BoolVarP(cmd.Flags(), &noReap, "no-reap", "", "", false, "Do not start a process reaper.")
cliflag.StringVarP(cmd.Flags(), &pprofAddress, "pprof-address", "", "CODER_AGENT_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_AGENT_WIREGUARD", true, "Whether to start the Wireguard interface.")
return cmd
}
+37 -18
View File
@@ -4,12 +4,16 @@ import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/testutil"
)
func TestWorkspaceAgent(t *testing.T) {
@@ -19,8 +23,8 @@ func TestWorkspaceAgent(t *testing.T) {
instanceID := "instanceidentifier"
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
client := coderdtest.New(t, &coderdtest.Options{
AzureCertificates: certificates,
IncludeProvisionerD: true,
AzureCertificates: certificates,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -46,7 +50,7 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
@@ -59,11 +63,16 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer dialer.Close()
_, err = dialer.Ping()
require.NoError(t, err)
require.Eventually(t, func() bool {
_, err := dialer.Ping()
return err == nil
}, testutil.WaitMedium, testutil.IntervalFast)
cancelFunc()
err = <-errC
require.NoError(t, err)
@@ -74,8 +83,8 @@ func TestWorkspaceAgent(t *testing.T) {
instanceID := "instanceidentifier"
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client := coderdtest.New(t, &coderdtest.Options{
AWSCertificates: certificates,
IncludeProvisionerD: true,
AWSCertificates: certificates,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -101,7 +110,7 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
@@ -114,11 +123,16 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer dialer.Close()
_, err = dialer.Ping()
require.NoError(t, err)
require.Eventually(t, func() bool {
_, err := dialer.Ping()
return err == nil
}, testutil.WaitMedium, testutil.IntervalFast)
cancelFunc()
err = <-errC
require.NoError(t, err)
@@ -129,8 +143,8 @@ func TestWorkspaceAgent(t *testing.T) {
instanceID := "instanceidentifier"
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
IncludeProvisionerD: true,
GoogleTokenValidator: validator,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -156,7 +170,7 @@ func TestWorkspaceAgent(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
errC := make(chan error)
@@ -169,11 +183,16 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer dialer.Close()
_, err = dialer.Ping()
require.NoError(t, err)
require.Eventually(t, func() bool {
_, err := dialer.Ping()
return err == nil
}, testutil.WaitMedium, testutil.IntervalFast)
cancelFunc()
err = <-errC
require.NoError(t, err)
+33 -3
View File
@@ -6,8 +6,7 @@
//
// Will produce the following usage docs:
//
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
//
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
package cliflag
import (
@@ -19,6 +18,8 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/coder/coder/cli/cliui"
)
// IsSetBool returns the value of the boolean flag if it is set.
@@ -62,6 +63,18 @@ func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string
flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env))
}
func StringArray(flagset *pflag.FlagSet, name, shorthand, env string, def []string, usage string) {
v, ok := os.LookupEnv(env)
if !ok || v == "" {
if v == "" {
def = []string{}
} else {
def = strings.Split(v, ",")
}
}
flagset.StringArrayP(name, shorthand, def, fmtUsage(usage, env))
}
func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) {
val, ok := os.LookupEnv(env)
if ok {
@@ -91,6 +104,23 @@ func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
}
// IntVarP sets a uint8 flag on the given flag set.
func IntVarP(flagset *pflag.FlagSet, ptr *int, name string, shorthand string, env string, def int, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
vi64, err := strconv.ParseUint(val, 10, 8)
if err != nil {
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
return
}
flagset.IntVarP(ptr, name, shorthand, int(vi64), fmtUsage(usage, env))
}
func Bool(flagset *pflag.FlagSet, name, shorthand, env string, def bool, usage string) {
val, ok := os.LookupEnv(env)
if !ok || val == "" {
@@ -148,7 +178,7 @@ func fmtUsage(u string, env string) string {
if strings.HasSuffix(u, ".") {
dot = ""
}
u = fmt.Sprintf("%s%s\nConsumes $%s", u, dot, env)
u = fmt.Sprintf("%s%s\n"+cliui.Styles.Placeholder.Render("Consumes $%s"), u, dot, env)
}
return u
+43 -3
View File
@@ -14,6 +14,7 @@ import (
)
// Testcliflag cannot run in parallel because it uses t.Setenv.
//
//nolint:paralleltest
func TestCliflag(t *testing.T) {
t.Run("StringDefault", func(t *testing.T) {
@@ -107,7 +108,7 @@ func TestCliflag(t *testing.T) {
require.Equal(t, []string{}, got)
})
t.Run("IntDefault", func(t *testing.T) {
t.Run("UInt8Default", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Int63n(10)
@@ -120,7 +121,7 @@ func TestCliflag(t *testing.T) {
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("IntEnvVar", func(t *testing.T) {
t.Run("UInt8EnvVar", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Int63n(10)
@@ -133,7 +134,7 @@ func TestCliflag(t *testing.T) {
require.Equal(t, uint8(envValue), got)
})
t.Run("IntFailParse", func(t *testing.T) {
t.Run("UInt8FailParse", func(t *testing.T) {
var ptr uint8
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
@@ -146,6 +147,45 @@ func TestCliflag(t *testing.T) {
require.Equal(t, uint8(def), got)
})
t.Run("IntDefault", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
def, _ := cryptorand.Int63n(10)
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(def), got)
require.Contains(t, flagset.FlagUsages(), usage)
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
})
t.Run("IntEnvVar", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.Int63n(10)
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
def, _ := cryptorand.Int()
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, def, usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(envValue), got)
})
t.Run("IntFailParse", func(t *testing.T) {
var ptr int
flagset, name, shorthand, env, usage := randomFlag()
envValue, _ := cryptorand.String(10)
t.Setenv(env, envValue)
def, _ := cryptorand.Int63n(10)
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
got, err := flagset.GetInt(name)
require.NoError(t, err)
require.Equal(t, int(def), got)
})
t.Run("BoolDefault", func(t *testing.T) {
var ptr bool
flagset, name, shorthand, env, usage := randomFlag()
+11 -1
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"errors"
"io"
"io/ioutil"
"os"
"path/filepath"
"testing"
@@ -21,7 +22,13 @@ import (
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
cmd := cli.Root()
return NewWithSubcommands(t, cli.AGPL(), args...)
}
func NewWithSubcommands(
t *testing.T, subcommands []*cobra.Command, args ...string,
) (*cobra.Command, config.Root) {
cmd := cli.Root(subcommands)
dir := t.TempDir()
root := config.Root(dir)
cmd.SetArgs(append([]string{"--global-config", dir}, args...))
@@ -46,6 +53,9 @@ func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
// new temporary testing directory.
func CreateTemplateVersionSource(t *testing.T, responses *echo.Responses) string {
directory := t.TempDir()
f, err := ioutil.TempFile(directory, "*.tf")
require.NoError(t, err)
f.Close()
data, err := echo.Tar(responses)
require.NoError(t, err)
extractTar(t, data, directory)
+1 -1
View File
@@ -79,7 +79,7 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
defer resourceMutex.Unlock()
message := "Don't panic, your workspace is booting up!"
if agent.Status == codersdk.WorkspaceAgentDisconnected {
message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder rebuild "+opts.WorkspaceName)
message = "The workspace agent lost connection! Wait for it to reconnect or restart your workspace."
}
// This saves the cursor position, then defers clearing from the cursor
// position to the end of the screen.
+1 -1
View File
@@ -48,7 +48,7 @@ var Styles = struct {
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
Keyword: defaultStyles.Keyword,
Paragraph: defaultStyles.Paragraph,
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#005fff"}),
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#4d46b3"}),
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
Fuchsia: defaultStyles.SelectedMenuItem.Copy(),
+4 -3
View File
@@ -22,7 +22,7 @@ func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Clie
build, err := client.WorkspaceBuild(ctx, build)
return build.Job, err
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
return client.WorkspaceBuildLogsAfter(ctx, build, before)
},
})
@@ -31,7 +31,7 @@ func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Clie
type ProvisionerJobOptions struct {
Fetch func() (codersdk.ProvisionerJob, error)
Cancel func() error
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
Logs func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error)
FetchInterval time.Duration
// Verbose determines whether debug and trace logs will be shown.
@@ -132,10 +132,11 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
// The initial stage needs to print after the signal handler has been registered.
printStage()
logs, err := opts.Logs()
logs, closer, err := opts.Logs()
if err != nil {
return xerrors.Errorf("logs: %w", err)
}
defer closer.Close()
var (
// logOutput is where log output is written
+11 -2
View File
@@ -2,6 +2,7 @@ package cliui_test
import (
"context"
"io"
"os"
"runtime"
"sync"
@@ -136,8 +137,10 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
Cancel: func() error {
return nil
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return logs, nil
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
return logs, closeFunc(func() error {
return nil
}), nil
},
})
},
@@ -164,3 +167,9 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
PTY: ptty,
}
}
type closeFunc func() error
func (c closeFunc) Close() error {
return c()
}
+38 -13
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/mod/semver"
"github.com/coder/coder/coderd/database"
@@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct {
HideAgentState bool
HideAccess bool
Title string
ServerVersion string
}
// WorkspaceResources displays the connection status and tree-view of provided resources.
@@ -48,6 +50,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
row := table.Row{"Resource"}
if !options.HideAgentState {
row = append(row, "Status")
row = append(row, "Version")
}
if !options.HideAccess {
row = append(row, "Access")
@@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
}
if !options.HideAgentState {
var agentStatus string
var agentVersion string
if !options.HideAgentState {
switch agent.Status {
case codersdk.WorkspaceAgentConnecting:
since := database.Now().Sub(agent.CreatedAt)
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
case codersdk.WorkspaceAgentDisconnected:
since := database.Now().Sub(*agent.DisconnectedAt)
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
case codersdk.WorkspaceAgentConnected:
agentStatus = Styles.Keyword.Render("⦿ connected")
}
agentStatus = renderAgentStatus(agent)
agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
}
row = append(row, agentStatus)
row = append(row, agentStatus, agentVersion)
}
if !options.HideAccess {
sshCommand := "coder ssh " + options.WorkspaceName
@@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
_, err := fmt.Fprintln(writer, tableWriter.Render())
return err
}
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
switch agent.Status {
case codersdk.WorkspaceAgentConnecting:
since := database.Now().Sub(agent.CreatedAt)
return Styles.Warn.Render("⦾ connecting") + " " +
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
case codersdk.WorkspaceAgentDisconnected:
since := database.Now().Sub(*agent.DisconnectedAt)
return Styles.Error.Render("⦾ disconnected") + " " +
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
case codersdk.WorkspaceAgentConnected:
return Styles.Keyword.Render("⦿ connected")
default:
return Styles.Warn.Render("○ unknown")
}
}
func renderAgentVersion(agentVersion, serverVersion string) string {
if agentVersion == "" {
agentVersion = "(unknown)"
}
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
return Styles.Placeholder.Render(agentVersion)
}
outdated := semver.Compare(agentVersion, serverVersion) < 0
if outdated {
return Styles.Warn.Render(agentVersion + " (outdated)")
}
return Styles.Keyword.Render(agentVersion)
}
+50
View File
@@ -0,0 +1,50 @@
package cliui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRenderAgentVersion(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
agentVersion string
serverVersion string
expected string
}{
{
name: "OK",
agentVersion: "v1.2.3",
serverVersion: "v1.2.3",
expected: "v1.2.3",
},
{
name: "Outdated",
agentVersion: "v1.2.3",
serverVersion: "v1.2.4",
expected: "v1.2.3 (outdated)",
},
{
name: "AgentUnknown",
agentVersion: "",
serverVersion: "v1.2.4",
expected: "(unknown)",
},
{
name: "ServerUnknown",
agentVersion: "v1.2.3",
serverVersion: "",
expected: "v1.2.3",
},
}
for _, testCase := range testCases {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion)
assert.Equal(t, testCase.expected, actual)
})
}
}
+264
View File
@@ -1,9 +1,14 @@
package cliui
import (
"fmt"
"reflect"
"strings"
"time"
"github.com/fatih/structtag"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors"
)
// Table creates a new table with standardized styles.
@@ -41,3 +46,262 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
}
return columnConfigs
}
// DisplayTable renders a table as a string. The input argument must be a slice
// of structs. At least one field in the struct must have a `table:""` tag
// containing the name of the column in the outputted table.
//
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
// malformed or a field is marked as recursive but does not contain a struct or
// a pointer to a struct, this function will return an error (even with an empty
// input slice).
//
// If sort is empty, the input order will be used. If filterColumns is empty or
// nil, all available columns are included.
func DisplayTable(out any, sort string, filterColumns []string) (string, error) {
v := reflect.Indirect(reflect.ValueOf(out))
if v.Kind() != reflect.Slice {
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
}
// Get the list of table column headers.
headersRaw, err := typeToTableHeaders(v.Type().Elem())
if err != nil {
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
}
if len(headersRaw) == 0 {
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
}
headers := make(table.Row, len(headersRaw))
for i, header := range headersRaw {
headers[i] = header
}
// Verify that the given sort column and filter columns are valid.
if sort != "" || len(filterColumns) != 0 {
headersMap := make(map[string]string, len(headersRaw))
for _, header := range headersRaw {
headersMap[strings.ToLower(header)] = header
}
if sort != "" {
sort = strings.ToLower(strings.ReplaceAll(sort, "_", " "))
h, ok := headersMap[sort]
if !ok {
return "", xerrors.Errorf(`specified sort column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
}
// Autocorrect
sort = h
}
for i, column := range filterColumns {
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
h, ok := headersMap[column]
if !ok {
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
}
// Autocorrect
filterColumns[i] = h
}
}
// Verify that the given sort column is valid.
if sort != "" {
sort = strings.ReplaceAll(sort, "_", " ")
found := false
for _, header := range headersRaw {
if strings.EqualFold(sort, header) {
found = true
sort = header
break
}
}
if !found {
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
}
}
// Setup the table formatter.
tw := Table()
tw.AppendHeader(headers)
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
if sort != "" {
tw.SortBy([]table.SortBy{{
Name: sort,
}})
}
// Write each struct to the table.
for i := 0; i < v.Len(); i++ {
// Format the row as a slice.
rowMap, err := valueToTableMap(v.Index(i))
if err != nil {
return "", xerrors.Errorf("get table row map %v: %w", i, err)
}
rowSlice := make([]any, len(headers))
for i, h := range headersRaw {
v, ok := rowMap[h]
if !ok {
v = nil
}
// Special type formatting.
switch val := v.(type) {
case time.Time:
v = val.Format(time.Stamp)
case *time.Time:
if val != nil {
v = val.Format(time.Stamp)
}
case fmt.Stringer:
if val != nil {
v = val.String()
}
}
rowSlice[i] = v
}
tw.AppendRow(table.Row(rowSlice))
}
return tw.Render(), nil
}
// parseTableStructTag returns the name of the field according to the `table`
// struct tag. If the table tag does not exist or is "-", an empty string is
// returned. If the table tag is malformed, an error is returned.
//
// The returned name is transformed from "snake_case" to "normal text".
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
tags, err := structtag.Parse(string(field.Tag))
if err != nil {
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
}
tag, err := tags.Get("table")
if err != nil || tag.Name == "-" {
// tags.Get only returns an error if the tag is not found.
return "", false, nil
}
recursive := false
for _, opt := range tag.Options {
if opt == "recursive" {
recursive = true
continue
}
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
}
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
}
func isStructOrStructPointer(t reflect.Type) bool {
return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)
}
// typeToTableHeaders converts a type to a slice of column names. If the given
// type is invalid (not a struct or a pointer to a struct, has invalid table
// tags, etc.), an error is returned.
func typeToTableHeaders(t reflect.Type) ([]string, error) {
if !isStructOrStructPointer(t) {
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
headers := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name, recursive, err := parseTableStructTag(field)
if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
}
if name == "" {
continue
}
fieldType := field.Type
if recursive {
if !isStructOrStructPointer(fieldType) {
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
}
childNames, err := typeToTableHeaders(fieldType)
if err != nil {
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
}
for _, childName := range childNames {
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
}
continue
}
headers = append(headers, name)
}
return headers, nil
}
// valueToTableMap converts a struct to a map of column name to value. If the
// given type is invalid (not a struct or a pointer to a struct, has invalid
// table tags, etc.), an error is returned.
func valueToTableMap(val reflect.Value) (map[string]any, error) {
if !isStructOrStructPointer(val.Type()) {
return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type")
}
if val.Kind() == reflect.Pointer {
if val.IsNil() {
// No data for this struct, so return an empty map. All values will
// be rendered as nil in the resulting table.
return map[string]any{}, nil
}
val = val.Elem()
}
row := map[string]any{}
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
fieldVal := val.Field(i)
name, recursive, err := parseTableStructTag(field)
if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
}
if name == "" {
continue
}
// Recurse if it's a struct.
fieldType := field.Type
if recursive {
if !isStructOrStructPointer(fieldType) {
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
}
// valueToTableMap does nothing on pointers so we don't need to
// filter here.
childMap, err := valueToTableMap(fieldVal)
if err != nil {
return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err)
}
for childName, childValue := range childMap {
row[fmt.Sprintf("%s %s", name, childName)] = childValue
}
continue
}
// Otherwise, we just use the field value.
row[name] = val.Field(i).Interface()
}
return row, nil
}
+352
View File
@@ -0,0 +1,352 @@
package cliui_test
import (
"fmt"
"log"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/cliui"
)
type stringWrapper struct {
str string
}
var _ fmt.Stringer = stringWrapper{}
func (s stringWrapper) String() string {
return s.str
}
type tableTest1 struct {
Name string `table:"name"`
NotIncluded string // no table tag
Age int `table:"age"`
Roles []string `table:"roles"`
Sub1 tableTest2 `table:"sub_1,recursive"`
Sub2 *tableTest2 `table:"sub_2,recursive"`
Sub3 tableTest3 `table:"sub 3,recursive"`
Sub4 tableTest2 `table:"sub 4"` // not recursive
// Types with special formatting.
Time time.Time `table:"time"`
TimePtr *time.Time `table:"time_ptr"`
}
type tableTest2 struct {
Name stringWrapper `table:"name"`
Age int `table:"age"`
NotIncluded string `table:"-"`
}
type tableTest3 struct {
NotIncluded string // no table tag
Sub tableTest2 `table:"inner,recursive"`
}
func Test_DisplayTable(t *testing.T) {
t.Parallel()
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local)
in := []tableTest1{
{
Name: "foo",
Age: 10,
Roles: []string{"a", "b", "c"},
Sub1: tableTest2{
Name: stringWrapper{str: "foo1"},
Age: 11,
},
Sub2: &tableTest2{
Name: stringWrapper{str: "foo2"},
Age: 12,
},
Sub3: tableTest3{
Sub: tableTest2{
Name: stringWrapper{str: "foo3"},
Age: 13,
},
},
Sub4: tableTest2{
Name: stringWrapper{str: "foo4"},
Age: 14,
},
Time: someTime,
TimePtr: &someTime,
},
{
Name: "bar",
Age: 20,
Roles: []string{"a"},
Sub1: tableTest2{
Name: stringWrapper{str: "bar1"},
Age: 21,
},
Sub2: nil,
Sub3: tableTest3{
Sub: tableTest2{
Name: stringWrapper{str: "bar3"},
Age: 23,
},
},
Sub4: tableTest2{
Name: stringWrapper{str: "bar4"},
Age: 24,
},
Time: someTime,
TimePtr: nil,
},
{
Name: "baz",
Age: 30,
Roles: nil,
Sub1: tableTest2{
Name: stringWrapper{str: "baz1"},
Age: 31,
},
Sub2: nil,
Sub3: tableTest3{
Sub: tableTest2{
Name: stringWrapper{str: "baz3"},
Age: 33,
},
},
Sub4: tableTest2{
Name: stringWrapper{str: "baz4"},
Age: 34,
},
Time: someTime,
TimePtr: nil,
},
}
// This test tests skipping fields without table tags, recursion, pointer
// dereferencing, and nil pointer skipping.
t.Run("OK", func(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
`
// Test with non-pointer values.
out, err := cliui.DisplayTable(in, "", nil)
log.Println("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
// Test with pointer values.
inPtr := make([]*tableTest1, len(in))
for i, v := range in {
v := v
inPtr[i] = &v
}
out, err = cliui.DisplayTable(inPtr, "", nil)
require.NoError(t, err)
compareTables(t, expected, out)
})
t.Run("Sort", func(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
`
out, err := cliui.DisplayTable(in, "name", nil)
log.Println("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
})
t.Run("Filter", func(t *testing.T) {
t.Parallel()
expected := `
NAME SUB 1 NAME SUB 3 INNER NAME TIME
foo foo1 foo3 Aug 2 15:49:10
bar bar1 bar3 Aug 2 15:49:10
baz baz1 baz3 Aug 2 15:49:10
`
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
log.Println("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
})
// This test ensures that safeties against invalid use of `table` tags
// causes errors (even without data).
t.Run("Errors", func(t *testing.T) {
t.Parallel()
t.Run("NotSlice", func(t *testing.T) {
t.Parallel()
var in string
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("BadSortColumn", func(t *testing.T) {
t.Parallel()
_, err := cliui.DisplayTable(in, "bad_column_does_not_exist", nil)
require.Error(t, err)
})
t.Run("BadFilterColumns", func(t *testing.T) {
t.Parallel()
_, err := cliui.DisplayTable(in, "", []string{"name", "bad_column_does_not_exist"})
require.Error(t, err)
})
t.Run("Interfaces", func(t *testing.T) {
t.Parallel()
t.Run("WithoutData", func(t *testing.T) {
t.Parallel()
var in []any
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []any{tableTest1{}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
t.Run("NotStruct", func(t *testing.T) {
t.Parallel()
t.Run("WithoutData", func(t *testing.T) {
t.Parallel()
var in []string
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []string{"foo", "bar", "baz"}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
t.Run("NoTableTags", func(t *testing.T) {
t.Parallel()
type noTableTagsTest struct {
Field string `json:"field"`
}
t.Run("WithoutData", func(t *testing.T) {
t.Parallel()
var in []noTableTagsTest
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []noTableTagsTest{{Field: "hi"}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
t.Run("InvalidTag/NoName", func(t *testing.T) {
t.Parallel()
type noNameTest struct {
Field string `table:""`
}
t.Run("WithoutData", func(t *testing.T) {
t.Parallel()
var in []noNameTest
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []noNameTest{{Field: "test"}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
t.Run("InvalidTag/BadSyntax", func(t *testing.T) {
t.Parallel()
type invalidSyntaxTest struct {
Field string `table:"asda,asdjada"`
}
t.Run("WithoutData", func(t *testing.T) {
t.Parallel()
var in []invalidSyntaxTest
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []invalidSyntaxTest{{Field: "test"}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
})
})
}
// compareTables normalizes the incoming table lines
func compareTables(t *testing.T, expected, out string) {
t.Helper()
expectedLines := strings.Split(strings.TrimSpace(expected), "\n")
gotLines := strings.Split(strings.TrimSpace(out), "\n")
assert.Equal(t, len(expectedLines), len(gotLines), "expected line count does not match generated line count")
// Map the expected and got lines to normalize them.
expectedNormalized := make([]string, len(expectedLines))
gotNormalized := make([]string, len(gotLines))
normalizeLine := func(s string) string {
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
}
for i, s := range expectedLines {
expectedNormalized[i] = normalizeLine(s)
}
for i, s := range gotLines {
gotNormalized[i] = normalizeLine(s)
}
require.Equal(t, expectedNormalized, gotNormalized, "expected lines to match generated lines")
}
+104 -77
View File
@@ -137,15 +137,13 @@ func configSSH() *cobra.Command {
sshConfigFile string
sshConfigOpts sshConfigOptions
usePreviousOpts bool
coderConfigFile string
dryRun bool
skipProxyCommand bool
wireguard bool
)
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "config-ssh",
Short: "Populate your SSH config with Host entries for all of your workspaces",
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
Example: formatExamples(
example{
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
@@ -158,7 +156,7 @@ func configSSH() *cobra.Command {
),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -171,10 +169,20 @@ func configSSH() *cobra.Command {
// that it's possible to capture the diff.
out = cmd.OutOrStderr()
}
binaryFile, err := currentBinPath(out)
coderBinary, err := currentBinPath(out)
if err != nil {
return err
}
escapedCoderBinary, err := sshConfigExecEscape(coderBinary)
if err != nil {
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
}
root := createConfig(cmd)
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
if err != nil {
return xerrors.Errorf("escape global config for ssh failed: %w", err)
}
homedir, err := os.UserHomeDir()
if err != nil {
@@ -198,15 +206,7 @@ func configSSH() *cobra.Command {
// Parse the previous configuration only if config-ssh
// has been run previously.
var lastConfig *sshConfigOptions
var ok bool
var coderConfigRaw []byte
if coderConfigFile, coderConfigRaw, ok = readDeprecatedCoderConfigFile(homedir, coderConfigFile); ok {
// Deprecated: Remove after migration period.
changes = append(changes, fmt.Sprintf("Remove old auto-generated coder config file at %s", coderConfigFile))
// Backwards compate, restore old options.
c := sshConfigParseLastOptions(bytes.NewReader(coderConfigRaw))
lastConfig = &c
} else if section, ok := sshConfigGetCoderSection(configRaw); ok {
if section, ok := sshConfigGetCoderSection(configRaw); ok {
c := sshConfigParseLastOptions(bytes.NewReader(section))
lastConfig = &c
}
@@ -237,6 +237,8 @@ func configSSH() *cobra.Command {
}
// Selecting "no" will use the last config.
sshConfigOpts = *lastConfig
} else {
changes = append(changes, "Use new SSH options")
}
// Only print when prompts are shown.
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
@@ -246,15 +248,6 @@ func configSSH() *cobra.Command {
configModified := configRaw
// Check for the presence of the coder Include
// statement is present and add if missing.
// Deprecated: Remove after migration period.
if configModified, ok = removeDeprecatedSSHIncludeStatement(configModified); ok {
changes = append(changes, fmt.Sprintf("Remove %q from %s", "Include coder", sshConfigFile))
}
root := createConfig(cmd)
buf := &bytes.Buffer{}
before, after := sshConfigSplitOnCoderSection(configModified)
// Write the first half of the users config file to buf.
@@ -295,11 +288,13 @@ func configSSH() *cobra.Command {
"\tLogLevel ERROR",
)
if !skipProxyCommand {
if !wireguard {
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
} else {
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --wireguard --stdio %s", binaryFile, root, hostname))
}
configOptions = append(
configOptions,
fmt.Sprintf(
"\tProxyCommand %s --global-config %s ssh --stdio %s",
escapedCoderBinary, escapedGlobalConfig, hostname,
),
)
}
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
@@ -313,17 +308,34 @@ func configSSH() *cobra.Command {
_, _ = buf.Write(after)
if !bytes.Equal(configModified, buf.Bytes()) {
changes = append(changes, fmt.Sprintf("Update coder config section in %s", sshConfigFile))
changes = append(changes, fmt.Sprintf("Update the coder section in %s", sshConfigFile))
configModified = buf.Bytes()
}
if len(changes) > 0 {
dryRunDisclaimer := ""
if dryRun {
dryRunDisclaimer = " (dry-run, no changes will be made)"
if len(changes) == 0 {
_, _ = fmt.Fprintf(out, "No changes to make.\n")
return nil
}
if dryRun {
_, _ = fmt.Fprintf(out, "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n", strings.Join(changes, "\n * "))
color := isTTYOut(cmd)
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
if err != nil {
return xerrors.Errorf("diff failed: %w", err)
}
if len(diff) > 0 {
// Write diff to stdout.
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s", diff)
}
return nil
}
if len(changes) > 0 {
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?%s", strings.Join(changes, "\n * "), dryRunDisclaimer),
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
IsConfirm: true,
})
if err != nil {
@@ -335,47 +347,18 @@ func configSSH() *cobra.Command {
}
}
if dryRun {
color := isTTYOut(cmd)
diffFns := []func() ([]byte, error){
func() ([]byte, error) { return diffBytes(sshConfigFile, configRaw, configModified, color) },
}
if len(coderConfigRaw) > 0 {
// Deprecated: Remove after migration period.
diffFns = append(diffFns, func() ([]byte, error) { return diffBytes(coderConfigFile, coderConfigRaw, nil, color) })
}
for _, diffFn := range diffFns {
diff, err := diffFn()
if err != nil {
return xerrors.Errorf("diff failed: %w", err)
}
if len(diff) > 0 {
// Write diff to stdout.
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n%s", diff)
}
}
} else {
if !bytes.Equal(configRaw, configModified) {
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
if err != nil {
return xerrors.Errorf("write ssh config failed: %w", err)
}
}
// Deprecated: Remove after migration period.
if len(coderConfigRaw) > 0 {
err = os.Remove(coderConfigFile)
if err != nil {
return xerrors.Errorf("remove coder config failed: %w", err)
}
if !bytes.Equal(configRaw, configModified) {
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
if err != nil {
return xerrors.Errorf("write ssh config failed: %w", err)
}
}
if len(workspaceConfigs) > 0 {
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n\n", workspaceConfigs[0].Name)
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name)
} else {
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n\n")
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
}
return nil
},
@@ -386,13 +369,6 @@ func configSSH() *cobra.Command {
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
_ = cmd.Flags().MarkHidden("skip-proxy-command")
cliflag.BoolVarP(cmd.Flags(), &usePreviousOpts, "use-previous-options", "", "CODER_SSH_USE_PREVIOUS_OPTIONS", false, "Specifies whether or not to keep options from previous run of config-ssh.")
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_CONFIG_SSH_WIREGUARD", false, "Whether to use Wireguard for SSH tunneling.")
_ = cmd.Flags().MarkHidden("wireguard")
// Deprecated: Remove after migration period.
cmd.Flags().StringVar(&coderConfigFile, "test.ssh-coder-config-file", sshDefaultCoderConfigFileName, "Specifies the path to an Coder SSH config file. Useful for testing.")
_ = cmd.Flags().MarkHidden("test.ssh-coder-config-file")
cliui.AllowSkipPrompt(cmd)
return cmd
@@ -482,6 +458,11 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
dir := filepath.Dir(path)
name := filepath.Base(path)
// Ensure that e.g. the ~/.ssh directory exists.
if err = os.MkdirAll(dir, 0o700); err != nil {
return xerrors.Errorf("create directory: %w", err)
}
// Create a tempfile in the same directory for ensuring write
// operation does not fail.
f, err := os.CreateTemp(dir, fmt.Sprintf(".%s.", name))
@@ -513,6 +494,52 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
return nil
}
// sshConfigExecEscape quotes the string if it contains spaces, as per
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
// run the command, and as such the formatting/escape requirements
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
//
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
// on most platforms, but double quotes sometimes break on Windows 10
// (see #2853). This function takes a best-effort approach to improving
// compatibility and covering edge cases.
//
// Given the following ProxyCommand:
//
// ProxyCommand "/path/with space/coder" ssh --stdio work
//
// This is ~what OpenSSH would execute:
//
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
//
// However, since it's actually an arg in C, the contents inside the
// single quotes are interpreted as is, e.g. if there was a '\t', it
// would be the literal string '\t', not a tab.
//
// See:
// - https://github.com/coder/coder/issues/2853
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/sshconnect.c#L231-L293
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/contrib/win32/win32compat/w32fd.c#L1075-L1100
func sshConfigExecEscape(path string) (string, error) {
// This is unlikely to ever happen, but newlines are allowed on
// certain filesystems, but cannot be used inside ssh config.
if strings.ContainsAny(path, "\n") {
return "", xerrors.Errorf("invalid path: %s", path)
}
// In the unlikely even that a path contains quotes, they must be
// escaped so that they are not interpreted as shell quotes.
if strings.Contains(path, "\"") {
path = strings.ReplaceAll(path, "\"", "\\\"")
}
// A space or a tab requires quoting, but tabs must not be escaped
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
if strings.ContainsAny(path, " \t") {
path = fmt.Sprintf("\"%s\"", path) //nolint:gocritic // We don't want %q here.
}
return path, nil
}
// currentBinPath returns the path to the coder binary suitable for use in ssh
// ProxyCommand.
func currentBinPath(w io.Writer) (string, error) {
@@ -558,7 +585,7 @@ func currentBinPath(w io.Writer) (string, error) {
// diffBytes takes two byte slices and diffs them as if they were in a
// file named name.
//nolint: revive // Color is an option, not a control coupling.
// nolint: revive // Color is an option, not a control coupling.
func diffBytes(name string, b1, b2 []byte, color bool) ([]byte, error) {
var buf bytes.Buffer
var opts []write.Option
+62
View File
@@ -0,0 +1,62 @@
package cli
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// This test tries to mimic the behavior of OpenSSH
// when executing e.g. a ProxyCommand.
func Test_sshConfigExecEscape(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
windows bool
}{
{"no spaces", "simple", false, true},
{"spaces", "path with spaces", false, true},
{"quotes", "path with \"quotes\"", false, false},
{"backslashes", "path with \\backslashes", false, false},
{"tabs", "path with \ttabs", false, false},
{"newline fails", "path with \nnewline", true, false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.")
}
dir := filepath.Join(t.TempDir(), tt.path)
err := os.MkdirAll(dir, 0o755)
require.NoError(t, err)
bin := filepath.Join(dir, "coder")
contents := []byte("#!/bin/sh\necho yay\n")
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
require.NoError(t, err)
escaped, err := sshConfigExecEscape(bin)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
b, err := exec.Command("/bin/sh", "-c", escaped).CombinedOutput() //nolint:gosec
require.NoError(t, err)
got := strings.TrimSpace(string(b))
require.Equal(t, "yay", got)
})
}
}
-66
View File
@@ -1,66 +0,0 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
)
// This file contains config-ssh definitions that are deprecated, they
// will be removed after a migratory period.
const (
sshDefaultCoderConfigFileName = "~/.ssh/coder"
sshCoderConfigHeader = "# This file is managed by coder. DO NOT EDIT."
)
// Regular expressions are used because SSH configs do not have
// meaningful indentation and keywords are case-insensitive.
var (
// Find the semantically correct include statement. Since the user can
// modify their configuration as they see fit, there could be:
// - Leading indentation (space, tab)
// - Trailing indentation (space, tab)
// - Select newline after Include statement for cleaner removal
// In the following cases, we will not recognize the Include statement
// and leave as-is (i.e. they're not supported):
// - User adds another file to the Include statement
// - User adds a comment on the same line as the Include statement
sshCoderIncludedRe = regexp.MustCompile(`(?m)^[\t ]*((?i)Include) coder[\t ]*[\r]?[\n]?$`)
)
// removeDeprecatedSSHIncludeStatement checks for the Include coder statement
// and returns modified = true if it was removed.
func removeDeprecatedSSHIncludeStatement(data []byte) (modifiedData []byte, modified bool) {
coderInclude := sshCoderIncludedRe.FindIndex(data)
if coderInclude == nil {
return data, false
}
// Remove Include statement.
d := append([]byte{}, data[:coderInclude[0]]...)
d = append(d, data[coderInclude[1]:]...)
data = d
return data, true
}
// readDeprecatedCoderConfigFile reads the deprecated split config file.
func readDeprecatedCoderConfigFile(homedir, coderConfigFile string) (name string, data []byte, ok bool) {
if strings.HasPrefix(coderConfigFile, "~/") {
coderConfigFile = filepath.Join(homedir, coderConfigFile[2:])
}
b, err := os.ReadFile(coderConfigFile)
if err != nil {
return coderConfigFile, nil, false
}
if len(b) > 0 {
if !bytes.HasPrefix(b, []byte(sshCoderConfigHeader)) {
return coderConfigFile, nil, false
}
}
return coderConfigFile, b, true
}
+35 -143
View File
@@ -6,19 +6,20 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
@@ -30,15 +31,14 @@ import (
"github.com/coder/coder/pty/ptytest"
)
func sshConfigFileNames(t *testing.T) (sshConfig string, coderConfig string) {
func sshConfigFileName(t *testing.T) (sshConfig string) {
t.Helper()
tmpdir := t.TempDir()
dotssh := filepath.Join(tmpdir, ".ssh")
err := os.Mkdir(dotssh, 0o700)
require.NoError(t, err)
n1 := filepath.Join(dotssh, "config")
n2 := filepath.Join(dotssh, "coder")
return n1, n2
n := filepath.Join(dotssh, "config")
return n
}
func sshConfigFileCreate(t *testing.T, name string, data io.Reader) {
@@ -63,7 +63,7 @@ func sshConfigFileRead(t *testing.T, name string) string {
func TestConfigSSH(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -106,14 +106,16 @@ func TestConfigSSH(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil),
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer func() {
_ = agentCloser.Close()
}()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer agentConn.Close()
@@ -122,20 +124,31 @@ func TestConfigSSH(t *testing.T) {
defer func() {
_ = listener.Close()
}()
copyDone := make(chan struct{})
go func() {
defer close(copyDone)
var wg sync.WaitGroup
for {
conn, err := listener.Accept()
if err != nil {
return
break
}
ssh, err := agentConn.SSH()
assert.NoError(t, err)
go io.Copy(conn, ssh)
go io.Copy(ssh, conn)
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(conn, ssh)
}()
go func() {
defer wg.Done()
_, _ = io.Copy(ssh, conn)
}()
}
wg.Wait()
}()
sshConfigFile, _ := sshConfigFileNames(t)
sshConfigFile := sshConfigFileName(t)
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
require.True(t, valid)
@@ -177,6 +190,9 @@ func TestConfigSSH(t *testing.T) {
data, err := sshCmd.Output()
require.NoError(t, err)
require.Equal(t, "test", strings.TrimSpace(string(data)))
_ = listener.Close()
<-copyDone
}
func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
@@ -197,12 +213,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
}, "\n")
type writeConfig struct {
ssh string
coder string
ssh string
}
type wantConfig struct {
ssh string
coderKept bool
ssh string
}
type match struct {
match, write string
@@ -514,120 +528,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
"--yes",
},
},
// Tests for deprecated split coder config.
{
name: "Do not overwrite unknown coder config",
writeConfig: writeConfig{
ssh: strings.Join([]string{
baseHeader,
"",
}, "\n"),
coder: strings.Join([]string{
"We're no strangers to love",
"You know the rules and so do I (do I)",
}, "\n"),
},
wantConfig: wantConfig{
coderKept: true,
},
},
{
name: "Transfer options from coder to ssh config",
writeConfig: writeConfig{
ssh: strings.Join([]string{
"Include coder",
"",
}, "\n"),
coder: strings.Join([]string{
"# This file is managed by coder. DO NOT EDIT.",
"#",
"# You should not hand-edit this file, all changes will be lost when running",
"# \"coder config-ssh\".",
"#",
"# Last config-ssh options:",
"# :ssh-option=ForwardAgent=yes",
"#",
}, "\n"),
},
wantConfig: wantConfig{
ssh: strings.Join([]string{
headerStart,
"# Last config-ssh options:",
"# :ssh-option=ForwardAgent=yes",
"#",
headerEnd,
"",
}, "\n"),
},
matches: []match{
{match: "Use new options?", write: "no"},
{match: "Continue?", write: "yes"},
},
},
{
name: "Allow overwriting previous options from coder config",
writeConfig: writeConfig{
ssh: strings.Join([]string{
"Include coder",
"",
}, "\n"),
coder: strings.Join([]string{
"# This file is managed by coder. DO NOT EDIT.",
"#",
"# You should not hand-edit this file, all changes will be lost when running",
"# \"coder config-ssh\".",
"#",
"# Last config-ssh options:",
"# :ssh-option=ForwardAgent=yes",
"#",
}, "\n"),
},
wantConfig: wantConfig{
ssh: strings.Join([]string{
baseHeader,
"",
}, "\n"),
},
matches: []match{
{match: "Use new options?", write: "yes"},
{match: "Continue?", write: "yes"},
},
},
{
name: "Allow overwriting previous options from coder config when they differ",
writeConfig: writeConfig{
ssh: strings.Join([]string{
"Include coder",
"",
}, "\n"),
coder: strings.Join([]string{
"# This file is managed by coder. DO NOT EDIT.",
"#",
"# You should not hand-edit this file, all changes will be lost when running",
"# \"coder config-ssh\".",
"#",
"# Last config-ssh options:",
"# :ssh-option=ForwardAgent=yes",
"#",
}, "\n"),
},
wantConfig: wantConfig{
ssh: strings.Join([]string{
headerStart,
"# Last config-ssh options:",
"# :ssh-option=ForwardAgent=no",
"#",
headerEnd,
"",
}, "\n"),
},
args: []string{"--ssh-option", "ForwardAgent=no"},
matches: []match{
{match: "Use new options?", write: "yes"},
{match: "Continue?", write: "yes"},
},
},
}
for _, tt := range tests {
tt := tt
@@ -635,7 +535,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -645,18 +545,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
)
// Prepare ssh config files.
sshConfigName, coderConfigName := sshConfigFileNames(t)
sshConfigName := sshConfigFileName(t)
if tt.writeConfig.ssh != "" {
sshConfigFileCreate(t, sshConfigName, strings.NewReader(tt.writeConfig.ssh))
}
if tt.writeConfig.coder != "" {
sshConfigFileCreate(t, coderConfigName, strings.NewReader(tt.writeConfig.coder))
}
args := []string{
"config-ssh",
"--ssh-config-file", sshConfigName,
"--test.ssh-coder-config-file", coderConfigName,
}
args = append(args, tt.args...)
cmd, root := clitest.New(t, args...)
@@ -685,10 +581,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
got := sshConfigFileRead(t, sshConfigName)
assert.Equal(t, tt.wantConfig.ssh, got)
}
if !tt.wantConfig.coderKept {
_, err := os.ReadFile(coderConfigName)
assert.ErrorIs(t, err, fs.ErrNotExist)
}
})
}
}
@@ -765,7 +657,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
},
}}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
// authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -778,7 +670,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
sshConfigFile, _ := sshConfigFileNames(t)
sshConfigFile := sshConfigFileName(t)
cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
clitest.SetupConfig(t, client, root)
+13 -12
View File
@@ -2,6 +2,7 @@ package cli
import (
"fmt"
"io"
"time"
"github.com/spf13/cobra"
@@ -25,9 +26,9 @@ func create() *cobra.Command {
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "create [name]",
Short: "Create a workspace from a template",
Short: "Create a workspace",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -72,7 +73,7 @@ func create() *cobra.Command {
}
slices.SortFunc(templates, func(a, b codersdk.Template) bool {
return a.WorkspaceOwnerCount > b.WorkspaceOwnerCount
return a.ActiveUserCount > b.ActiveUserCount
})
templateNames := make([]string, 0, len(templates))
@@ -81,13 +82,13 @@ func create() *cobra.Command {
for _, template := range templates {
templateName := template.Name
if template.WorkspaceOwnerCount > 0 {
developerText := "developer"
if template.WorkspaceOwnerCount != 1 {
developerText = "developers"
}
templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText))
if template.ActiveUserCount > 0 {
templateName += cliui.Styles.Placeholder.Render(
fmt.Sprintf(
" (used by %s)",
formatActiveDevelopers(template.ActiveUserCount),
),
)
}
templateNames = append(templateNames, templateName)
@@ -139,7 +140,7 @@ func create() *cobra.Command {
}
after := time.Now()
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: workspaceName,
AutostartSchedule: schedSpec,
@@ -253,7 +254,7 @@ PromptParamLoop:
Cancel: func() error {
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, after)
},
// Don't show log output for the dry-run unless there's an error.
+7 -7
View File
@@ -23,7 +23,7 @@ func TestCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
@@ -80,7 +80,7 @@ func TestCreate(t *testing.T) {
t.Run("CreateFromListWithSkip", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -102,7 +102,7 @@ func TestCreate(t *testing.T) {
t.Run("FromNothing", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -139,7 +139,7 @@ func TestCreate(t *testing.T) {
t.Run("WithParameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
defaultValue := "something"
@@ -180,7 +180,7 @@ func TestCreate(t *testing.T) {
t.Run("WithParameterFileContainingTheValue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
defaultValue := "something"
@@ -223,7 +223,7 @@ func TestCreate(t *testing.T) {
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
defaultValue := "something"
@@ -254,7 +254,7 @@ func TestCreate(t *testing.T) {
t.Run("FailedDryRun", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: []*proto.Parse_Response{{
+19 -2
View File
@@ -12,6 +12,7 @@ import (
// nolint
func deleteWorkspace() *cobra.Command {
var orphan bool
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "delete <workspace>",
@@ -28,7 +29,7 @@ func deleteWorkspace() *cobra.Command {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -36,9 +37,21 @@ func deleteWorkspace() *cobra.Command {
if err != nil {
return err
}
var state []byte
if orphan {
cliui.Warn(
cmd.ErrOrStderr(),
"Orphaning workspace requires template edit permission",
)
}
before := time.Now()
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
Transition: codersdk.WorkspaceTransitionDelete,
ProvisionerState: state,
Orphan: orphan,
})
if err != nil {
return err
@@ -53,6 +66,10 @@ func deleteWorkspace() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&orphan, "orphan", false,
`Delete a workspace without deleting its resources. This can delete a
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
)
cliui.AllowSkipPrompt(cmd)
return cmd
}
+32 -2
View File
@@ -15,9 +15,10 @@ import (
)
func TestDelete(t *testing.T) {
t.Parallel()
t.Run("WithParameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -42,9 +43,38 @@ func TestDelete(t *testing.T) {
<-doneChan
})
t.Run("Orphan", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
// When running with the race detector on, we sometimes get an EOF.
if err != nil {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("Cleaning Up")
<-doneChan
})
t.Run("DifferentUser", func(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
adminUser := coderdtest.CreateFirstUser(t, adminClient)
orgID := adminUser.OrganizationID
client := coderdtest.CreateAnotherUser(t, adminClient, orgID)
+1 -1
View File
@@ -22,7 +22,7 @@ func dotfiles() *cobra.Command {
cmd := &cobra.Command{
Use: "dotfiles [git_repo_url]",
Args: cobra.ExactArgs(1),
Short: "Check out and install a dotfiles repository.",
Short: "Checkout and install a dotfiles repository from a Git URL",
Example: formatExamples(
example{
Description: "Check out and install a dotfiles repository without prompts",
+121 -4
View File
@@ -1,9 +1,15 @@
package cli
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"github.com/spf13/cobra"
@@ -13,16 +19,30 @@ import (
)
func gitssh() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "gitssh",
Hidden: true,
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
env := os.Environ()
// Catch interrupt signals to ensure the temporary private
// key file is cleaned up on most cases.
ctx, stop := signal.NotifyContext(ctx, interruptSignals...)
defer stop()
// Early check so errors are reported immediately.
identityFiles, err := parseIdentityFilesForHost(ctx, args, env)
if err != nil {
return err
}
client, err := createAgentClient(cmd)
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
key, err := client.AgentGitSSHKey(cmd.Context())
key, err := client.AgentGitSSHKey(ctx)
if err != nil {
return xerrors.Errorf("get agent git ssh token: %w", err)
}
@@ -44,8 +64,23 @@ func gitssh() *cobra.Command {
return xerrors.Errorf("close temp gitsshkey file: %w", err)
}
args = append([]string{"-i", privateKeyFile.Name()}, args...)
c := exec.CommandContext(cmd.Context(), "ssh", args...)
// Append our key, giving precedence to user keys. Note that
// OpenSSH server are typically configured with MaxAuthTries
// set to the default value of 6. This means that only the 6
// first keys can be tried. However, we will assume that if
// a user has configured 6+ keys for a host, they know what
// they're doing. This behavior is critical if a server has
// been configured with MaxAuthTries set to 1.
identityFiles = append(identityFiles, privateKeyFile.Name())
var identityArgs []string
for _, id := range identityFiles {
identityArgs = append(identityArgs, "-i", id)
}
args = append(identityArgs, args...)
c := exec.CommandContext(ctx, "ssh", args...)
c.Env = append(c.Env, env...)
c.Stderr = cmd.ErrOrStderr()
c.Stdout = cmd.OutOrStdout()
c.Stdin = cmd.InOrStdin()
@@ -69,4 +104,86 @@ func gitssh() *cobra.Command {
return nil
},
}
return cmd
}
// fallbackIdentityFiles is the list of identity files SSH tries when
// none have been defined for a host.
var fallbackIdentityFiles = strings.Join([]string{
"identityfile ~/.ssh/id_rsa",
"identityfile ~/.ssh/id_dsa",
"identityfile ~/.ssh/id_ecdsa",
"identityfile ~/.ssh/id_ecdsa_sk",
"identityfile ~/.ssh/id_ed25519",
"identityfile ~/.ssh/id_ed25519_sk",
"identityfile ~/.ssh/id_xmss",
}, "\n")
// parseIdentityFilesForHost uses ssh -G to discern what SSH keys have
// been enabled for the host (via the users SSH config) and returns a
// list of existing identity files.
//
// We do this because when no keys are defined for a host, SSH uses
// fallback keys (see above). However, by passing `-i` to attach our
// private key, we're effectively disabling the fallback keys.
//
// Example invocation:
//
// ssh -G -o SendEnv=GIT_PROTOCOL git@github.com git-upload-pack 'coder/coder'
//
// The extra arguments work without issue and lets us run the command
// as-is without stripping out the excess (git-upload-pack 'coder/coder').
func parseIdentityFilesForHost(ctx context.Context, args, env []string) (identityFiles []string, error error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, xerrors.Errorf("get user home dir failed: %w", err)
}
var outBuf bytes.Buffer
var r io.Reader = &outBuf
args = append([]string{"-G"}, args...)
cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Env = append(cmd.Env, env...)
cmd.Stdout = &outBuf
cmd.Stderr = io.Discard
err = cmd.Run()
if err != nil {
// If ssh -G failed, the SSH version is likely too old, fallback
// to using the default identity files.
r = strings.NewReader(fallbackIdentityFiles)
}
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "identityfile ") {
id := strings.TrimPrefix(line, "identityfile ")
if strings.HasPrefix(id, "~/") {
id = home + id[1:]
}
// OpenSSH on Windows is weird, it supports using (and does
// use) mixed \ and / in paths.
//
// Example: C:\Users\ZeroCool/.ssh/known_hosts
//
// To check the file existence in Go, though, we want to use
// proper Windows paths.
// OpenSSH is amazing, this will work on Windows too:
// C:\Users\ZeroCool/.ssh/id_rsa
id = filepath.FromSlash(id)
// Only include the identity file if it exists.
if _, err := os.Stat(id); err == nil {
identityFiles = append(identityFiles, id)
}
}
}
if err := s.Err(); err != nil {
// This should never happen, the check is for completeness.
return nil, xerrors.Errorf("scan ssh output: %w", err)
}
return identityFiles, nil
}
+225 -79
View File
@@ -2,8 +2,16 @@ package cli_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
@@ -17,98 +25,236 @@ import (
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, string, gossh.PublicKey) {
t.Helper()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithCancel(ctx)
defer t.Cleanup(cancel) // Defer so that cancel is the first cleanup.
// get user public key
keypair, err := client.GitSSHKey(ctx, codersdk.Me)
require.NoError(t, err)
//nolint:dogsled
pubkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey))
require.NoError(t, err)
// setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
Agents: []*proto.Agent{{
Auth: &proto.Agent_Token{
Token: agentToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// start workspace agent
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
agentClient := client
clitest.SetupConfig(t, agentClient, root)
errC := make(chan error, 1)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
t.Cleanup(func() { require.NoError(t, <-errC) })
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
return agentClient, agentToken, pubkey
}
func serveSSHForGitSSH(t *testing.T, handler func(ssh.Session), pubkeys ...gossh.PublicKey) *net.TCPAddr {
t.Helper()
// start ssh server
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
t.Cleanup(func() { _ = l.Close() })
serveOpts := []ssh.Option{
ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
for _, pubkey := range pubkeys {
if ssh.KeysEqual(pubkey, key) {
return true
}
}
return false
}),
}
errC := make(chan error, 1)
go func() {
// as long as we get a successful session we don't care if the server errors
errC <- ssh.Serve(l, handler, serveOpts...)
}()
t.Cleanup(func() {
_ = l.Close() // Ensure server shutdown.
<-errC
})
// start ssh session
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok)
return addr
}
func writePrivateKeyToFile(t *testing.T, name string, key *ecdsa.PrivateKey) {
t.Helper()
b, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
b = pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: b,
})
err = os.WriteFile(name, b, 0o600)
require.NoError(t, err)
}
func TestGitSSH(t *testing.T) {
t.Parallel()
t.Run("Dial", func(t *testing.T) {
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
t.Parallel()
// get user public key
keypair, err := client.GitSSHKey(context.Background(), codersdk.Me)
require.NoError(t, err)
publicKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey))
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
Agents: []*proto.Agent{{
Auth: &proto.Agent_Token{
Token: agentToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// start workspace agent
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String(), "--wireguard=false")
agentClient := client
clitest.SetupConfig(t, agentClient, root)
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
agentErrC := make(chan error)
go func() {
agentErrC <- cmd.ExecuteContext(ctx)
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
dialer, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer dialer.Close()
_, err = dialer.Ping()
require.NoError(t, err)
// start ssh server
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
defer l.Close()
publicKeyOption := ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return ssh.KeysEqual(publicKey, key)
})
client, token, pubkey := prepareTestGitSSH(ctx, t)
var inc int64
sshErrC := make(chan error)
go func() {
// as long as we get a successful session we don't care if the server errors
_ = ssh.Serve(l, func(s ssh.Session) {
atomic.AddInt64(&inc, 1)
t.Log("got authenticated session")
sshErrC <- s.Exit(0)
}, publicKeyOption)
}()
errC := make(chan error, 1)
addr := serveSSHForGitSSH(t, func(s ssh.Session) {
atomic.AddInt64(&inc, 1)
t.Log("got authenticated session")
select {
case errC <- s.Exit(0):
default:
t.Error("error channel is full")
}
}, pubkey)
// start ssh session
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok)
// set to agent config dir
gitsshCmd, _ := clitest.New(t, "gitssh", "--agent-url", agentClient.URL.String(), "--agent-token", agentToken, "--", fmt.Sprintf("-p%d", addr.Port), "-o", "StrictHostKeyChecking=no", "-o", "IdentitiesOnly=yes", "127.0.0.1")
err = gitsshCmd.ExecuteContext(context.Background())
cmd, _ := clitest.New(t,
"gitssh",
"--agent-url", client.URL.String(),
"--agent-token", token,
"--",
fmt.Sprintf("-p%d", addr.Port),
"-o", "StrictHostKeyChecking=no",
"-o", "IdentitiesOnly=yes",
"127.0.0.1",
)
err := cmd.ExecuteContext(ctx)
require.NoError(t, err)
require.EqualValues(t, 1, inc)
err = <-sshErrC
require.NoError(t, err, "error in ssh session exit")
cancelFunc()
err = <-agentErrC
err = <-errC
require.NoError(t, err, "error in agent execute")
})
t.Run("Local SSH Keys", func(t *testing.T) {
t.Parallel()
home := t.TempDir()
sshdir := filepath.Join(home, ".ssh")
err := os.MkdirAll(sshdir, 0o700)
require.NoError(t, err)
idFile := filepath.Join(sshdir, "id_ed25519")
privkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
localPubkey, err := gossh.NewPublicKey(&privkey.PublicKey)
require.NoError(t, err)
writePrivateKeyToFile(t, idFile, privkey)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, token, coderPubkey := prepareTestGitSSH(ctx, t)
authkey := make(chan gossh.PublicKey, 1)
addr := serveSSHForGitSSH(t, func(s ssh.Session) {
t.Logf("authenticated with: %s", gossh.MarshalAuthorizedKey(s.PublicKey()))
select {
case authkey <- s.PublicKey():
default:
t.Error("authkey channel is full")
}
}, localPubkey, coderPubkey)
// Create a new config which sets an identity file.
config := filepath.Join(sshdir, "config")
knownHosts := filepath.Join(sshdir, "known_hosts")
err = os.WriteFile(config, []byte(strings.Join([]string{
"Host mytest",
" HostName 127.0.0.1",
fmt.Sprintf(" Port %d", addr.Port),
" StrictHostKeyChecking no",
" UserKnownHostsFile=" + knownHosts,
" IdentitiesOnly yes",
" IdentityFile=" + idFile,
}, "\n")), 0o600)
require.NoError(t, err)
pty := ptytest.New(t)
cmdArgs := []string{
"gitssh",
"--agent-url", client.URL.String(),
"--agent-token", token,
"--",
"-F", config,
"mytest",
}
// Test authentication via local private key.
cmd, _ := clitest.New(t, cmdArgs...)
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
err = cmd.ExecuteContext(ctx)
require.NoError(t, err)
select {
case key := <-authkey:
require.Equal(t, localPubkey, key)
case <-ctx.Done():
t.Fatal("timeout waiting for auth")
}
// Delete the local private key.
err = os.Remove(idFile)
require.NoError(t, err)
// With the local file deleted, the coder key should be used.
cmd, _ = clitest.New(t, cmdArgs...)
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
err = cmd.ExecuteContext(ctx)
require.NoError(t, err)
select {
case key := <-authkey:
require.Equal(t, coderPubkey, key)
case <-ctx.Done():
t.Fatal("timeout waiting for auth")
}
})
}
+75 -49
View File
@@ -5,7 +5,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
@@ -14,28 +13,82 @@ import (
"github.com/coder/coder/codersdk"
)
type workspaceListRow struct {
Workspace string `table:"workspace"`
Template string `table:"template"`
Status string `table:"status"`
LastBuilt string `table:"last built"`
Outdated bool `table:"outdated"`
StartsAt string `table:"starts at"`
StopsAfter string `table:"stops after"`
}
func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow {
status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
autostartDisplay := "-"
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil {
autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
}
}
autostopDisplay := "-"
if !ptr.NilOrZero(workspace.TTLMillis) {
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
autostopDisplay = durationDisplay(dur)
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.Time.After(now) && status == "Running" {
remaining := time.Until(workspace.LatestBuild.Deadline.Time)
autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining))
}
}
user := usersByID[workspace.OwnerID]
return workspaceListRow{
Workspace: user.Username + "/" + workspace.Name,
Template: workspace.TemplateName,
Status: status,
LastBuilt: durationDisplay(lastBuilt),
Outdated: workspace.Outdated,
StartsAt: autostartDisplay,
StopsAfter: autostopDisplay,
}
}
func list() *cobra.Command {
var columns []string
var (
all bool
columns []string
defaultQuery = "owner:me"
searchQuery string
)
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "list",
Short: "List all workspaces",
Short: "List workspaces",
Aliases: []string{"ls"},
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{})
filter := codersdk.WorkspaceFilter{
FilterQuery: searchQuery,
}
if all && searchQuery == defaultQuery {
filter.FilterQuery = ""
}
workspaces, err := client.Workspaces(cmd.Context(), filter)
if err != nil {
return err
}
if len(workspaces) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder create <name>"))
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), " "+cliui.Styles.Code.Render("coder create <name>"))
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
return nil
}
users, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
@@ -47,52 +100,25 @@ func list() *cobra.Command {
usersByID[user.ID] = user
}
tableWriter := cliui.Table()
header := table.Row{"workspace", "template", "status", "last built", "outdated", "starts at", "stops after"}
tableWriter.AppendHeader(header)
tableWriter.SortBy([]table.SortBy{{
Name: "workspace",
}})
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns))
now := time.Now()
for _, workspace := range workspaces {
status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
lastBuilt := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
autostartDisplay := "-"
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil {
autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
}
}
autostopDisplay := "-"
if !ptr.NilOrZero(workspace.TTLMillis) {
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
autostopDisplay = durationDisplay(dur)
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.After(now) && status == "Running" {
remaining := time.Until(workspace.LatestBuild.Deadline)
autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining))
}
}
user := usersByID[workspace.OwnerID]
tableWriter.AppendRow(table.Row{
user.Username + "/" + workspace.Name,
workspace.TemplateName,
status,
durationDisplay(lastBuilt),
workspace.Outdated,
autostartDisplay,
autostopDisplay,
})
displayWorkspaces := make([]workspaceListRow, len(workspaces))
for i, workspace := range workspaces {
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
return err
},
}
cmd.Flags().BoolVarP(&all, "all", "a", false,
"Specifies whether all workspaces will be listed or not.")
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
"Specify a column to filter in the table.")
cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.")
return cmd
}
+1 -1
View File
@@ -16,7 +16,7 @@ func TestList(t *testing.T) {
t.Parallel()
t.Run("Single", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+6 -3
View File
@@ -45,7 +45,7 @@ func login() *cobra.Command {
)
cmd := &cobra.Command{
Use: "login <url>",
Short: "Authenticate with a Coder deployment",
Short: "Authenticate with Coder deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rawURL := args[0]
@@ -66,7 +66,10 @@ func login() *cobra.Command {
serverURL.Scheme = "https"
}
client := codersdk.New(serverURL)
client, err := createUnauthenticatedClient(cmd, serverURL)
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
@@ -80,7 +83,7 @@ func login() *cobra.Command {
hasInitialUser, err := client.HasFirstUser(cmd.Context())
if err != nil {
return xerrors.Errorf("has initial user: %w", err)
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
}
if !hasInitialUser {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
+10
View File
@@ -2,6 +2,7 @@ package cli_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@@ -23,6 +24,15 @@ func TestLogin(t *testing.T) {
require.Error(t, err)
})
t.Run("InitialUserBadLoginURL", func(t *testing.T) {
t.Parallel()
badLoginURL := "https://fcca2077f06e68aaf9"
root, _ := clitest.New(t, "login", badLoginURL)
err := root.Execute()
errMsg := fmt.Sprintf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser?", badLoginURL)
require.ErrorContains(t, err, errMsg)
})
t.Run("InitialUserTTY", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
+2 -2
View File
@@ -14,9 +14,9 @@ import (
func logout() *cobra.Command {
cmd := &cobra.Command{
Use: "logout",
Short: "Remove the local authenticated session",
Short: "Unauthenticate your local session",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
-30
View File
@@ -1,11 +1,7 @@
package cli
import (
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func parameters() *cobra.Command {
@@ -30,29 +26,3 @@ func parameters() *cobra.Command {
)
return cmd
}
// displayParameters will return a table displaying all parameters passed in.
// filterColumns must be a subset of the parameter fields and will determine which
// columns to display
func displayParameters(filterColumns []string, params ...codersdk.Parameter) string {
tableWriter := cliui.Table()
header := table.Row{"id", "scope", "scope id", "name", "source scheme", "destination scheme", "created at", "updated at"}
tableWriter.AppendHeader(header)
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
tableWriter.SortBy([]table.SortBy{{
Name: "name",
}})
for _, param := range params {
tableWriter.AppendRow(table.Row{
param.ID.String(),
param.Scope,
param.ScopeID.String(),
param.Name,
param.SourceScheme,
param.DestinationScheme,
param.CreatedAt,
param.UpdatedAt,
})
}
return tableWriter.Render()
}
+9 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
@@ -21,7 +22,7 @@ func parameterList() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
scope, name := args[0], args[1]
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -70,11 +71,16 @@ func parameterList() *cobra.Command {
return xerrors.Errorf("fetch params: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayParameters(columns, params...))
out, err := cliui.DisplayTable(params, "name", columns)
if err != nil {
return xerrors.Errorf("render table: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
return err
},
}
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination_scheme"},
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
"Specify a column to filter in the table.")
return cmd
}
+33 -95
View File
@@ -6,30 +6,30 @@ import (
"net"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/pion/udp"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
coderagent "github.com/coder/coder/agent"
"cdr.dev/slog"
"github.com/coder/coder/agent"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func portForward() *cobra.Command {
var (
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
unixForwards []string // <path>:<path> OR <port>:<path>
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
)
cmd := &cobra.Command{
Use: "port-forward <workspace>",
Short: "Forward one or more ports from the local machine to the remote workspace",
Short: "Forward ports from machine to a workspace",
Aliases: []string{"tunnel"},
Args: cobra.ExactArgs(1),
Example: formatExamples(
@@ -41,14 +41,6 @@ func portForward() *cobra.Command {
Description: "Port forward a single UDP port from port 9000 to port 9000 on your local machine",
Command: "coder port-forward <workspace> --udp 9000",
},
example{
Description: "Forward a Unix socket in the workspace to a local Unix socket",
Command: "coder port-forward <workspace> --unix ./local.sock:~/remote.sock",
},
example{
Description: "Forward a Unix socket in the workspace to a local TCP port",
Command: "coder port-forward <workspace> --unix 8080:~/remote.sock",
},
example{
Description: "Port forward multiple TCP ports and a UDP port",
Command: "coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53",
@@ -58,7 +50,7 @@ func portForward() *cobra.Command {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards)
specs, err := parsePortForwards(tcpForwards, udpForwards)
if err != nil {
return xerrors.Errorf("parse port-forward specs: %w", err)
}
@@ -70,12 +62,12 @@ func portForward() *cobra.Command {
return xerrors.New("no port-forwards requested")
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
workspace, agent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
if err != nil {
return err
}
@@ -92,16 +84,16 @@ func portForward() *cobra.Command {
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
WorkspaceName: workspace.Name,
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
return client.WorkspaceAgent(ctx, agent.ID)
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
},
})
if err != nil {
return xerrors.Errorf("await agent: %w", err)
}
conn, err := client.DialWorkspaceAgent(ctx, agent.ID, nil)
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
if err != nil {
return xerrors.Errorf("dial workspace agent: %w", err)
return err
}
defer conn.Close()
@@ -150,6 +142,22 @@ func portForward() *cobra.Command {
closeAllListeners()
}()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
_, err = conn.Ping()
if err != nil {
continue
}
break
}
ticker.Stop()
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
wg.Wait()
return closeErr
@@ -158,12 +166,10 @@ func portForward() *cobra.Command {
cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine")
cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
cmd.Flags().StringArrayVar(&unixForwards, "unix", []string{}, "Forward a Unix socket in the workspace to a local Unix socket or TCP port")
return cmd
}
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderagent.Conn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.AgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
var (
@@ -190,8 +196,6 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
IP: net.ParseIP(host),
Port: portInt,
})
case "unix":
l, err = net.Listen(spec.listenNetwork, spec.listenAddress)
default:
return nil, xerrors.Errorf("unknown listen network %q", spec.listenNetwork)
}
@@ -219,7 +223,7 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
}
defer remoteConn.Close()
coderagent.Bicopy(ctx, netConn, remoteConn)
agent.Bicopy(ctx, netConn, remoteConn)
}(netConn)
}
}(spec)
@@ -228,14 +232,14 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
}
type portForwardSpec struct {
listenNetwork string // tcp, udp, unix
listenNetwork string // tcp, udp
listenAddress string // <ip>:<port> or path
dialNetwork string // tcp, udp, unix
dialNetwork string // tcp, udp
dialAddress string // <ip>:<port> or path
}
func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpec, error) {
func parsePortForwards(tcpSpecs, udpSpecs []string) ([]portForwardSpec, error) {
specs := []portForwardSpec{}
for _, spec := range tcpSpecs {
@@ -266,29 +270,6 @@ func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpe
})
}
for _, specStr := range unixSpecs {
localPath, localTCP, remotePath, err := parseUnixUnix(specStr)
if err != nil {
return nil, xerrors.Errorf("failed to parse Unix port-forward specification %q: %w", specStr, err)
}
spec := portForwardSpec{
dialNetwork: "unix",
dialAddress: remotePath,
}
if localPath == "" {
spec.listenNetwork = "tcp"
spec.listenAddress = fmt.Sprintf("127.0.0.1:%v", localTCP)
} else {
if runtime.GOOS == "windows" {
return nil, xerrors.Errorf("Unix port-forwarding is not supported on Windows")
}
spec.listenNetwork = "unix"
spec.listenAddress = localPath
}
specs = append(specs, spec)
}
// Check for duplicate entries.
locals := map[string]struct{}{}
for _, spec := range specs {
@@ -314,15 +295,6 @@ func parsePort(in string) (uint16, error) {
return uint16(port), nil
}
func parseUnixPath(in string) (string, error) {
path, err := coderagent.ExpandRelativeHomePath(strings.TrimSpace(in))
if err != nil {
return "", xerrors.Errorf("tidy path %q: %w", in, err)
}
return path, nil
}
func parsePortPort(in string) (local uint16, remote uint16, err error) {
parts := strings.Split(in, ":")
if len(parts) > 2 {
@@ -344,37 +316,3 @@ func parsePortPort(in string) (local uint16, remote uint16, err error) {
return local, remote, nil
}
func parsePortOrUnixPath(in string) (string, uint16, error) {
port, err := parsePort(in)
if err == nil {
return "", port, nil
}
path, err := parseUnixPath(in)
if err != nil {
return "", 0, xerrors.Errorf("could not parse port or unix path %q: %w", in, err)
}
return path, 0, nil
}
func parseUnixUnix(in string) (string, uint16, string, error) {
parts := strings.Split(in, ":")
if len(parts) > 2 {
return "", 0, "", xerrors.Errorf("invalid port-forward specification %q", in)
}
if len(parts) == 1 {
// Duplicate the single part
parts = append(parts, parts[0])
}
localPath, localPort, err := parsePortOrUnixPath(parts[0])
if err != nil {
return "", 0, "", xerrors.Errorf("parse local part of spec %q: %w", in, err)
}
// We don't really touch the remote path at all since it gets cleaned
// up/expanded on the remote.
return localPath, localPort, parts[1], nil
}
+38 -162
View File
@@ -1,17 +1,12 @@
package cli_test
import (
"bytes"
"context"
"fmt"
"io"
"net"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/pion/udp"
@@ -23,11 +18,13 @@ import (
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func TestPortForward(t *testing.T) {
t.Parallel()
t.Skip("These tests flake... a lot. It seems related to the Tailscale change, but all other tests pass...")
t.Run("None", func(t *testing.T) {
t.Parallel()
@@ -37,15 +34,17 @@ func TestPortForward(t *testing.T) {
cmd, root := clitest.New(t, "port-forward", "blah")
clitest.SetupConfig(t, client, root)
buf := newThreadSafeBuffer()
cmd.SetOut(buf)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
err := cmd.Execute()
require.Error(t, err)
require.ErrorContains(t, err, "no port-forwards")
// Check that the help was printed.
require.Contains(t, buf.String(), "port-forward <workspace>")
pty.ExpectMatch("port-forward <workspace>")
})
cases := []struct {
@@ -58,7 +57,7 @@ func TestPortForward(t *testing.T) {
// setupRemote creates a "remote" listener to emulate a service in the
// workspace.
setupRemote func(t *testing.T) net.Listener
// setupLocal returns an available port or Unix socket path that the
// setupLocal returns an available port that the
// port-forward command will listen on "locally". Returns the address
// you pass to net.Dial, and the port/path you pass to `coder
// port-forward`.
@@ -110,32 +109,12 @@ func TestPortForward(t *testing.T) {
return l.Addr().String(), port
},
},
{
name: "Unix",
network: "unix",
flag: "--unix=%v:%v",
setupRemote: func(t *testing.T) net.Listener {
if runtime.GOOS == "windows" {
t.Skip("Unix socket forwarding isn't supported on Windows")
}
tmpDir := t.TempDir()
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
require.NoError(t, err, "create UDP listener")
return l
},
setupLocal: func(t *testing.T) (string, string) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.sock")
return path, path
},
},
}
// Setup agent once to be shared between test-cases (avoid expensive
// non-parallel setup).
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
_, workspace = runAgent(t, client, user.UserID)
)
@@ -155,17 +134,19 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listener.
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, client, root)
buf := newThreadSafeBuffer()
cmd.SetOut(buf)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
waitForPortForwardReady(t, buf)
pty.ExpectMatch("Ready!")
t.Parallel() // Port is reserved, enable parallel execution.
@@ -201,17 +182,19 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listeners.
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag1, flag2)
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
clitest.SetupConfig(t, client, root)
buf := newThreadSafeBuffer()
cmd.SetOut(buf)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
waitForPortForwardReady(t, buf)
pty.ExpectMatch("Ready!")
t.Parallel() // Port is reserved, enable parallel execution.
@@ -234,74 +217,16 @@ func TestPortForward(t *testing.T) {
})
}
// Test doing a TCP -> Unix forward.
//nolint:paralleltest
t.Run("TCP2Unix", func(t *testing.T) {
var (
// Find the TCP and Unix cases so we can use their setupLocal and
// setupRemote methods respectively.
tcpCase = cases[0]
unixCase = cases[2]
// Setup remote Unix listener.
p1 = setupTestListener(t, unixCase.setupRemote(t))
)
// Create a flag that forwards from local TCP to Unix listener 1.
// Notably this is a --unix flag.
localAddress, localFlag := tcpCase.setupLocal(t)
flag := fmt.Sprintf(unixCase.flag, localFlag, p1)
// Launch port-forward in a goroutine so we can start dialing
// the "local" listener.
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, client, root)
buf := newThreadSafeBuffer()
cmd.SetOut(buf)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
waitForPortForwardReady(t, buf)
t.Parallel() // Port is reserved, enable parallel execution.
// Open two connections simultaneously and test them out of
// sync.
d := net.Dialer{Timeout: testutil.WaitShort}
c1, err := d.DialContext(ctx, tcpCase.network, localAddress)
require.NoError(t, err, "open connection 1 to 'local' listener")
defer c1.Close()
c2, err := d.DialContext(ctx, tcpCase.network, localAddress)
require.NoError(t, err, "open connection 2 to 'local' listener")
defer c2.Close()
testDial(t, c2)
testDial(t, c1)
cancel()
err = <-errC
require.ErrorIs(t, err, context.Canceled)
})
// Test doing TCP, UDP and Unix at the same time.
// Test doing TCP and UDP at the same time.
//nolint:paralleltest
t.Run("All", func(t *testing.T) {
var (
// These aren't fixed size because we exclude Unix on Windows.
dials = []addr{}
flags = []string{}
)
// Start listeners and populate arrays with the cases.
for _, c := range cases {
if strings.HasPrefix(c.network, "unix") && runtime.GOOS == "windows" {
// Unix isn't supported on Windows, but we can still
// test other protocols together.
continue
}
p := setupTestListener(t, c.setupRemote(t))
localAddress, localFlag := c.setupLocal(t)
@@ -314,17 +239,19 @@ func TestPortForward(t *testing.T) {
// Launch port-forward in a goroutine so we can start dialing
// the "local" listeners.
cmd, root := clitest.New(t, append([]string{"port-forward", workspace.Name}, flags...)...)
cmd, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
clitest.SetupConfig(t, client, root)
buf := newThreadSafeBuffer()
cmd.SetOut(buf)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
waitForPortForwardReady(t, buf)
pty.ExpectMatch("Ready!")
t.Parallel() // Port is reserved, enable parallel execution.
@@ -355,6 +282,7 @@ func TestPortForward(t *testing.T) {
// runAgent creates a fake workspace and starts an agent locally for that
// workspace. The agent will be cleaned up on test completion.
// nolint:unused
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) {
ctx := context.Background()
user, err := client.User(ctx, userID.String())
@@ -391,8 +319,12 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Start workspace agent in a goroutine
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String(), "--wireguard=false")
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
errC := make(chan error)
agentCtx, agentCancel := context.WithCancel(ctx)
t.Cleanup(func() {
@@ -412,7 +344,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
}
// setupTestListener starts accepting connections and echoing a single packet.
// Returns the listener and the listen port or Unix path.
// Returns the listener and the listen port.
func setupTestListener(t *testing.T, l net.Listener) string {
t.Helper()
@@ -444,11 +376,9 @@ func setupTestListener(t *testing.T, l net.Listener) string {
}()
addr := l.Addr().String()
if !strings.HasPrefix(l.Addr().Network(), "unix") {
_, port, err := net.SplitHostPort(addr)
require.NoErrorf(t, err, "split non-Unix listen path %q", addr)
addr = port
}
_, port, err := net.SplitHostPort(addr)
require.NoErrorf(t, err, "split non-Unix listen path %q", addr)
addr = port
return addr
}
@@ -486,61 +416,7 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) {
assert.Equal(t, len(payload), n, "payload length does not match")
}
func waitForPortForwardReady(t *testing.T, output *threadSafeBuffer) {
t.Helper()
for i := 0; i < 100; i++ {
time.Sleep(testutil.IntervalMedium)
data := output.String()
if strings.Contains(data, "Ready!") {
return
}
}
t.Fatal("port-forward command did not become ready in time")
}
type addr struct {
network string
addr string
}
type threadSafeBuffer struct {
b *bytes.Buffer
mut *sync.RWMutex
}
func newThreadSafeBuffer() *threadSafeBuffer {
return &threadSafeBuffer{
b: bytes.NewBuffer(nil),
mut: new(sync.RWMutex),
}
}
var (
_ io.Reader = &threadSafeBuffer{}
_ io.Writer = &threadSafeBuffer{}
)
// Read implements io.Reader.
func (b *threadSafeBuffer) Read(p []byte) (int, error) {
b.mut.RLock()
defer b.mut.RUnlock()
return b.b.Read(p)
}
// Write implements io.Writer.
func (b *threadSafeBuffer) Write(p []byte) (int, error) {
b.mut.Lock()
defer b.mut.Unlock()
return b.b.Write(p)
}
func (b *threadSafeBuffer) String() string {
b.mut.RLock()
defer b.mut.RUnlock()
return b.b.String()
}
+2 -2
View File
@@ -18,9 +18,9 @@ func publickey() *cobra.Command {
cmd := &cobra.Command{
Use: "publickey",
Aliases: []string{"pubkey"},
Short: "Output your public key for Git operations",
Short: "Output your Coder public key used for Git operations",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create codersdk client: %w", err)
}
+1
View File
@@ -13,6 +13,7 @@ import (
func TestPublicKey(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "publickey")
+62
View File
@@ -0,0 +1,62 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func rename() *cobra.Command {
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "rename <workspace> <new name>",
Short: "Rename a workspace",
Args: cobra.ExactArgs(2),
// Keep hidden until renaming is safe, see:
// * https://github.com/coder/coder/issues/3000
// * https://github.com/coder/coder/issues/3386
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
if err != nil {
return err
}
workspace, err := namedWorkspace(cmd, client, args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n",
cliui.Styles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes)."),
)
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
Validate: func(s string) error {
if s == workspace.Name {
return nil
}
return xerrors.Errorf("Input %q does not match %q", s, workspace.Name)
},
})
if err != nil {
return err
}
err = client.UpdateWorkspace(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceRequest{
Name: args[1],
})
if err != nil {
return xerrors.Errorf("rename workspace: %w", err)
}
return nil
},
}
cliui.AllowSkipPrompt(cmd)
return cmd
}
+52
View File
@@ -0,0 +1,52 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func TestRename(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
want := workspace.Name + "-test"
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
errC := make(chan error, 1)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
pty.ExpectMatch("confirm rename:")
pty.WriteLine(workspace.Name)
require.NoError(t, <-errC)
ws, err := client.Workspace(ctx, workspace.ID)
assert.NoError(t, err)
got := ws.Name
assert.Equal(t, want, got, "workspace name did not change")
}
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/userpassword"
)
@@ -20,7 +21,7 @@ func resetPassword() *cobra.Command {
root := &cobra.Command{
Use: "reset-password <username>",
Short: "Reset a user's password by directly updating the database",
Short: "Directly connect to the database to reset a user's password",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
username := args[0]
@@ -35,7 +36,7 @@ func resetPassword() *cobra.Command {
return xerrors.Errorf("ping postgres: %w", err)
}
err = database.EnsureClean(sqlDB)
err = migrations.EnsureClean(sqlDB)
if err != nil {
return xerrors.Errorf("database needs migration: %w", err)
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestResetPassword(t *testing.T) {
go func() {
defer close(serverDone)
err = serverCmd.ExecuteContext(ctx)
assert.ErrorIs(t, err, context.Canceled)
assert.NoError(t, err)
}()
var rawURL string
require.Eventually(t, func() bool {
+188 -72
View File
@@ -1,8 +1,10 @@
package cli
import (
"context"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"strings"
@@ -20,6 +22,7 @@ import (
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)
@@ -34,18 +37,21 @@ var (
)
const (
varURL = "url"
varToken = "token"
varAgentToken = "agent-token"
varAgentURL = "agent-url"
varGlobalConfig = "global-config"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
varURL = "url"
varToken = "token"
varAgentToken = "agent-token"
varAgentURL = "agent-url"
varGlobalConfig = "global-config"
varHeader = "header"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varNoFeatureWarning = "no-feature-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
)
var (
@@ -58,37 +64,78 @@ func init() {
cobra.AddTemplateFuncs(templateFunctions)
}
func Root() *cobra.Command {
func Core() []*cobra.Command {
return []*cobra.Command{
configSSH(),
create(),
deleteWorkspace(),
dotfiles(),
gitssh(),
list(),
login(),
logout(),
parameters(),
portForward(),
publickey(),
resetPassword(),
schedules(),
show(),
ssh(),
speedtest(),
start(),
state(),
stop(),
rename(),
templates(),
update(),
users(),
versionCmd(),
workspaceAgent(),
}
}
func AGPL() []*cobra.Command {
all := append(Core(), Server(func(_ context.Context, o *coderd.Options) (*coderd.API, error) {
return coderd.New(o), nil
}))
return all
}
func Root(subcommands []*cobra.Command) *cobra.Command {
cmd := &cobra.Command{
Use: "coder",
SilenceErrors: true,
SilenceUsage: true,
Long: `Coder A tool for provisioning self-hosted development environments.
Long: `Coder A tool for provisioning self-hosted development environments with Terraform.
`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
err := func() error {
if cliflag.IsSetBool(cmd, varNoVersionCheck) {
return nil
}
if cliflag.IsSetBool(cmd, varNoVersionCheck) &&
cliflag.IsSetBool(cmd, varNoFeatureWarning) {
return
}
// Login handles checking the versions itself since it
// has a handle to an unauthenticated client.
// Server is skipped for obvious reasons.
if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "gitssh" {
return nil
}
// login handles checking the versions itself since it has a handle
// to an unauthenticated client.
//
// server is skipped for obvious reasons.
//
// agent is skipped because these checks use the global coder config
// and not the agent URL and token from the environment.
//
// gitssh is skipped because it's usually not called by users
// directly.
if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "agent" || cmd.Name() == "gitssh" {
return
}
client, err := createClient(cmd)
// If the client is unauthenticated we can ignore the check.
// The child commands should handle an unauthenticated client.
if xerrors.Is(err, errUnauthenticated) {
return nil
}
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
return checkVersions(cmd, client)
}()
client, err := CreateClient(cmd)
// If we are unable to create a client, presumably the subcommand will fail as well
// so we can bail out here.
if err != nil {
return
}
err = checkVersions(cmd, client)
if err != nil {
// Just log the error here. We never want to fail a command
// due to a pre-run.
@@ -96,6 +143,14 @@ func Root() *cobra.Command {
cliui.Styles.Warn.Render("check versions error: %s"), err)
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
}
err = checkWarnings(cmd, client)
if err != nil {
// Same as above
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
cliui.Styles.Warn.Render("check entitlement warnings error: %s"), err)
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
}
},
Example: formatExamples(
example{
@@ -109,53 +164,59 @@ func Root() *cobra.Command {
),
}
cmd.AddCommand(
configSSH(),
create(),
deleteWorkspace(),
dotfiles(),
gitssh(),
list(),
login(),
logout(),
parameters(),
portForward(),
publickey(),
resetPassword(),
schedules(),
server(),
show(),
ssh(),
start(),
state(),
stop(),
templates(),
update(),
users(),
versionCmd(),
wireguardPortForward(),
workspaceAgent(),
)
cmd.AddCommand(subcommands...)
fixUnknownSubcommandError(cmd.Commands())
cmd.SetUsageTemplate(usageTemplate())
cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
cmd.PersistentFlags().String(varURL, "", "URL to a deployment.")
cliflag.Bool(cmd.PersistentFlags(), varNoVersionCheck, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.")
cliflag.Bool(cmd.PersistentFlags(), varNoFeatureWarning, "", envNoFeatureWarning, false, "Suppress warnings about unlicensed features.")
cliflag.String(cmd.PersistentFlags(), varToken, "", envSessionToken, "", fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken))
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.")
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "An agent authentication token.")
_ = cmd.PersistentFlags().MarkHidden(varAgentToken)
cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.")
cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "URL for an agent to access your deployment.")
_ = cmd.PersistentFlags().MarkHidden(varAgentURL)
cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.")
cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Path to the global `coder` config directory.")
cliflag.StringArray(cmd.PersistentFlags(), varHeader, "", "CODER_HEADER", []string{}, "HTTP headers added to all requests. Provide as \"Key=Value\"")
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.")
_ = cmd.PersistentFlags().MarkHidden(varForceTty)
cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.")
_ = cmd.PersistentFlags().MarkHidden(varNoOpen)
cliflag.Bool(cmd.PersistentFlags(), varVerbose, "v", "CODER_VERBOSE", false, "Enable verbose output")
cliflag.Bool(cmd.PersistentFlags(), varVerbose, "v", "CODER_VERBOSE", false, "Enable verbose output.")
return cmd
}
// fixUnknownSubcommandError modifies the provided commands so that the
// ones with subcommands output the correct error message when an
// unknown subcommand is invoked.
//
// Example:
//
// unknown command "bad" for "coder templates"
func fixUnknownSubcommandError(commands []*cobra.Command) {
for _, sc := range commands {
if sc.HasSubCommands() {
if sc.Run == nil && sc.RunE == nil {
if sc.Args != nil {
// In case the developer does not know about this
// behavior in Cobra they must verify correct
// behavior. For instance, settings Args to
// `cobra.ExactArgs(0)` will not give the same
// message as `cobra.NoArgs`. Likewise, omitting the
// run function will not give the wanted error.
panic("developer error: subcommand has subcommands and Args but no Run or RunE")
}
sc.Args = cobra.NoArgs
sc.Run = func(*cobra.Command, []string) {}
}
fixUnknownSubcommandError(sc.Commands())
}
}
}
// versionCmd prints the coder version
func versionCmd() *cobra.Command {
return &cobra.Command{
@@ -180,9 +241,9 @@ func isTest() bool {
return flag.Lookup("test.v") != nil
}
// createClient returns a new client from the command context.
// CreateClient returns a new client from the command context.
// It reads from global configuration files if flags are not set.
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
func CreateClient(cmd *cobra.Command) (*codersdk.Client, error) {
root := createConfig(cmd)
rawURL, err := cmd.Flags().GetString(varURL)
if err != nil || rawURL == "" {
@@ -210,13 +271,37 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
return nil, err
}
}
client, err := createUnauthenticatedClient(cmd, serverURL)
if err != nil {
return nil, err
}
client.SessionToken = token
return client, nil
}
func createUnauthenticatedClient(cmd *cobra.Command, serverURL *url.URL) (*codersdk.Client, error) {
client := codersdk.New(serverURL)
client.SessionToken = strings.TrimSpace(token)
headers, err := cmd.Flags().GetStringArray(varHeader)
if err != nil {
return nil, err
}
transport := &headerTransport{
transport: http.DefaultTransport,
headers: map[string]string{},
}
for _, header := range headers {
parts := strings.SplitN(header, "=", 2)
if len(parts) < 2 {
return nil, xerrors.Errorf("split header %q had less than two parts", header)
}
transport.headers[parts[0]] = parts[1]
}
client.HTTPClient.Transport = transport
return client, nil
}
// createAgentClient returns a new client from the command context.
// It works just like createClient, but uses the agent token and URL instead.
// It works just like CreateClient, but uses the agent token and URL instead.
func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) {
rawURL, err := cmd.Flags().GetString(varAgentURL)
if err != nil {
@@ -457,9 +542,11 @@ func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
return nil
}
clientVersion := buildinfo.Version()
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
info, err := client.BuildInfo(cmd.Context())
clientVersion := buildinfo.Version()
info, err := client.BuildInfo(ctx)
// Avoid printing errors that are connection-related.
if codersdk.IsConnectionErr(err) {
return nil
@@ -482,3 +569,32 @@ download the server version with: 'curl -L https://coder.com/install.sh | sh -s
return nil
}
func checkWarnings(cmd *cobra.Command, client *codersdk.Client) error {
if cliflag.IsSetBool(cmd, varNoFeatureWarning) {
return nil
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
entitlements, err := client.Entitlements(ctx)
if err == nil {
for _, w := range entitlements.Warnings {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Warn.Render(w))
}
}
return nil
}
type headerTransport struct {
transport http.RoundTripper
headers map[string]string
}
func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for k, v := range h.headers {
req.Header.Add(k, v)
}
return h.transport.RoundTrip(req)
}
+8 -4
View File
@@ -1,10 +1,10 @@
package cli
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func Test_formatExamples(t *testing.T) {
@@ -67,7 +67,11 @@ func Test_formatExamples(t *testing.T) {
}
func TestMain(m *testing.M) {
// Replace with goleak.VerifyTestMain(m) when we enable goleak.
os.Exit(m.Run())
// goleak.VerifyTestMain(m)
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"),
)
}
+25
View File
@@ -2,9 +2,12 @@ package cli_test
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
@@ -15,6 +18,7 @@ import (
)
func TestRoot(t *testing.T) {
t.Parallel()
t.Run("FormatCobraError", func(t *testing.T) {
t.Parallel()
@@ -128,4 +132,25 @@ func TestRoot(t *testing.T) {
require.Contains(t, output, buildinfo.Version(), "has version")
require.Contains(t, output, buildinfo.ExternalURL(), "has url")
})
t.Run("Header", func(t *testing.T) {
t.Parallel()
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "wow", r.Header.Get("X-Testing"))
w.WriteHeader(http.StatusGone)
select {
case <-done:
close(done)
default:
}
}))
defer srv.Close()
buf := new(bytes.Buffer)
cmd, _ := clitest.New(t, "--header", "X-Testing=wow", "login", srv.URL)
cmd.SetOut(buf)
// This won't succeed, because we're using the login cmd to assert requests.
_ = cmd.Execute()
})
}
+7 -7
View File
@@ -57,7 +57,7 @@ func schedules() *cobra.Command {
scheduleCmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "schedule { show | start | stop | override } <workspace>",
Short: "Modify scheduled stop and start times for your workspace",
Short: "Schedule automated start and stop times for workspaces",
}
scheduleCmd.AddCommand(
@@ -77,7 +77,7 @@ func scheduleShow() *cobra.Command {
Long: scheduleShowDescriptionLong,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -106,7 +106,7 @@ func scheduleStart() *cobra.Command {
Long: scheduleStartDescriptionLong,
Args: cobra.RangeArgs(2, 4),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -156,7 +156,7 @@ func scheduleStop() *cobra.Command {
Short: "Edit workspace stop schedule",
Long: scheduleStopDescriptionLong,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -207,7 +207,7 @@ func scheduleOverride() *cobra.Command {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
@@ -280,8 +280,8 @@ func displaySchedule(workspace codersdk.Workspace, out io.Writer) error {
if workspace.LatestBuild.Transition != "start" {
schedNextStop = "-"
} else {
schedNextStop = workspace.LatestBuild.Deadline.In(loc).Format(timeFormat + " on " + dateFormat)
schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline)))
schedNextStop = workspace.LatestBuild.Deadline.Time.In(loc).Format(timeFormat + " on " + dateFormat)
schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline.Time)))
}
}
+14 -16
View File
@@ -28,7 +28,7 @@ func TestScheduleShow(t *testing.T) {
sched = "30 7 * * 1-5"
schedCron = fmt.Sprintf("CRON_TZ=%s %s", tz, sched)
ttl = 8 * time.Hour
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -61,22 +61,20 @@ func TestScheduleShow(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = nil
cwr.TTLMillis = nil
})
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmdArgs = []string{"schedule", "show", workspace.Name}
stdoutBuf = &bytes.Buffer{}
)
// unset workspace TTL
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}))
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)
@@ -96,7 +94,7 @@ func TestScheduleShow(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -115,7 +113,7 @@ func TestScheduleStart(t *testing.T) {
var (
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -168,7 +166,7 @@ func TestScheduleStop(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -221,7 +219,7 @@ func TestScheduleOverride(t *testing.T) {
var (
err error
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -239,7 +237,7 @@ func TestScheduleOverride(t *testing.T) {
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute)
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@@ -252,7 +250,7 @@ func TestScheduleOverride(t *testing.T) {
// Then: the deadline of the latest build is updated assuming the units are minutes
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline.Time, time.Minute)
})
t.Run("InvalidDuration", func(t *testing.T) {
@@ -262,7 +260,7 @@ func TestScheduleOverride(t *testing.T) {
var (
err error
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -279,7 +277,7 @@ func TestScheduleOverride(t *testing.T) {
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute)
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@@ -298,7 +296,7 @@ func TestScheduleOverride(t *testing.T) {
var (
err error
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -349,7 +347,7 @@ func TestScheduleOverride(t *testing.T) {
func TestScheduleStartDefaults(t *testing.T) {
t.Setenv("TZ", "Pacific/Tongatapu")
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+264 -114
View File
@@ -28,19 +28,18 @@ import (
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/pion/turn/v2"
"github.com/pion/webrtc/v3"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/afero"
"github.com/spf13/cobra"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
xgithub "golang.org/x/oauth2/github"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
@@ -52,12 +51,12 @@ import (
"github.com/coder/coder/coderd/autobuild/executor"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/devtunnel"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/prometheusmetrics"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/turnconn"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/provisioner/echo"
@@ -65,14 +64,23 @@ import (
"github.com/coder/coder/provisionerd"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/tailnet"
)
// nolint:gocyclo
func server() *cobra.Command {
func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error)) *cobra.Command {
var (
accessURL string
address string
wildcardAccessURL string
autobuildPollInterval time.Duration
derpServerEnabled bool
derpServerRegionID int
derpServerRegionCode string
derpServerRegionName string
derpServerSTUNAddrs []string
derpConfigURL string
derpConfigPath string
promEnabled bool
promAddress string
pprofEnabled bool
@@ -94,7 +102,9 @@ func server() *cobra.Command {
oidcEmailDomain string
oidcIssuerURL string
oidcScopes []string
tailscaleEnable bool
telemetryEnable bool
telemetryTraceEnable bool
telemetryURL string
tlsCertFile string
tlsClientCAFile string
@@ -102,14 +112,15 @@ func server() *cobra.Command {
tlsEnable bool
tlsKeyFile string
tlsMinVersion string
turnRelayAddress string
tunnel bool
stunServers []string
trace bool
traceEnable bool
secureAuthCookie bool
sshKeygenAlgorithmRaw string
autoImportTemplates []string
spooky bool
verbose bool
metricsCacheRefreshInterval time.Duration
agentStatRefreshInterval time.Duration
)
root := &cobra.Command{
@@ -127,32 +138,60 @@ func server() *cobra.Command {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
// 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()
// restores default behavior for the signals. This protects
// the shutdown sequence from abrubtly terminating things
// like: database migrations, provisioner work, workspace
// cleanup in dev-mode, etc.
//
// To get out of a graceful shutdown, the user can send
// SIGQUIT with ctrl+\ or SIGKILL with `kill -9`.
notifyCtx, notifyStop := signal.NotifyContext(ctx, interruptSignals...)
defer notifyStop()
// Clean up idle connections at the end, e.g.
// embedded-postgres can leave an idle connection
// which is caught by goleaks.
defer http.DefaultClient.CloseIdleConnections()
var (
tracerProvider *sdktrace.TracerProvider
tracerProvider trace.TracerProvider
err error
sqlDriver = "postgres"
)
if trace {
tracerProvider, err = tracing.TracerProvider(ctx, "coderd")
// Coder tracing should be disabled if telemetry is disabled unless
// --telemetry-trace was explicitly provided.
shouldCoderTrace := telemetryEnable && !isTest()
// Only override if telemetryTraceEnable was specifically set.
// By default we want it to be controlled by telemetryEnable.
if cmd.Flags().Changed("telemetry-trace") {
shouldCoderTrace = telemetryTraceEnable
}
if traceEnable || shouldCoderTrace {
sdkTracerProvider, closeTracing, err := tracing.TracerProvider(ctx, "coderd", tracing.TracerOpts{
Default: traceEnable,
Coder: shouldCoderTrace,
})
if err != nil {
logger.Warn(ctx, "failed to start telemetry exporter", slog.Error(err))
logger.Warn(ctx, "start telemetry exporter", slog.Error(err))
} else {
// allow time for traces to flush even if command context is canceled
defer func() {
_ = shutdownWithTimeout(tracerProvider, 5*time.Second)
_ = shutdownWithTimeout(closeTracing, 5*time.Second)
}()
d, err := tracing.PostgresDriver(tracerProvider, "coderd.database")
d, err := tracing.PostgresDriver(sdkTracerProvider, "coderd.database")
if err != nil {
logger.Warn(ctx, "failed to start postgres tracing driver", slog.Error(err))
logger.Warn(ctx, "start postgres tracing driver", slog.Error(err))
} else {
sqlDriver = d
}
tracerProvider = sdkTracerProvider
}
}
@@ -231,6 +270,17 @@ func server() *cobra.Command {
if err != nil {
return xerrors.Errorf("parse URL: %w", err)
}
accessURLPortRaw := accessURLParsed.Port()
if accessURLPortRaw == "" {
accessURLPortRaw = "80"
if accessURLParsed.Scheme == "https" {
accessURLPortRaw = "443"
}
}
accessURLPort, err := strconv.Atoi(accessURLPortRaw)
if err != nil {
return xerrors.Errorf("parse access URL port: %w", err)
}
// Warn the user if the access URL appears to be a loopback address.
isLocal, err := isLocalURL(ctx, accessURLParsed)
@@ -256,34 +306,69 @@ func server() *cobra.Command {
return xerrors.Errorf("parse ssh keygen algorithm %s: %w", sshKeygenAlgorithmRaw, err)
}
turnServer, err := turnconn.New(&turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP(turnRelayAddress),
Address: turnRelayAddress,
})
if err != nil {
return xerrors.Errorf("create turn server: %w", err)
}
defer turnServer.Close()
// Validate provided auto-import templates.
var (
validatedAutoImportTemplates = make([]coderd.AutoImportTemplate, len(autoImportTemplates))
seenValidatedAutoImportTemplates = make(map[coderd.AutoImportTemplate]struct{}, len(autoImportTemplates))
)
for i, autoImportTemplate := range autoImportTemplates {
var v coderd.AutoImportTemplate
switch autoImportTemplate {
case "kubernetes":
v = coderd.AutoImportTemplateKubernetes
default:
return xerrors.Errorf("auto import template %q is not supported", autoImportTemplate)
}
iceServers := make([]webrtc.ICEServer, 0)
for _, stunServer := range stunServers {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{stunServer},
})
if _, ok := seenValidatedAutoImportTemplates[v]; ok {
return xerrors.Errorf("auto import template %q is specified more than once", v)
}
seenValidatedAutoImportTemplates[v] = struct{}{}
validatedAutoImportTemplates[i] = v
}
defaultRegion := &tailcfg.DERPRegion{
EmbeddedRelay: true,
RegionID: derpServerRegionID,
RegionCode: derpServerRegionCode,
RegionName: derpServerRegionName,
Nodes: []*tailcfg.DERPNode{{
Name: fmt.Sprintf("%db", derpServerRegionID),
RegionID: derpServerRegionID,
HostName: accessURLParsed.Hostname(),
DERPPort: accessURLPort,
STUNPort: -1,
ForceHTTP: accessURLParsed.Scheme == "http",
}},
}
if !derpServerEnabled {
defaultRegion = nil
}
derpMap, err := tailnet.NewDERPMap(ctx, defaultRegion, derpServerSTUNAddrs, derpConfigURL, derpConfigPath)
if err != nil {
return xerrors.Errorf("create derp map: %w", err)
}
appHostname := strings.TrimPrefix(wildcardAccessURL, "http://")
appHostname = strings.TrimPrefix(appHostname, "https://")
appHostname = strings.TrimPrefix(appHostname, "*.")
options := &coderd.Options{
AccessURL: accessURLParsed,
ICEServers: iceServers,
Logger: logger.Named("coderd"),
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
CacheDir: cacheDir,
GoogleTokenValidator: googleTokenValidator,
SecureAuthCookie: secureAuthCookie,
SSHKeygenAlgorithm: sshKeygenAlgorithm,
TURNServer: turnServer,
TracerProvider: tracerProvider,
Telemetry: telemetry.NewNoop(),
AccessURL: accessURLParsed,
AppHostname: appHostname,
Logger: logger.Named("coderd"),
Database: databasefake.New(),
DERPMap: derpMap,
Pubsub: database.NewPubsubInMemory(),
CacheDir: cacheDir,
GoogleTokenValidator: googleTokenValidator,
SecureAuthCookie: secureAuthCookie,
SSHKeygenAlgorithm: sshKeygenAlgorithm,
TracerProvider: tracerProvider,
Telemetry: telemetry.NewNoop(),
AutoImportTemplates: validatedAutoImportTemplates,
MetricsCacheRefreshInterval: metricsCacheRefreshInterval,
AgentStatsRefreshInterval: agentStatRefreshInterval,
}
if oauth2GithubClientSecret != "" {
@@ -339,7 +424,7 @@ func server() *cobra.Command {
if err != nil {
return xerrors.Errorf("ping postgres: %w", err)
}
err = database.MigrateUp(sqlDB)
err = migrations.Up(sqlDB)
if err != nil {
return xerrors.Errorf("migrate up: %w", err)
}
@@ -386,7 +471,7 @@ func server() *cobra.Command {
OIDCAuth: oidcClientID != "",
OIDCIssuerURL: oidcIssuerURL,
Prometheus: promEnabled,
STUN: len(stunServers) != 0,
STUN: len(derpServerSTUNAddrs) != 0,
Tunnel: tunnel,
})
if err != nil {
@@ -421,7 +506,10 @@ func server() *cobra.Command {
), promAddress, "prometheus")()
}
coderAPI := coderd.New(options)
coderAPI, err := newAPI(ctx, options)
if err != nil {
return err
}
defer coderAPI.Close()
client := codersdk.New(localURL)
@@ -459,17 +547,22 @@ func server() *cobra.Command {
shutdownConnsCtx, shutdownConns := context.WithCancel(ctx)
defer shutdownConns()
// ReadHeaderTimeout is purposefully not enabled. It caused some issues with
// websockets over the dev tunnel.
// See: https://github.com/coder/coder/pull/3730
//nolint:gosec
server := &http.Server{
// These errors are typically noise like "TLS: EOF". Vault does similar:
// https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714
ErrorLog: log.New(io.Discard, "", 0),
Handler: coderAPI.Handler,
Handler: coderAPI.RootHandler,
BaseContext: func(_ net.Listener) context.Context {
return shutdownConnsCtx
},
}
defer func() {
_ = shutdownWithTimeout(server, 5*time.Second)
_ = shutdownWithTimeout(server.Shutdown, 5*time.Second)
}()
eg := errgroup.Group{}
@@ -521,22 +614,13 @@ func server() *cobra.Command {
// such as via the systemd service.
_ = config.URL().Write(client.URL.String())
// Because the graceful shutdown includes cleaning up workspaces in dev mode, we're
// going to make it harder to accidentally skip the graceful shutdown by hitting ctrl+c
// two or more times. So the stopChan is unlimited in size and we don't call
// signal.Stop() until graceful shutdown finished--this means we swallow additional
// SIGINT after the first. To get out of a graceful shutdown, the user can send SIGQUIT
// with ctrl+\ or SIGTERM with `kill`.
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
// 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 <-ctx.Done():
exitErr = ctx.Err()
case <-notifyCtx.Done():
exitErr = notifyCtx.Err()
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render(
"Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit",
))
@@ -566,7 +650,7 @@ func server() *cobra.Command {
// in-flight requests, give in-flight requests 5 seconds to
// complete.
cmd.Println("Shutting down API server...")
err = shutdownWithTimeout(server, 5*time.Second)
err = shutdownWithTimeout(server.Shutdown, 5*time.Second)
if err != nil {
cmd.Printf("API server shutdown took longer than 5s: %s", err)
} else {
@@ -588,7 +672,7 @@ func server() *cobra.Command {
if verbose {
cmd.Printf("Shutting down provisioner daemon %d...\n", id)
}
err := shutdownWithTimeout(provisionerDaemon, 5*time.Second)
err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second)
if err != nil {
cmd.PrintErrf("Failed to shutdown provisioner daemon %d: %s\n", id, err)
return
@@ -607,7 +691,7 @@ func server() *cobra.Command {
cmd.Println("Waiting for WebSocket connections to close...")
_ = coderAPI.Close()
cmd.Println("Done wainting for WebSocket connections")
cmd.Println("Done waiting for WebSocket connections")
// Close tunnel after we no longer have in-flight connections.
if tunnel {
@@ -623,6 +707,9 @@ func server() *cobra.Command {
// Trigger context cancellation for any remaining services.
cancel()
if xerrors.Is(exitErr, context.Canceled) {
return nil
}
return exitErr
},
}
@@ -630,13 +717,13 @@ func server() *cobra.Command {
root.AddCommand(&cobra.Command{
Use: "postgres-builtin-url",
Short: "Output the connection URL for the built-in PostgreSQL deployment.",
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(cmd *cobra.Command, _ []string) error {
cfg := createConfig(cmd)
url, err := embeddedPostgresURL(cfg)
if err != nil {
return err
}
cmd.Println(cliui.Styles.Code.Render("psql \"" + url + "\""))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "psql %q\n", url)
return nil
},
})
@@ -668,80 +755,130 @@ func server() *cobra.Command {
},
})
cliflag.DurationVarP(root.Flags(), &autobuildPollInterval, "autobuild-poll-interval", "", "CODER_AUTOBUILD_POLL_INTERVAL", time.Minute, "Specifies the interval at which to poll for and execute automated workspace build operations.")
cliflag.StringVarP(root.Flags(), &accessURL, "access-url", "", "CODER_ACCESS_URL", "", "Specifies the external URL to access Coder.")
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard.")
cliflag.BoolVarP(root.Flags(), &promEnabled, "prometheus-enable", "", "CODER_PROMETHEUS_ENABLE", false, "Enable serving prometheus metrics on the addressdefined by --prometheus-address.")
cliflag.StringVarP(root.Flags(), &promAddress, "prometheus-address", "", "CODER_PROMETHEUS_ADDRESS", "127.0.0.1:2112", "The address to serve prometheus metrics.")
cliflag.BoolVarP(root.Flags(), &pprofEnabled, "pprof-enable", "", "CODER_PPROF_ENABLE", false, "Enable serving pprof metrics on the address defined by --pprof-address.")
cliflag.StringVarP(root.Flags(), &pprofAddress, "pprof-address", "", "CODER_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
defaultCacheDir := filepath.Join(os.TempDir(), "coder-cache")
cliflag.DurationVarP(root.Flags(), &autobuildPollInterval, "autobuild-poll-interval", "", "CODER_AUTOBUILD_POLL_INTERVAL", time.Minute,
"Interval to poll for scheduled workspace builds.")
_ = root.Flags().MarkHidden("autobuild-poll-interval")
cliflag.StringVarP(root.Flags(), &accessURL, "access-url", "", "CODER_ACCESS_URL", "",
"External URL to access your deployment. This must be accessible by all provisioned workspaces.")
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000",
"Bind address of the server.")
cliflag.StringVarP(root.Flags(), &wildcardAccessURL, "wildcard-access-url", "", "CODER_WILDCARD_ACCESS_URL", "", `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".`)
cliflag.StringVarP(root.Flags(), &derpConfigURL, "derp-config-url", "", "CODER_DERP_CONFIG_URL", "",
"URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/")
cliflag.StringVarP(root.Flags(), &derpConfigPath, "derp-config-path", "", "CODER_DERP_CONFIG_PATH", "",
"Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/")
cliflag.BoolVarP(root.Flags(), &derpServerEnabled, "derp-server-enable", "", "CODER_DERP_SERVER_ENABLE", true,
"Whether to enable or disable the embedded DERP relay server.")
cliflag.IntVarP(root.Flags(), &derpServerRegionID, "derp-server-region-id", "", "CODER_DERP_SERVER_REGION_ID", 999,
"Region ID to use for the embedded DERP server.")
cliflag.StringVarP(root.Flags(), &derpServerRegionCode, "derp-server-region-code", "", "CODER_DERP_SERVER_REGION_CODE", "coder",
"Region code that for the embedded DERP server.")
cliflag.StringVarP(root.Flags(), &derpServerRegionName, "derp-server-region-name", "", "CODER_DERP_SERVER_REGION_NAME", "Coder Embedded Relay",
"Region name that for the embedded DERP server.")
cliflag.StringArrayVarP(root.Flags(), &derpServerSTUNAddrs, "derp-server-stun-addresses", "", "CODER_DERP_SERVER_STUN_ADDRESSES", []string{
"stun.l.google.com:19302",
}, "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.")
cliflag.BoolVarP(root.Flags(), &promEnabled, "prometheus-enable", "", "CODER_PROMETHEUS_ENABLE", false,
"Serve prometheus metrics on the address defined by `prometheus-address`.")
cliflag.StringVarP(root.Flags(), &promAddress, "prometheus-address", "", "CODER_PROMETHEUS_ADDRESS", "127.0.0.1:2112",
"The bind address to serve prometheus metrics.")
cliflag.BoolVarP(root.Flags(), &pprofEnabled, "pprof-enable", "", "CODER_PPROF_ENABLE", false,
"Serve pprof metrics on the address defined by `pprof-address`.")
cliflag.StringVarP(root.Flags(), &pprofAddress, "pprof-address", "", "CODER_PPROF_ADDRESS", "127.0.0.1:6060",
"The bind address to serve pprof.")
defaultCacheDir, err := os.UserCacheDir()
if err != nil {
defaultCacheDir = os.TempDir()
}
if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" {
// For compatibility with systemd.
defaultCacheDir = dir
}
cliflag.StringVarP(root.Flags(), &cacheDir, "cache-dir", "", "CODER_CACHE_DIRECTORY", defaultCacheDir, "Specifies a directory to cache binaries for provision operations. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.")
defaultCacheDir = filepath.Join(defaultCacheDir, "coder")
cliflag.StringVarP(root.Flags(), &cacheDir, "cache-dir", "", "CODER_CACHE_DIRECTORY", defaultCacheDir,
"The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.")
cliflag.BoolVarP(root.Flags(), &inMemoryDatabase, "in-memory", "", "CODER_INMEMORY", false,
"Specifies whether data will be stored in an in-memory database.")
"Controls whether data will be stored in an in-memory database.")
_ = root.Flags().MarkHidden("in-memory")
cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "", "The URL of a PostgreSQL database to connect to. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\"")
cliflag.Uint8VarP(root.Flags(), &provisionerDaemonCount, "provisioner-daemons", "", "CODER_PROVISIONER_DAEMONS", 3, "The amount of provisioner daemons to create on start.")
cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "",
"URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\"")
cliflag.Uint8VarP(root.Flags(), &provisionerDaemonCount, "provisioner-daemons", "", "CODER_PROVISIONER_DAEMONS", 3,
"Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this.")
cliflag.StringVarP(root.Flags(), &oauth2GithubClientID, "oauth2-github-client-id", "", "CODER_OAUTH2_GITHUB_CLIENT_ID", "",
"Specifies a client ID to use for oauth2 with GitHub.")
"Client ID for Login with GitHub.")
cliflag.StringVarP(root.Flags(), &oauth2GithubClientSecret, "oauth2-github-client-secret", "", "CODER_OAUTH2_GITHUB_CLIENT_SECRET", "",
"Specifies a client secret to use for oauth2 with GitHub.")
"Client secret for Login with GitHub.")
cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedOrganizations, "oauth2-github-allowed-orgs", "", "CODER_OAUTH2_GITHUB_ALLOWED_ORGS", nil,
"Specifies organizations the user must be a member of to authenticate with GitHub.")
"Organizations the user must be a member of to Login with GitHub.")
cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedTeams, "oauth2-github-allowed-teams", "", "CODER_OAUTH2_GITHUB_ALLOWED_TEAMS", nil,
"Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: <organization-name>/<team-slug>.")
"Teams inside organizations the user must be a member of to Login with GitHub. Structured as: <organization-name>/<team-slug>.")
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
"Specifies whether new users can sign up with GitHub.")
"Whether new users can sign up with GitHub.")
cliflag.StringVarP(root.Flags(), &oauth2GithubEnterpriseBaseURL, "oauth2-github-enterprise-base-url", "", "CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL", "",
"Specifies the base URL of a GitHub Enterprise instance to use for oauth2.")
"Base URL of a GitHub Enterprise deployment to use for Login with GitHub.")
cliflag.BoolVarP(root.Flags(), &oidcAllowSignups, "oidc-allow-signups", "", "CODER_OIDC_ALLOW_SIGNUPS", true,
"Specifies whether new users can sign up with OIDC.")
"Whether new users can sign up with OIDC.")
cliflag.StringVarP(root.Flags(), &oidcClientID, "oidc-client-id", "", "CODER_OIDC_CLIENT_ID", "",
"Specifies a client ID to use for OIDC.")
"Client ID to use for Login with OIDC.")
cliflag.StringVarP(root.Flags(), &oidcClientSecret, "oidc-client-secret", "", "CODER_OIDC_CLIENT_SECRET", "",
"Specifies a client secret to use for OIDC.")
"Client secret to use for Login with OIDC.")
cliflag.StringVarP(root.Flags(), &oidcEmailDomain, "oidc-email-domain", "", "CODER_OIDC_EMAIL_DOMAIN", "",
"Specifies an email domain that clients authenticating with OIDC must match.")
"Email domain that clients logging in with OIDC must match.")
cliflag.StringVarP(root.Flags(), &oidcIssuerURL, "oidc-issuer-url", "", "CODER_OIDC_ISSUER_URL", "",
"Specifies an issuer URL to use for OIDC.")
"Issuer URL to use for Login with OIDC.")
cliflag.StringArrayVarP(root.Flags(), &oidcScopes, "oidc-scopes", "", "CODER_OIDC_SCOPES", []string{oidc.ScopeOpenID, "profile", "email"},
"Specifies scopes to grant when authenticating with OIDC.")
"Scopes to grant when authenticating with OIDC.")
cliflag.BoolVarP(root.Flags(), &tailscaleEnable, "tailscale", "", "CODER_TAILSCALE", true,
"Specifies whether Tailscale networking is used for web applications and terminals.")
_ = root.Flags().MarkHidden("tailscale")
enableTelemetryByDefault := !isTest()
cliflag.BoolVarP(root.Flags(), &telemetryEnable, "telemetry", "", "CODER_TELEMETRY", enableTelemetryByDefault, "Specifies whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.")
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.")
cliflag.BoolVarP(root.Flags(), &telemetryEnable, "telemetry", "", "CODER_TELEMETRY", enableTelemetryByDefault,
"Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.")
cliflag.BoolVarP(root.Flags(), &telemetryTraceEnable, "telemetry-trace", "", "CODER_TELEMETRY_TRACE", enableTelemetryByDefault,
"Whether Opentelemetry traces are sent to Coder. Coder collects anonymized application tracing to help improve our product. Disabling telemetry also disables this option.")
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com",
"URL to send telemetry.")
_ = root.Flags().MarkHidden("telemetry-url")
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled")
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false,
"Whether TLS will be enabled.")
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+
"Path to the certificate for TLS. It requires a PEM-encoded file. "+
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
"and the CA certificate together. The primary certificate should appear first in the combined file")
"and the CA certificate together. The primary certificate should appear first in the combined file.")
cliflag.StringVarP(root.Flags(), &tlsClientCAFile, "tls-client-ca-file", "", "CODER_TLS_CLIENT_CA_FILE", "",
"PEM-encoded Certificate Authority file used for checking the authenticity of client")
cliflag.StringVarP(root.Flags(), &tlsClientAuth, "tls-client-auth", "", "CODER_TLS_CLIENT_AUTH", "request",
`Specifies the policy the server will follow for TLS Client Authentication. `+
`Policy the server will follow for TLS Client Authentication. `+
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`)
cliflag.StringVarP(root.Flags(), &tlsKeyFile, "tls-key-file", "", "CODER_TLS_KEY_FILE", "",
"Specifies the path to the private key for the certificate. It requires a PEM-encoded file")
"Path to the private key for the certificate. It requires a PEM-encoded file")
cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12",
`Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
`Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
cliflag.BoolVarP(root.Flags(), &tunnel, "tunnel", "", "CODER_TUNNEL", false,
"Workspaces must be able to reach the `access-url`. This overrides your access URL with a public access URL that tunnels your Coder deployment.")
cliflag.StringArrayVarP(root.Flags(), &stunServers, "stun-server", "", "CODER_STUN_SERVERS", []string{
"stun:stun.l.google.com:19302",
}, "Specify URLs for STUN servers to enable P2P connections.")
cliflag.BoolVarP(root.Flags(), &trace, "trace", "", "CODER_TRACE", false, "Specifies if application tracing data is collected")
cliflag.StringVarP(root.Flags(), &turnRelayAddress, "turn-relay-address", "", "CODER_TURN_RELAY_ADDRESS", "127.0.0.1",
"Specifies the address to bind TURN connections.")
cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies")
cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+
`Accepted values are "ed25519", "ecdsa", or "rsa4096"`)
cliflag.BoolVarP(root.Flags(), &spooky, "spooky", "", "", false, "Specifies spookiness level")
cliflag.BoolVarP(root.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false, "Enables verbose logging.")
cliflag.BoolVarP(root.Flags(), &traceEnable, "trace", "", "CODER_TRACE", false,
"Whether application tracing data is collected.")
cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false,
"Controls if the 'Secure' property is set on browser session cookies")
cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519",
"The algorithm to use for generating ssh keys. "+
`Accepted values are "ed25519", "ecdsa", or "rsa4096"`)
cliflag.StringArrayVarP(root.Flags(), &autoImportTemplates, "auto-import-template", "", "CODER_TEMPLATE_AUTOIMPORT", []string{},
"Templates to auto-import. Available auto-importable templates are: kubernetes")
_ = root.Flags().MarkHidden("auto-import-template")
cliflag.BoolVarP(root.Flags(), &spooky, "spooky", "", "", false, "Specifies spookiness level...")
_ = root.Flags().MarkHidden("spooky")
cliflag.BoolVarP(root.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false,
"Enables verbose logging.")
// These metrics flags are for manually testing the metric system.
// The defaults should be acceptable for any Coder deployment of any
// reasonable size.
cliflag.DurationVarP(root.Flags(), &metricsCacheRefreshInterval, "metrics-cache-refresh-interval", "", "CODER_METRICS_CACHE_REFRESH_INTERVAL", time.Hour, "How frequently metrics are refreshed")
_ = root.Flags().MarkHidden("metrics-cache-refresh-interval")
cliflag.DurationVarP(root.Flags(), &agentStatRefreshInterval, "agent-stats-refresh-interval", "", "CODER_AGENT_STATS_REFRESH_INTERVAL", time.Minute*10, "How frequently agent stats are recorded")
_ = root.Flags().MarkHidden("agent-stats-refresh-interval")
return root
}
@@ -794,15 +931,20 @@ func isLocalURL(ctx context.Context, u *url.URL) (bool, error) {
return false, nil
}
func shutdownWithTimeout(s interface{ Shutdown(context.Context) error }, timeout time.Duration) error {
func shutdownWithTimeout(shutdown func(context.Context) error, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.Shutdown(ctx)
return shutdown(ctx)
}
// nolint:revive
func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
logger slog.Logger, cacheDir string, errCh chan error, dev bool,
func newProvisionerDaemon(
ctx context.Context,
coderAPI *coderd.API,
logger slog.Logger,
cacheDir string,
errCh chan error,
dev bool,
) (srv *provisionerd.Server, err error) {
ctx, cancel := context.WithCancel(ctx)
defer func() {
@@ -875,22 +1017,23 @@ func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
UpdateInterval: 500 * time.Millisecond,
Provisioners: provisioners,
WorkDirectory: tempDir,
Tracer: coderAPI.TracerProvider,
}), nil
}
// nolint: revive
func printLogo(cmd *cobra.Command, spooky bool) {
if spooky {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), `
_, _ = fmt.Fprintf(cmd.OutOrStdout(), `
`)
return
}
@@ -1076,7 +1219,14 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler, addr, name string) (closeFunc func()) {
logger.Debug(ctx, "http server listening", slog.F("addr", addr), slog.F("name", name))
srv := &http.Server{Addr: addr, Handler: handler}
// ReadHeaderTimeout is purposefully not enabled. It caused some issues with
// websockets over the dev tunnel.
// See: https://github.com/coder/coder/pull/3730
//nolint:gosec
srv := &http.Server{
Addr: addr,
Handler: handler,
}
go func() {
err := srv.ListenAndServe()
if err != nil && !xerrors.Is(err, http.ErrServerClosed) {
+31 -23
View File
@@ -24,7 +24,7 @@ import (
"testing"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@@ -39,7 +39,7 @@ import (
)
// This cannot be ran in parallel because it uses a signal.
// nolint:paralleltest
// nolint:tparallel,paralleltest
func TestServer(t *testing.T) {
t.Run("Production", func(t *testing.T) {
if runtime.GOOS != "linux" || testing.Short() {
@@ -73,7 +73,7 @@ func TestServer(t *testing.T) {
})
require.NoError(t, err)
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.NoError(t, <-errC)
})
t.Run("BuiltinPostgres", func(t *testing.T) {
t.Parallel()
@@ -101,7 +101,7 @@ func TestServer(t *testing.T) {
return err == nil && rawURL != ""
}, 3*time.Minute, testutil.IntervalFast, "failed to get access URL")
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.NoError(t, <-errC)
})
t.Run("BuiltinPostgresURL", func(t *testing.T) {
t.Parallel()
@@ -129,8 +129,9 @@ func TestServer(t *testing.T) {
"--access-url", "localhost:3000/",
"--cache-dir", t.TempDir(),
)
buf := newThreadSafeBuffer()
root.SetOutput(buf)
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
errC := make(chan error, 1)
go func() {
errC <- root.ExecuteContext(ctx)
@@ -139,10 +140,11 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI: http://localhost:3000/")
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.Contains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
require.Contains(t, buf.String(), "View the Web UI: http://localhost:3000/\n")
require.NoError(t, <-errC)
})
// Validate that an https scheme is prepended to a remote access URL
@@ -159,8 +161,9 @@ func TestServer(t *testing.T) {
"--access-url", "foobarbaz.mydomain",
"--cache-dir", t.TempDir(),
)
buf := newThreadSafeBuffer()
root.SetOutput(buf)
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
errC := make(chan error, 1)
go func() {
errC <- root.ExecuteContext(ctx)
@@ -169,10 +172,11 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI: https://foobarbaz.mydomain")
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.Contains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
require.Contains(t, buf.String(), "View the Web UI: https://foobarbaz.mydomain\n")
require.NoError(t, <-errC)
})
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
@@ -187,8 +191,9 @@ func TestServer(t *testing.T) {
"--access-url", "https://google.com",
"--cache-dir", t.TempDir(),
)
buf := newThreadSafeBuffer()
root.SetOutput(buf)
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
errC := make(chan error, 1)
go func() {
errC <- root.ExecuteContext(ctx)
@@ -197,10 +202,10 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("View the Web UI: https://google.com")
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.NotContains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
require.Contains(t, buf.String(), "View the Web UI: https://google.com\n")
require.NoError(t, <-errC)
})
t.Run("TLSBadVersion", func(t *testing.T) {
@@ -286,7 +291,7 @@ func TestServer(t *testing.T) {
require.NoError(t, err)
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.NoError(t, <-errC)
})
// This cannot be ran in parallel because it uses a signal.
//nolint:paralleltest
@@ -317,7 +322,7 @@ func TestServer(t *testing.T) {
// We cannot send more signals here, because it's possible Coder
// has already exited, which could cause the test to fail due to interrupt.
err = <-serverErr
require.ErrorIs(t, err, context.Canceled)
require.NoError(t, err)
})
t.Run("TracerNoLeak", func(t *testing.T) {
t.Parallel()
@@ -336,7 +341,7 @@ func TestServer(t *testing.T) {
errC <- root.ExecuteContext(ctx)
}()
cancelFunc()
require.ErrorIs(t, <-errC, context.Canceled)
require.NoError(t, <-errC)
require.Error(t, goleak.Find())
})
t.Run("Telemetry", func(t *testing.T) {
@@ -410,6 +415,7 @@ func TestServer(t *testing.T) {
require.Eventually(t, func() bool {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://127.0.0.1:%d", randomPort), nil)
assert.NoError(t, err)
// nolint:bodyclose
res, err = http.DefaultClient.Do(req)
return err == nil
}, testutil.WaitShort, testutil.IntervalFast)
@@ -461,7 +467,9 @@ func TestServer(t *testing.T) {
}
githubURL, err := accessURL.Parse("/api/v2/users/oauth2/github")
require.NoError(t, err)
res, err := client.HTTPClient.Get(githubURL.String())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubURL.String(), nil)
require.NoError(t, err)
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
fakeURL, err := res.Location()
+7 -2
View File
@@ -11,13 +11,17 @@ func show() *cobra.Command {
return &cobra.Command{
Annotations: workspaceCommand,
Use: "show <workspace>",
Short: "Show details of a workspace's resources and agents",
Short: "Display details of a workspace's resources and agents",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
buildInfo, err := client.BuildInfo(cmd.Context())
if err != nil {
return xerrors.Errorf("get server version: %w", err)
}
workspace, err := namedWorkspace(cmd, client, args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
@@ -28,6 +32,7 @@ func show() *cobra.Command {
}
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
WorkspaceName: workspace.Name,
ServerVersion: buildInfo.Version,
})
},
}
+1 -1
View File
@@ -15,7 +15,7 @@ func TestShow(t *testing.T) {
t.Parallel()
t.Run("Exists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package cli
import (
"os"
"syscall"
)
var interruptSignals = []os.Signal{
os.Interrupt,
syscall.SIGTERM,
syscall.SIGHUP,
}
+9
View File
@@ -0,0 +1,9 @@
//go:build windows
package cli
import (
"os"
)
var interruptSignals = []os.Signal{os.Interrupt}
+123
View File
@@ -0,0 +1,123 @@
package cli
import (
"context"
"fmt"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
tsspeedtest "tailscale.com/net/speedtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func speedtest() *cobra.Command {
var (
direct bool
duration time.Duration
reverse bool
)
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "speedtest <workspace>",
Args: cobra.ExactArgs(1),
Short: "Run upload and download tests from your machine to a workspace",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create codersdk client: %w", err)
}
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
if err != nil {
return err
}
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
WorkspaceName: workspace.Name,
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
},
})
if err != nil {
return xerrors.Errorf("await agent: %w", err)
}
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
if cliflag.IsSetBool(cmd, varVerbose) {
logger = logger.Leveled(slog.LevelDebug)
}
conn, err := client.DialWorkspaceAgentTailnet(ctx, logger, workspaceAgent.ID)
if err != nil {
return err
}
defer conn.Close()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
dur, err := conn.Ping()
if err != nil {
continue
}
status := conn.Status()
if len(status.Peers()) != 1 {
continue
}
peer := status.Peer[status.Peers()[0]]
if peer.CurAddr == "" && direct {
cmd.Printf("Waiting for a direct connection... (%dms via %s)\n", dur.Milliseconds(), peer.Relay)
continue
}
via := peer.Relay
if via == "" {
via = "direct"
}
cmd.Printf("%dms via %s\n", dur.Milliseconds(), via)
break
}
dir := tsspeedtest.Download
if reverse {
dir = tsspeedtest.Upload
}
cmd.Printf("Starting a %ds %s test...\n", int(duration.Seconds()), dir)
results, err := conn.Speedtest(dir, duration)
if err != nil {
return err
}
tableWriter := cliui.Table()
tableWriter.AppendHeader(table.Row{"Interval", "Transfer", "Bandwidth"})
for _, r := range results {
if r.Total {
tableWriter.AppendSeparator()
}
tableWriter.AppendRow(table.Row{
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Seconds(), r.IntervalEnd.Seconds()),
fmt.Sprintf("%.4f MBits", r.MegaBits()),
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
})
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
return err
},
}
cliflag.BoolVarP(cmd.Flags(), &direct, "direct", "d", "", false,
"Specifies whether to wait for a direct connection before testing speed.")
cliflag.BoolVarP(cmd.Flags(), &reverse, "reverse", "r", "", false,
"Specifies whether to run in reverse mode where the client receives and the server sends.")
cmd.Flags().DurationVarP(&duration, "time", "t", tsspeedtest.DefaultDuration,
"Specifies the duration to monitor traffic.")
return cmd
}
+46
View File
@@ -0,0 +1,46 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func TestSpeedtest(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
}
client, workspace, agentToken := setupWorkspaceForAgent(t)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer agentCloser.Close()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "speedtest", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetOut(pty.Output())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
cmdDone := tGo(t, func() {
err := cmd.ExecuteContext(ctx)
assert.NoError(t, err)
})
<-cmdDone
}
+39 -94
View File
@@ -19,22 +19,21 @@ import (
gosshagent "golang.org/x/crypto/ssh/agent"
"golang.org/x/term"
"golang.org/x/xerrors"
"inet.af/netaddr"
tslogger "tailscale.com/types/logger"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/autobuild/notify"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/peer/peerwg"
)
var workspacePollInterval = time.Minute
var autostopNotifyCountdown = []time.Duration{30 * time.Minute}
var (
workspacePollInterval = time.Minute
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
)
func ssh() *cobra.Command {
var (
@@ -43,18 +42,17 @@ func ssh() *cobra.Command {
forwardAgent bool
identityAgent string
wsPollInterval time.Duration
wireguard bool
)
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "ssh <workspace>",
Short: "SSH into a workspace",
Short: "Start a shell into a workspace",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -88,87 +86,30 @@ func ssh() *cobra.Command {
return xerrors.Errorf("await agent: %w", err)
}
var newSSHClient func() (*gossh.Client, error)
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
if err != nil {
return err
}
defer conn.Close()
if !wireguard {
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil)
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
defer stopPolling()
if stdio {
rawSSH, err := conn.SSH()
if err != nil {
return err
}
defer conn.Close()
defer rawSSH.Close()
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
defer stopPolling()
if stdio {
rawSSH, err := conn.SSH()
if err != nil {
return err
}
defer rawSSH.Close()
go func() {
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
}()
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
return nil
}
newSSHClient = conn.SSHClient
} else {
// TODO: more granual control of Tailscale logging.
peerwg.Logf = tslogger.Discard
ipv6 := peerwg.UUIDToNetaddr(uuid.New())
wgn, err := peerwg.New(
slog.Make(sloghuman.Sink(cmd.ErrOrStderr())),
[]netaddr.IPPrefix{netaddr.IPPrefixFrom(ipv6, 128)},
)
if err != nil {
return xerrors.Errorf("create wireguard network: %w", err)
}
defer wgn.Close()
err = client.PostWireguardPeer(ctx, workspace.ID, peerwg.Handshake{
Recipient: workspaceAgent.ID,
NodePublicKey: wgn.NodePrivateKey.Public(),
DiscoPublicKey: wgn.DiscoPublicKey,
IPv6: ipv6,
})
if err != nil {
return xerrors.Errorf("post wireguard peer: %w", err)
}
err = wgn.AddPeer(peerwg.Handshake{
Recipient: workspaceAgent.ID,
DiscoPublicKey: workspaceAgent.DiscoPublicKey,
NodePublicKey: workspaceAgent.WireguardPublicKey,
IPv6: workspaceAgent.IPv6.IP(),
})
if err != nil {
return xerrors.Errorf("add workspace agent as peer: %w", err)
}
if stdio {
rawSSH, err := wgn.SSH(ctx, workspaceAgent.IPv6.IP())
if err != nil {
return err
}
defer rawSSH.Close()
go func() {
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
}()
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
return nil
}
newSSHClient = func() (*gossh.Client, error) {
return wgn.SSHClient(ctx, workspaceAgent.IPv6.IP())
}
go func() {
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
}()
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
return nil
}
sshClient, err := newSSHClient()
sshClient, err := conn.SSHClient()
if err != nil {
return err
}
@@ -247,6 +188,13 @@ func ssh() *cobra.Command {
// shutdown of services.
defer cancel()
if validOut {
// Set initial window size.
width, height, err := term.GetSize(int(stdoutFile.Fd()))
if err == nil {
_ = sshSession.WindowChange(height, width)
}
}
err = sshSession.Wait()
if err != nil {
// If the connection drops unexpectedly, we get an ExitMissingError but no other
@@ -266,9 +214,6 @@ func ssh() *cobra.Command {
cliflag.BoolVarP(cmd.Flags(), &forwardAgent, "forward-agent", "A", "CODER_SSH_FORWARD_AGENT", false, "Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK")
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_SSH_WIREGUARD", false, "Whether to use Wireguard for SSH tunneling.")
_ = cmd.Flags().MarkHidden("wireguard")
return cmd
}
@@ -328,34 +273,34 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
if len(agents) == 0 {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
}
var agent codersdk.WorkspaceAgent
var workspaceAgent codersdk.WorkspaceAgent
if len(workspaceParts) >= 2 {
for _, otherAgent := range agents {
if otherAgent.Name != workspaceParts[1] {
continue
}
agent = otherAgent
workspaceAgent = otherAgent
break
}
if agent.ID == uuid.Nil {
if workspaceAgent.ID == uuid.Nil {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1])
}
}
if agent.ID == uuid.Nil {
if workspaceAgent.ID == uuid.Nil {
if len(agents) > 1 {
if !shuffle {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("you must specify the name of an agent")
}
agent, err = cryptorand.Element(agents)
workspaceAgent, err = cryptorand.Element(agents)
if err != nil {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
}
} else {
agent = agents[0]
workspaceAgent = agents[0]
}
}
return workspace, agent, nil
return workspace, workspaceAgent, nil
}
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
@@ -385,7 +330,7 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
return time.Time{}, nil
}
deadline = ws.LatestBuild.Deadline
deadline = ws.LatestBuild.Deadline.Time
callback = func() {
ttl := deadline.Sub(now)
var title, body string
+17 -12
View File
@@ -19,7 +19,6 @@ import (
"golang.org/x/crypto/ssh"
gosshagent "golang.org/x/crypto/ssh/agent"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
@@ -32,9 +31,9 @@ import (
"github.com/coder/coder/testutil"
)
func setupWorkspaceForSSH(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
func setupWorkspaceForAgent(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
t.Helper()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -70,7 +69,7 @@ func TestSSH(t *testing.T) {
t.Run("ImmediateExit", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForSSH(t)
client, workspace, agentToken := setupWorkspaceForAgent(t)
cmd, root := clitest.New(t, "ssh", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
@@ -89,8 +88,10 @@ func TestSSH(t *testing.T) {
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer func() {
_ = agentCloser.Close()
@@ -102,14 +103,16 @@ func TestSSH(t *testing.T) {
})
t.Run("Stdio", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForSSH(t)
client, workspace, agentToken := setupWorkspaceForAgent(t)
_, _ = tGoContext(t, func(ctx context.Context) {
// Run this async so the SSH command has to wait for
// the build and agent to connect!
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
<-ctx.Done()
_ = agentCloser.Close()
@@ -170,12 +173,14 @@ func TestSSH(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForSSH(t)
client, workspace, agentToken := setupWorkspaceForAgent(t)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer agentCloser.Close()
+2 -10
View File
@@ -14,18 +14,10 @@ func start() *cobra.Command {
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "start <workspace>",
Short: "Build a workspace with the start state",
Short: "Start a workspace",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Confirm start workspace?",
IsConfirm: true,
})
if err != nil {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
+15 -14
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"time"
"github.com/spf13/cobra"
@@ -22,24 +23,24 @@ func state() *cobra.Command {
}
func statePull() *cobra.Command {
var buildName string
var buildNumber int
cmd := &cobra.Command{
Use: "pull <workspace> [file]",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := namedWorkspace(cmd, client, args[0])
client, err := CreateClient(cmd)
if err != nil {
return err
}
var build codersdk.WorkspaceBuild
if buildName == "latest" {
if buildNumber == 0 {
workspace, err := namedWorkspace(cmd, client, args[0])
if err != nil {
return err
}
build = workspace.LatestBuild
} else {
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
build, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(cmd.Context(), codersdk.Me, args[0], strconv.Itoa(buildNumber))
if err != nil {
return err
}
@@ -58,17 +59,17 @@ func statePull() *cobra.Command {
return os.WriteFile(args[1], state, 0600)
},
}
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
cmd.Flags().IntVarP(&buildNumber, "build", "b", 0, "Specify a workspace build to target by name.")
return cmd
}
func statePush() *cobra.Command {
var buildName string
var buildNumber int
cmd := &cobra.Command{
Use: "push <workspace> <file>",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -77,10 +78,10 @@ func statePush() *cobra.Command {
return err
}
var build codersdk.WorkspaceBuild
if buildName == "latest" {
if buildNumber == 0 {
build = workspace.LatestBuild
} else {
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
build, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(cmd.Context(), codersdk.Me, args[0], strconv.Itoa(buildNumber))
if err != nil {
return err
}
@@ -108,6 +109,6 @@ func statePush() *cobra.Command {
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID, before)
},
}
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
cmd.Flags().IntVarP(&buildNumber, "build", "b", 0, "Specify a workspace build to target by name.")
return cmd
}
+6 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -19,7 +20,7 @@ func TestStatePull(t *testing.T) {
t.Parallel()
t.Run("File", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
wantState := []byte("some state")
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -47,7 +48,7 @@ func TestStatePull(t *testing.T) {
})
t.Run("Stdout", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
wantState := []byte("some state")
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -78,7 +79,7 @@ func TestStatePush(t *testing.T) {
t.Parallel()
t.Run("File", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
@@ -103,7 +104,7 @@ func TestStatePush(t *testing.T) {
t.Run("Stdin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
@@ -113,7 +114,7 @@ func TestStatePush(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmd, root := clitest.New(t, "state", "push", "--build", workspace.LatestBuild.Name, workspace.Name, "-")
cmd, root := clitest.New(t, "state", "push", "--build", strconv.Itoa(int(workspace.LatestBuild.BuildNumber)), workspace.Name, "-")
clitest.SetupConfig(t, client, root)
cmd.SetIn(strings.NewReader("some magic state"))
err := cmd.Execute()
+2 -2
View File
@@ -14,7 +14,7 @@ func stop() *cobra.Command {
cmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "stop <workspace>",
Short: "Build a workspace with the stop state",
Short: "Stop a workspace",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
@@ -25,7 +25,7 @@ func stop() *cobra.Command {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
+10 -2
View File
@@ -2,10 +2,12 @@ package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/briandowns/spinner"
"github.com/spf13/cobra"
@@ -32,7 +34,7 @@ func templateCreate() *cobra.Command {
Short: "Create a template from the current directory or as specified by flag",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -49,6 +51,10 @@ func templateCreate() *cobra.Command {
templateName = args[0]
}
if utf8.RuneCountInString(templateName) > 31 {
return xerrors.Errorf("Template name must be less than 32 characters")
}
_, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
if err == nil {
return xerrors.Errorf("A template already exists named %q!", templateName)
@@ -138,6 +144,7 @@ func templateCreate() *cobra.Command {
}
type createValidTemplateVersionArgs struct {
Name string
Client *codersdk.Client
Organization codersdk.Organization
Provisioner database.ProvisionerType
@@ -156,6 +163,7 @@ func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVers
client := args.Client
req := codersdk.CreateTemplateVersionRequest{
Name: args.Name,
StorageMethod: codersdk.ProvisionerStorageMethodFile,
StorageSource: args.FileHash,
Provisioner: codersdk.ProvisionerType(args.Provisioner),
@@ -177,7 +185,7 @@ func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVers
Cancel: func() error {
return client.CancelTemplateVersion(cmd.Context(), version.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
return client.TemplateVersionLogsAfter(cmd.Context(), version.ID, before)
},
})
+20 -5
View File
@@ -40,7 +40,7 @@ func TestTemplateCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: echo.ParseComplete,
@@ -87,7 +87,7 @@ func TestTemplateCreate(t *testing.T) {
t.Run("WithParameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: createTestParseResponse(),
@@ -123,7 +123,7 @@ func TestTemplateCreate(t *testing.T) {
t.Run("WithParameterFileContainingTheValue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: createTestParseResponse(),
@@ -162,7 +162,7 @@ func TestTemplateCreate(t *testing.T) {
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: createTestParseResponse(),
@@ -200,7 +200,7 @@ func TestTemplateCreate(t *testing.T) {
t.Run("Recreate template with same name (create, delete, create)", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
create := func() error {
@@ -241,6 +241,21 @@ func TestTemplateCreate(t *testing.T) {
err = create()
require.NoError(t, err, "Template must be recreated without error")
})
t.Run("WithParameterExceedingCharLimit", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "templates", "create", "1234567890123456789012345678901234567891", "--test.provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, client, root)
execDone := make(chan error)
go func() {
execDone <- cmd.Execute()
}()
require.EqualError(t, <-execDone, "Template name must be less than 32 characters")
})
}
func createTestParseResponse() []*proto.Parse_Response {
+1 -1
View File
@@ -23,7 +23,7 @@ func templateDelete() *cobra.Command {
templates = []codersdk.Template{}
)
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
+4 -4
View File
@@ -21,7 +21,7 @@ func TestTemplateDelete(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -51,7 +51,7 @@ func TestTemplateDelete(t *testing.T) {
t.Run("Multiple --yes", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -78,7 +78,7 @@ func TestTemplateDelete(t *testing.T) {
t.Run("Multiple prompted", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -117,7 +117,7 @@ func TestTemplateDelete(t *testing.T) {
t.Run("Selector", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+9 -3
View File
@@ -13,7 +13,9 @@ import (
func templateEdit() *cobra.Command {
var (
name string
description string
icon string
maxTTL time.Duration
minAutostartInterval time.Duration
)
@@ -23,7 +25,7 @@ func templateEdit() *cobra.Command {
Args: cobra.ExactArgs(1),
Short: "Edit the metadata of a template by name.",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
@@ -38,7 +40,9 @@ func templateEdit() *cobra.Command {
// NOTE: coderd will ignore empty fields.
req := codersdk.UpdateTemplateMeta{
Name: name,
Description: description,
Icon: icon,
MaxTTLMillis: maxTTL.Milliseconds(),
MinAutostartIntervalMillis: minAutostartInterval.Milliseconds(),
}
@@ -52,9 +56,11 @@ func templateEdit() *cobra.Command {
},
}
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name")
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
cmd.Flags().DurationVarP(&maxTTL, "max_ttl", "", 0, "Edit the template maximum time before shutdown")
cmd.Flags().DurationVarP(&minAutostartInterval, "min_autostart_interval", "", 0, "Edit the template minimum autostart interval")
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 0, "Edit the template maximum time before shutdown - workspaces created from this template cannot stay running longer than this.")
cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", 0, "Edit the template minimum autostart interval - workspaces created from this template must wait at least this long between autostarts.")
cliui.AllowSkipPrompt(cmd)
return cmd
+18 -6
View File
@@ -19,27 +19,32 @@ func TestTemplateEdit(t *testing.T) {
t.Run("Modified", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.Icon = "/icons/default-icon.png"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
// Test the cli command.
name := "new-template-name"
desc := "lorem ipsum dolor sit amet et cetera"
icon := "/icons/new-icon.png"
maxTTL := 12 * time.Hour
minAutostartInterval := time.Minute
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--name", name,
"--description", desc,
"--max_ttl", maxTTL.String(),
"--min_autostart_interval", minAutostartInterval.String(),
"--icon", icon,
"--max-ttl", maxTTL.String(),
"--min-autostart-interval", minAutostartInterval.String(),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@@ -51,19 +56,22 @@ func TestTemplateEdit(t *testing.T) {
// Assert that the template metadata changed.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, name, updated.Name)
assert.Equal(t, desc, updated.Description)
assert.Equal(t, icon, updated.Icon)
assert.Equal(t, maxTTL.Milliseconds(), updated.MaxTTLMillis)
assert.Equal(t, minAutostartInterval.Milliseconds(), updated.MinAutostartIntervalMillis)
})
t.Run("NotModified", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.Icon = "/icons/default-icon.png"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
@@ -73,9 +81,11 @@ func TestTemplateEdit(t *testing.T) {
"templates",
"edit",
template.Name,
"--name", template.Name,
"--description", template.Description,
"--max_ttl", (time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
"--min_autostart_interval", (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
"--icon", template.Icon,
"--max-ttl", (time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
"--min-autostart-interval", (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@@ -87,7 +97,9 @@ func TestTemplateEdit(t *testing.T) {
// Assert that the template metadata did not change.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Name, updated.Name)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.Icon, updated.Icon)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
})
+1 -1
View File
@@ -36,7 +36,7 @@ func templateInit() *cobra.Command {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render(
"A template defines infrastructure as code to be provisioned "+
"for individual developer workspaces. Select an example to get started:\n"))
"for individual developer workspaces. Select an example to be copied to the active directory:\n"))
option, err := cliui.Select(cmd, cliui.SelectOptions{
Options: exampleNames,
})
+9 -4
View File
@@ -16,7 +16,7 @@ func templateList() *cobra.Command {
Short: "List all the templates available for the organization",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -30,12 +30,17 @@ func templateList() *cobra.Command {
}
if len(templates) == 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s No templates found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name))
_, _ = fmt.Fprintln(cmd.OutOrStdout(), color.HiMagentaString(" $ coder templates create <directory>\n"))
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No templates found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name))
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), color.HiMagentaString(" $ coder templates create <directory>\n"))
return nil
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayTemplates(columns, templates...))
out, err := displayTemplates(columns, templates...)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
return err
},
}
+6 -4
View File
@@ -15,7 +15,7 @@ func TestTemplateList(t *testing.T) {
t.Parallel()
t.Run("ListTemplates", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
firstVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, firstVersion.ID)
@@ -49,7 +49,7 @@ func TestTemplateList(t *testing.T) {
})
t.Run("NoTemplates", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{})
coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "templates", "list")
@@ -57,7 +57,7 @@ func TestTemplateList(t *testing.T) {
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
errC := make(chan error)
go func() {
@@ -66,6 +66,8 @@ func TestTemplateList(t *testing.T) {
require.NoError(t, <-errC)
pty.ExpectMatch("No templates found in testuser! Create one:")
pty.ExpectMatch("No templates found in")
pty.ExpectMatch(coderdtest.FirstUserParams.Username)
pty.ExpectMatch("Create one:")
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ func templatePlan() *cobra.Command {
return &cobra.Command{
Use: "plan <directory>",
Args: cobra.MinimumNArgs(1),
Short: "Plan a template update from the current directory",
Short: "Plan a template push from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
+1 -1
View File
@@ -29,7 +29,7 @@ func templatePull() *cobra.Command {
dest = args[1]
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
+2 -2
View File
@@ -32,7 +32,7 @@ func TestTemplatePull(t *testing.T) {
t.Run("Stdout", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
// Create an initial template bundle.
@@ -70,7 +70,7 @@ func TestTemplatePull(t *testing.T) {
t.Run("ToFile", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
// Create an initial template bundle.
+4 -1
View File
@@ -19,6 +19,7 @@ import (
func templatePush() *cobra.Command {
var (
directory string
versionName string
provisioner string
parameterFile string
alwaysPrompt bool
@@ -29,7 +30,7 @@ func templatePush() *cobra.Command {
Args: cobra.MaximumNArgs(1),
Short: "Push a new template version from the current directory or as specified by flag",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}
@@ -75,6 +76,7 @@ func templatePush() *cobra.Command {
spin.Stop()
job, _, err := createValidTemplateVersion(cmd, createValidTemplateVersionArgs{
Name: versionName,
Client: client,
Organization: organization,
Provisioner: database.ProvisionerType(provisioner),
@@ -107,6 +109,7 @@ func templatePush() *cobra.Command {
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
cmd.Flags().StringVarP(&parameterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
cmd.Flags().StringVarP(&versionName, "name", "", "", "Specify a name for the new template version. It will be automatically generated if not provided.")
cmd.Flags().BoolVar(&alwaysPrompt, "always-prompt", false, "Always prompt all parameters. Does not pull parameter values from active template version")
cliui.AllowSkipPrompt(cmd)
// This is for testing!
+5 -4
View File
@@ -31,7 +31,7 @@ func TestTemplatePush(t *testing.T) {
// 7. Asset 0 params in new version
t.Run("NewParameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
// Create initial template version to update
lastActiveVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
@@ -110,7 +110,7 @@ func TestTemplatePush(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
@@ -122,7 +122,7 @@ func TestTemplatePush(t *testing.T) {
Parse: echo.ParseComplete,
Provision: echo.ProvisionComplete,
})
cmd, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
cmd, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
@@ -153,11 +153,12 @@ func TestTemplatePush(t *testing.T) {
require.NoError(t, err)
assert.Len(t, templateVersions, 2)
assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID)
require.Equal(t, "example", templateVersions[1].Name)
})
t.Run("UseWorkingDir", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+30 -29
View File
@@ -1,10 +1,9 @@
package cli
import (
"fmt"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
@@ -14,7 +13,8 @@ import (
func templates() *cobra.Command {
cmd := &cobra.Command{
Use: "templates",
Short: "Create, manage, and deploy templates",
Short: "Manage templates",
Long: "Templates are written in standard Terraform and describe the infrastructure for workspaces",
Aliases: []string{"template"},
Example: formatExamples(
example{
@@ -46,35 +46,36 @@ func templates() *cobra.Command {
return cmd
}
type templateTableRow struct {
Name string `table:"name"`
CreatedAt string `table:"created at"`
LastUpdated string `table:"last updated"`
OrganizationID uuid.UUID `table:"organization id"`
Provisioner codersdk.ProvisionerType `table:"provisioner"`
ActiveVersionID uuid.UUID `table:"active version id"`
UsedBy string `table:"used by"`
MaxTTL time.Duration `table:"max ttl"`
MinAutostartInterval time.Duration `table:"min autostart"`
}
// displayTemplates will return a table displaying all templates passed in.
// filterColumns must be a subset of the template fields and will determine which
// columns to display
func displayTemplates(filterColumns []string, templates ...codersdk.Template) string {
tableWriter := cliui.Table()
header := table.Row{
"Name", "Created At", "Last Updated", "Organization ID", "Provisioner",
"Active Version ID", "Used By", "Max TTL", "Min Autostart"}
tableWriter.AppendHeader(header)
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
tableWriter.SortBy([]table.SortBy{{
Name: "name",
}})
for _, template := range templates {
suffix := ""
if template.WorkspaceOwnerCount != 1 {
suffix = "s"
func displayTemplates(filterColumns []string, templates ...codersdk.Template) (string, error) {
rows := make([]templateTableRow, len(templates))
for i, template := range templates {
rows[i] = templateTableRow{
Name: template.Name,
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
LastUpdated: template.UpdatedAt.Format("January 2, 2006"),
OrganizationID: template.OrganizationID,
Provisioner: template.Provisioner,
ActiveVersionID: template.ActiveVersionID,
UsedBy: cliui.Styles.Fuchsia.Render(formatActiveDevelopers(template.ActiveUserCount)),
MaxTTL: (time.Duration(template.MaxTTLMillis) * time.Millisecond),
MinAutostartInterval: (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond),
}
tableWriter.AppendRow(table.Row{
template.Name,
template.CreatedAt.Format("January 2, 2006"),
template.UpdatedAt.Format("January 2, 2006"),
template.OrganizationID.String(),
template.Provisioner,
template.ActiveVersionID.String(),
cliui.Styles.Fuchsia.Render(fmt.Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)),
(time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
(time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
})
}
return tableWriter.Render()
return cliui.DisplayTable(rows, "name", filterColumns)
}
+30 -17
View File
@@ -3,9 +3,9 @@ package cli
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
@@ -38,7 +38,7 @@ func templateVersionsList() *cobra.Command {
Args: cobra.ExactArgs(1),
Short: "List all the versions of the specified template",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
@@ -58,31 +58,44 @@ func templateVersionsList() *cobra.Command {
if err != nil {
return xerrors.Errorf("get template versions by template: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayTemplateVersions(template.ActiveVersionID, versions...))
out, err := displayTemplateVersions(template.ActiveVersionID, versions...)
if err != nil {
return xerrors.Errorf("render table: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
return err
},
}
}
type templateVersionRow struct {
Name string `table:"name"`
CreatedAt time.Time `table:"created at"`
CreatedBy string `table:"created by"`
Status string `table:"status"`
Active string `table:"active"`
}
// displayTemplateVersions will return a table displaying existing
// template versions for the specified template.
func displayTemplateVersions(activeVersionID uuid.UUID, templateVersions ...codersdk.TemplateVersion) string {
tableWriter := cliui.Table()
header := table.Row{
"Name", "Created At", "Created By", "Status", ""}
tableWriter.AppendHeader(header)
for _, templateVersion := range templateVersions {
func displayTemplateVersions(activeVersionID uuid.UUID, templateVersions ...codersdk.TemplateVersion) (string, error) {
rows := make([]templateVersionRow, len(templateVersions))
for i, templateVersion := range templateVersions {
var activeStatus = ""
if templateVersion.ID == activeVersionID {
activeStatus = cliui.Styles.Code.Render(cliui.Styles.Keyword.Render("Active"))
}
tableWriter.AppendRow(table.Row{
templateVersion.Name,
templateVersion.CreatedAt.Format("03:04:05 PM MST on Jan 2, 2006"),
templateVersion.CreatedByName,
strings.Title(string(templateVersion.Job.Status)),
activeStatus,
})
rows[i] = templateVersionRow{
Name: templateVersion.Name,
CreatedAt: templateVersion.CreatedAt,
CreatedBy: templateVersion.CreatedByName,
Status: strings.Title(string(templateVersion.Job.Status)),
Active: activeStatus,
}
}
return tableWriter.Render()
return cliui.DisplayTable(rows, "name", nil)
}

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